dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

commit 351320c1afe60b05c811d7987deb0ff4ff8c5d11
parent e2e708835a5ebe969fc44559ab6a866a4c81161a
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Mon, 30 Jan 2023 10:21:36 +0900

feat: 위키 및 페이지 관리 페이지 추가

Signed-off-by: Yongbin Kim <iam@yongbin.kim>

Diffstat:
Mcomponents/wiki/WikiBase.tsx | 3++-
Mcomponents/wiki/WikiToolbar.tsx | 8+++++++-
Mlib/models/wiki_info.ts | 15+++++++++++++++
Mlib/models/wiki_page.ts | 17+++++++++++++++++
Mpages/api/wiki/[slug]/[...path].tsx | 56+++++++++++++++++++++++++++++++-------------------------
Mpages/api/wiki/[slug]/index.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Apages/manage/[slug]/[...path].tsx | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpages/wiki/[slug]/[...path].tsx | 10++++++++--
8 files changed, 292 insertions(+), 33 deletions(-)

diff --git a/components/wiki/WikiBase.tsx b/components/wiki/WikiBase.tsx @@ -8,6 +8,7 @@ import { ReactNode } from 'react' export interface WikiBaseProps { pageKind: WikiToolbarProps['pageKind'] title: ReactNode + enableManage?: boolean children: ReactNode } @@ -19,7 +20,7 @@ export default function WikiBase (props: WikiBaseProps) { </Hero> <Container> - <WikiToolbar pageKind={props.pageKind} /> + <WikiToolbar pageKind={props.pageKind} enableManage={props.enableManage} /> {props.children} </Container> diff --git a/components/wiki/WikiToolbar.tsx b/components/wiki/WikiToolbar.tsx @@ -4,7 +4,8 @@ import { useRouter } from 'next/router' import styles from './WikiToolbar.module.css'; export interface WikiToolbarProps { - pageKind: 'wiki' | 'edit' | 'talk' | 'logs' + pageKind: 'wiki' | 'edit' | 'talk' | 'logs' | 'manage' + enableManage?: boolean } export default function WikiToolbar (props: WikiToolbarProps) { @@ -34,6 +35,11 @@ export default function WikiToolbar (props: WikiToolbarProps) { History </Link> )} + {props.enableManage && ( + <Link href={`/manage/${slug}/${path}`} className={styles['item']}> + Manage + </Link> + )} </div> ) } diff --git a/lib/models/wiki_info.ts b/lib/models/wiki_info.ts @@ -136,3 +136,18 @@ export const getWikiViaSlug = modelBehaviour< updatedAt: row[7], } }) + +const SQL_UPDATE_WIKI = ` + update wikis + set title = ?, description = ?, acl_data = ?, updated_at = now() + where id = ? +` + +export const updateWiki = modelBehaviour< + [ title: string, description: string | null, acl: string | null, id: number ], + void +>(async (conn, args) => { + await conn.query<OkPacket>({ + sql: SQL_UPDATE_WIKI, + }, args) +}) diff --git a/lib/models/wiki_page.ts b/lib/models/wiki_page.ts @@ -29,6 +29,23 @@ export const putWikiPage = modelBehaviour< return result.insertId }) +const SQL_UPDATE_WIKI_PAGE_ACL = ` + update wiki_pages + set acl_data = ? + where id = ? +` + +export const updateWikiPageAcl = modelBehaviour< + [id: number, acl: ACL | string], + number +>(async (conn, args) => { + const [result] = await conn.query<OkPacket>({ + sql: SQL_UPDATE_WIKI_PAGE_ACL, + }, [args[1], args[0]]) + + return result.affectedRows +}) + const SQL_GET_WIKI_PAGE = ` select wiki_id, path, text_id, acl_data, created_at, updated_at from wiki_pages diff --git a/pages/api/wiki/[slug]/[...path].tsx b/pages/api/wiki/[slug]/[...path].tsx @@ -1,12 +1,12 @@ -import { ApiError, ERR_INTERNAL, ERR_NOT_FOUND } from '@/lib/apierror' +import { ApiError, ERR_NOT_FOUND } from '@/lib/apierror' import { ERR_CODE_EMPTY_CONTENT } from '@/lib/error_codes' import { withConnection } from '@/lib/model_helpers' import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_acl' import { createWikiChange } from '@/lib/models/wiki_change' import { getWiki, getWikiViaSlug } from '@/lib/models/wiki_info' -import { getWikiPage as modelGetWikiPage, putWikiPage } from '@/lib/models/wiki_page' +import { getWikiPage as modelGetWikiPage, putWikiPage, updateWikiPageAcl } from '@/lib/models/wiki_page' import { createWikiText } from '@/lib/models/wiki_text' -import { ACL_ACTION_READ, ACL_ACTION_WRITE, resolveACL } from '@/lib/security/acl' +import { ACL, ACL_ACTION_MANAGE, ACL_ACTION_READ, ACL_ACTION_WRITE, resolveACL } from '@/lib/security/acl' import { AccessTokenPayload, authenticationFromCookies } from '@/lib/security/token' import { getRemoteIp } from '@/lib/utils/ip' import { getSlugAndPath } from '@/lib/utils/wiki' @@ -26,16 +26,6 @@ export default async function handler (req: NextApiRequest, res: NextApiResponse } } -function getParams (req: NextApiRequest, res: NextApiResponse) { - const { slug, path } = req.query - if (typeof slug !== 'string' || !Array.isArray(path)) { - res.status(500).json(ERR_INTERNAL) - return - } - - return { slug, path: path.join('/') } -} - export async function getWikiPage ( conn: Connection, token: AccessTokenPayload | null, @@ -88,6 +78,7 @@ async function handleGet (req: NextApiRequest, res: NextApiResponse) { export interface WikiPagePutRequest { content?: string + acl?: string } async function handlePut ( @@ -101,8 +92,8 @@ async function handlePut ( return } - const { content } = req.body as WikiPagePutRequest - if (content == null || content.length === 0) { + const { content, acl } = req.body as WikiPagePutRequest + if ((content == null || content.length === 0) && acl == null) { res.status(400).json({ code: ERR_CODE_EMPTY_CONTENT, message: 'content is empty' }) return } @@ -110,20 +101,35 @@ async function handlePut ( const token = await authenticationFromCookies(req.cookies) await withConnection(async (conn) => { - const acl = await getWikiAndPageACLViaPath(conn, [slug, path]) - if (acl == null || !resolveACL(token, [acl.wiki, acl.page], ACL_ACTION_WRITE)) { + const aclInfo = await getWikiAndPageACLViaPath(conn, [slug, path]) + if (aclInfo == null) { res.status(404).json(ERR_NOT_FOUND) return } - const textId = await createWikiText(conn, [content, 'utf-8']) - const pageId = await putWikiPage(conn, [acl.wikiId, path, textId]) - await createWikiChange(conn, [ - pageId, - token?.uid ?? null, - token?.uid == null ? getRemoteIp(req) : null, - textId, - ]) + if (acl != null) { + if (aclInfo.pageId == null || !resolveACL(token, aclInfo.wiki, ACL_ACTION_MANAGE)) { + res.status(403).json({ code: 'ERR_FORBIDDEN', message: 'forbidden' }) + return + } + await updateWikiPageAcl(conn, [aclInfo.pageId, acl]) + } + + if (content != null) { + if (!resolveACL(token, aclInfo.wiki, ACL_ACTION_WRITE)) { + res.status(403).json({ code: 'ERR_FORBIDDEN', message: 'forbidden' }) + return + } + + const textId = await createWikiText(conn, [content, 'utf-8']) + const pageId = await putWikiPage(conn, [aclInfo.wikiId, path, textId]) + await createWikiChange(conn, [ + pageId, + token?.uid ?? null, + token?.uid == null ? getRemoteIp(req) : null, + textId, + ]) + } res.status(200).json({ status: 'ok' }) return diff --git a/pages/api/wiki/[slug]/index.ts b/pages/api/wiki/[slug]/index.ts @@ -1,11 +1,34 @@ -import { ApiError, ERR_NOT_FOUND } from '@/lib/apierror' -import { getWiki, getWikiViaSlug, WikiInfo } from '@/lib/models/wiki_info' -import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' +import { ApiError, ERR_INTERNAL, ERR_METHOD_NOT_ALLOWED, ERR_NOT_FOUND } from '@/lib/apierror' +import { withConnection } from '@/lib/model_helpers' +import { getWiki, getWikiViaSlug, updateWiki, WikiInfo } from '@/lib/models/wiki_info' +import { ACL_ACTION_MANAGE, ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' import { authenticationFromCookies } from '@/lib/security/token' import { getSlugAndPath } from '@/lib/utils/wiki' import { NextApiRequest, NextApiResponse } from 'next' -export default async function handler ( +interface PatchWikiBody { + title: string + description: string + acl: string +} + +export default async function handler (req: NextApiRequest, res: NextApiResponse) { + switch (req.method) { + case 'GET': + await handleGet(req, res) + break + + case 'PATCH': + await handlePatch(req, res) + break + + default: + res.status(405).json(ERR_METHOD_NOT_ALLOWED) + break + } +} + +export async function handleGet ( req: NextApiRequest, res: NextApiResponse<ApiError | { wiki: WikiInfo }>, ) { @@ -32,3 +55,43 @@ export default async function handler ( wiki: wiki, }) } + +export async function handlePatch ( + req: NextApiRequest, + res: NextApiResponse<ApiError | { wiki: WikiInfo }>, +) { + const [slug] = getSlugAndPath(req) + if (slug == null) { + res.status(404).json(ERR_NOT_FOUND) + return + } + + const token = authenticationFromCookies(req.cookies) + + await withConnection(async (conn) => { + const wiki = await getWikiViaSlug(conn, [slug]) + if (wiki == null) { + res.status(404).json(ERR_NOT_FOUND) + return + } + + if (!resolveACL(token, wiki.acl, ACL_ACTION_MANAGE)) { + res.status(404).json(ERR_NOT_FOUND) + return + } + + const {title, description, acl} = req.body as PatchWikiBody + + try { + await updateWiki(conn, [title, description, acl, wiki.id]) + } catch (e) { + console.error(e) + res.status(500).json(ERR_INTERNAL) + return + } + + res.status(200).json({ + wiki: wiki, + }) + }) +} diff --git a/pages/manage/[slug]/[...path].tsx b/pages/manage/[slug]/[...path].tsx @@ -0,0 +1,145 @@ +import { SubmitButton } from '@/components/elements/Button' +import Field from '@/components/form/Field' +import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' +import Section from '@/components/layout/Section' +import WikiArticle from '@/components/wiki/WikiArticle' +import WikiBase from '@/components/wiki/WikiBase' +import { useForm } from '@/lib/hooks/use_form' +import { withConnection } from '@/lib/model_helpers' +import { getWikiViaSlug, WikiInfo } from '@/lib/models/wiki_info' +import { getWikiPage, WikiPage } from '@/lib/models/wiki_page' +import { resolveACL } from '@/lib/security/acl' +import { authenticationFromCookies } from '@/lib/security/token' +import { getSlugAndPath } from '@/lib/utils/wiki' +import { GetServerSideProps } from 'next' + +export interface WikiManagePageProps { + wiki?: WikiInfo + page?: WikiPage +} + +export const getServerSideProps: GetServerSideProps<WikiManagePageProps> = async (context) => { + const [slug, path] = getSlugAndPath(context) + if (slug == null || path == null) { + return { notFound: true } + } + + const token = await authenticationFromCookies(context.req.cookies) + + return await withConnection(async (conn) => { + const wiki = await getWikiViaSlug(conn, [slug]) + if (wiki == null) { + return { notFound: true } + } + + if (token == null || !resolveACL(token, wiki.acl, 'manage')) { + return { + redirect: { + destination: `/users/login?redirect_to=${context.resolvedUrl}`, + }, + props: {}, + } + } + + const page = await getWikiPage(conn, [wiki.id, path]) + if (page == null) { + return { + notFound: true, + } + } + + return { + props: { + wiki: wiki, + page: page, + }, + } + }) +} + +export default function WikiManagePage (props: WikiManagePageProps) { + const [wikiFields, updateWikiFields, submitWiki, isWikiLoading, wikiResult, wikiError] = useForm( + { method: 'PATCH', url: `/api/wiki/${props.wiki?.slug}` }, + { + title: props.wiki?.title, + description: props.wiki?.description, + acl: props.wiki?.acl == null ? '{}' : JSON.stringify(props.wiki.acl, null, 2), + }, + ) + + const [pageFields, updatePageFields, submitPage, isPageLoading, pageResult, pageError] = useForm( + { method: 'PUT', url: `/api/wiki/${props.wiki?.slug}/${props.page?.path}` }, + { + acl: props.page?.acl == null ? '{}' : JSON.stringify(props.page.acl, null, 2), + }, + ) + + return ( + <WikiBase + pageKind={'manage'} + title={( + <>관리: {props.wiki?.title}/{props.page?.path}</> + )} + > + <Section> + <WikiArticle> + <h2>{props.wiki?.title}</h2> + + <Form onSubmit={submitWiki}> + <Fields> + <Field + type="text" + placeholder="제목" + value={wikiFields.title} + onValueChange={updateWikiFields.bind(null, 'title')} + disabled={isWikiLoading} + /> + <Field + type="text" + placeholder="짧은 설명" + value={wikiFields.description} + onValueChange={updateWikiFields.bind(null, 'description')} + disabled={isWikiLoading} + /> + <Field + type="textarea" + placeholder="ACL" + value={wikiFields.acl} + onValueChange={updateWikiFields.bind(null, 'acl')} + disabled={isWikiLoading} + /> + <SubmitButton + value="저장" + disabled={isWikiLoading} + /> + </Fields> + </Form> + </WikiArticle> + </Section> + + <Section> + <WikiArticle> + <h2>{props.page?.path}</h2> + <Form onSubmit={submitPage}> + <Fields> + <Field + type="textarea" + placeholder="ACL" + value={pageFields.acl} + onValueChange={updatePageFields.bind(null, 'acl')} + disabled={isPageLoading} + color={pageError != null ? 'error' : undefined} + message={pageError?.message} + /> + <SubmitButton + value="저장" + disabled={isPageLoading} + /> + </Fields> + </Form> + </WikiArticle> + </Section> + </WikiBase> + ) +} diff --git a/pages/wiki/[slug]/[...path].tsx b/pages/wiki/[slug]/[...path].tsx @@ -9,7 +9,7 @@ import { withConnection } from '@/lib/model_helpers' import { getWikiViaSlug } from '@/lib/models/wiki_info' import { getWikiPage, getWikiPageRevision, WikiPage } from '@/lib/models/wiki_page' import { getWikiText } from '@/lib/models/wiki_text' -import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' +import { ACL_ACTION_MANAGE, ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' import { authenticationFromCookies } from '@/lib/security/token' import { getSlugAndPath, getStringFromWikiText } from '@/lib/utils/wiki' import { GetServerSideProps } from 'next' @@ -19,6 +19,7 @@ import { useRouter } from 'next/router' export type WikiViewPageProps = { page?: WikiPage tokens?: Token[] + enableManage?: boolean } export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async (context) => { @@ -41,6 +42,9 @@ export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async ( return { notFound: true } } + // 매니징 권한 체크 (메뉴 표시용) + const enableManage = resolveACL(token, wiki.acl, ACL_ACTION_MANAGE) + const page = revId == null ? await getWikiPage(conn, [wiki.id, path]) : await getWikiPageRevision(conn, [wiki.id, path, revId]) @@ -57,6 +61,7 @@ export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async ( props: { page: page, tokens: pageTokens, + enableManage: enableManage, }, } } @@ -76,6 +81,7 @@ export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async ( props: { page: page, tokens: pageTokens, + enableManage: enableManage, }, } }) @@ -100,7 +106,7 @@ export default function WikiViewPage (props: WikiViewPageProps) { } return ( - <WikiBase pageKind={'wiki'} title={path ?? ''}> + <WikiBase pageKind={'wiki'} title={path ?? ''} enableManage={props.enableManage}> <Section> <WikiArticle> {rev != null && (