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:
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 && (