dh_demo

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

commit 8788261f8c527bbc252b53e873aff840658b2b6d
parent 556277cf4785916e54921b384124c00cfa187a18
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Sat, 28 Jan 2023 22:53:56 +0900

feat: 수정 페이지 구현

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

Diffstat:
Mcomponents/elements/Title.module.css | 2+-
Mcomponents/elements/Title.module.css.map | 4++--
Mcomponents/elements/Title.module.scss | 3+++
Mcomponents/elements/Title.tsx | 2++
Acomponents/wiki/WikiEditor.module.css | 1+
Acomponents/wiki/WikiEditor.module.css.map | 2++
Acomponents/wiki/WikiEditor.module.scss | 23+++++++++++++++++++++++
Acomponents/wiki/WikiEditor.tsx | 19+++++++++++++++++++
Mlib/hooks/use_form.ts | 15++++++++++-----
Mlib/model_helpers.ts | 39++++++++++++++++++++++++---------------
Alib/models/wiki_acl.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/models/wiki_change.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/models/wiki_page.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Alib/models/wiki_text.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/security/acl.ts | 26++++++++++++++++++++++++++
Alib/utils/ip.ts | 7+++++++
Alib/utils/wiki.ts | 11+++++++++++
Mpages/api/talks/[slug]/[...path].ts | 7+++----
Mpages/api/users/[id]/wikis.ts | 4++--
Mpages/api/wiki/[slug]/[...path].tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Apages/edit/[slug]/[...path].tsx | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpages/talk/[slug]/[...path].tsx | 39+++++++++++++--------------------------
Mpages/wiki/[slug]/[...path].tsx | 15+++++++++++++--
Msql/0001_base.sql | 32+++++++++++++++++++++-----------
24 files changed, 570 insertions(+), 106 deletions(-)

diff --git a/components/elements/Title.module.css b/components/elements/Title.module.css @@ -1 +1 @@ -.is-display.is-large{line-height:4rem;font-size:3.5625rem;letter-spacing:0rem;font-weight:400}.is-display.is-medium{line-height:3.25rem;font-size:2.8125rem;letter-spacing:0rem;font-weight:400}.is-display.is-small{line-height:2.75rem;font-size:2.25rem;letter-spacing:0rem;font-weight:400}.is-headline.is-large{line-height:2.5rem;font-size:2rem;letter-spacing:0rem;font-weight:400}.is-headline.is-medium{line-height:2.25rem;font-size:1.75rem;letter-spacing:0rem;font-weight:400}.is-headline.is-small{line-height:2rem;font-size:1.5rem;letter-spacing:0rem;font-weight:400}.is-title.is-large{line-height:1.75rem;font-size:1.375rem;letter-spacing:0rem;font-weight:400}.is-title.is-medium{line-height:1.5rem;font-size:1rem;letter-spacing:.009375rem;font-weight:500}.is-title.is-small{line-height:1.25rem;font-size:.875rem;letter-spacing:.0071428571rem;font-weight:500}/*# sourceMappingURL=Title.module.css.map */ +.is-display.is-large{line-height:4rem;font-size:3.5625rem;letter-spacing:0rem;font-weight:400}.is-display.is-medium{line-height:3.25rem;font-size:2.8125rem;letter-spacing:0rem;font-weight:400}.is-display.is-small{line-height:2.75rem;font-size:2.25rem;letter-spacing:0rem;font-weight:400}.is-headline.is-large{line-height:2.5rem;font-size:2rem;letter-spacing:0rem;font-weight:400}.is-headline.is-medium{line-height:2.25rem;font-size:1.75rem;letter-spacing:0rem;font-weight:400}.is-headline.is-small{line-height:2rem;font-size:1.5rem;letter-spacing:0rem;font-weight:400}.is-title.is-large{line-height:1.75rem;font-size:1.375rem;letter-spacing:0rem;font-weight:400}.is-title.is-medium{line-height:1.5rem;font-size:1rem;letter-spacing:.009375rem;font-weight:500}.is-title.is-small{line-height:1.25rem;font-size:.875rem;letter-spacing:.0071428571rem;font-weight:500}.has-no-margin{margin-bottom:0}/*# sourceMappingURL=Title.module.css.map */ diff --git a/components/elements/Title.module.css.map b/components/elements/Title.module.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["Title.module.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAGE,qBCmHE,iBACA,oBACA,oBACA,gBDlHF,sBC+GE,oBACA,oBACA,oBACA,gBD9GF,qBC2GE,oBACA,kBACA,oBACA,gBDxGF,sBCqGE,mBACA,eACA,oBACA,gBDpGF,uBCiGE,oBACA,kBACA,oBACA,gBDhGF,sBC6FE,iBACA,iBACA,oBACA,gBD1FF,mBCuFE,oBACA,mBACA,oBACA,gBDtFF,oBCmFE,mBACA,eACA,0BACA,gBDlFF,mBC+EE,oBACA,kBACA,8BACA","file":"Title.module.css"} -\ No newline at end of file +{"version":3,"sourceRoot":"","sources":["Title.module.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAGE,qBCmHE,iBACA,oBACA,oBACA,gBDlHF,sBC+GE,oBACA,oBACA,oBACA,gBD9GF,qBC2GE,oBACA,kBACA,oBACA,gBDxGF,sBCqGE,mBACA,eACA,oBACA,gBDpGF,uBCiGE,oBACA,kBACA,oBACA,gBDhGF,sBC6FE,iBACA,iBACA,oBACA,gBD1FF,mBCuFE,oBACA,mBACA,oBACA,gBDtFF,oBCmFE,mBACA,eACA,0BACA,gBDlFF,mBC+EE,oBACA,kBACA,8BACA,gBD7EJ,eACE","file":"Title.module.css"} +\ No newline at end of file diff --git a/components/elements/Title.module.scss b/components/elements/Title.module.scss @@ -42,3 +42,6 @@ } } +.has-no-margin { + margin-bottom: 0 +} diff --git a/components/elements/Title.tsx b/components/elements/Title.tsx @@ -5,6 +5,7 @@ import styles from './Title.module.css' export interface TitleProps { size?: 'small' | 'medium' | 'large' kind?: 'title' | 'headline' | 'display' + hasNoMargin?: boolean children: ReactNode } @@ -15,6 +16,7 @@ export default function Title (props: TitleProps) { <h1 {...classNames( styles[`is-${size == null ? 'large' : size}`], styles[`is-${kind == null ? 'title' : kind}`], + props.hasNoMargin && styles['has-no-margin'] )}> {children} </h1> diff --git a/components/wiki/WikiEditor.module.css b/components/wiki/WikiEditor.module.css @@ -0,0 +1 @@ +.editor{display:block;width:100%;min-height:20rem;padding:1rem;margin-bottom:1rem;background:rgba(0,0,0,0);color:inherit;border:1px solid rgba(0,0,0,0);outline:none;font-family:D2Coding,"Source Code Pro","SF Mono",Monaco,Inconsolata,"Fira Mono","Droid Sans Mono",monospace,monospace;resize:none;overflow-y:auto;border-radius:.5rem;color:#191c1b;border-color:#6f7975}@media(prefers-color-scheme: dark){.editor{color:#e1e3e0;border-color:#89938f}}/*# sourceMappingURL=WikiEditor.module.css.map */ diff --git a/components/wiki/WikiEditor.module.css.map b/components/wiki/WikiEditor.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["WikiEditor.module.scss","../../styles/core/_vars.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AAGA,QACE,cACA,WACA,iBACA,aACA,cCRI,KDSJ,yBACA,cACA,+BACA,aACA,sHACA,YACA,gBACA,oBAGE,cACA,qBE4GF,mCF7HF,QAgBI,cACA","file":"WikiEditor.module.css"} +\ No newline at end of file diff --git a/components/wiki/WikiEditor.module.scss b/components/wiki/WikiEditor.module.scss @@ -0,0 +1,23 @@ +@use 'core/colors'; +@use 'core/vars'; + +.editor { + display: block; + width: 100%; + min-height: 20rem; + padding: 1rem; + margin-bottom: vars.$gap; + background: transparent; + color: inherit; + border: 1px solid transparent; + outline: none; + font-family: D2Coding, "Source Code Pro", "SF Mono", Monaco, Inconsolata, "Fira Mono", "Droid Sans Mono", monospace, monospace; + resize: none; + overflow-y: auto; + border-radius: 0.5rem; + + @include colors.apply-themes() using ($theme) { + color: colors.get($theme, 'on-surface'); + border-color: colors.get($theme, 'outline'); + } +} diff --git a/components/wiki/WikiEditor.tsx b/components/wiki/WikiEditor.tsx @@ -0,0 +1,19 @@ +import { ChangeEvent, useReducer, useState } from 'react' +import styles from './WikiEditor.module.css' + +export interface WikiEditorProps { + value?: string + onValueChange?: (value: string) => void +} + +export default function WikiEditor (props: WikiEditorProps) { + const handleValueChange = (e: ChangeEvent<HTMLTextAreaElement>) => { + props.onValueChange?.(e.currentTarget.value) + } + + return ( + <textarea className={styles.editor} onChange={handleValueChange}> + {props.value} + </textarea> + ) +} diff --git a/lib/hooks/use_form.ts b/lib/hooks/use_form.ts @@ -11,6 +11,7 @@ type FormFieldAction<T extends FormFields> = Partial<{ interface UpdateFieldOrDispatch<T extends FormFields> { (fields: T): void + <K extends keyof T> (key: K, value: T[K]): void } @@ -33,7 +34,7 @@ export const useFields = <T extends FormFields> (initial: T): [T, UpdateFieldOrD dispatch({ [fieldsOrKey]: value } as FormFieldAction<T>) } }, - [] + [], ) return [fields, update] @@ -45,7 +46,7 @@ export const useFields = <T extends FormFields> (initial: T): [T, UpdateFieldOrD * TODO: 훅 대신 컴포넌트로 구현 */ export const useForm = <T extends FormFields, Result = unknown> ( - input: RequestInfo | URL, + input: string | { method: string, url: string | URL }, initial: T, onSuccess?: (result: Result) => void, ): [fields: T, updateFields: UpdateFieldOrDispatch<T>, submit: () => void, @@ -55,14 +56,18 @@ export const useForm = <T extends FormFields, Result = unknown> ( const [result, setResult] = useState<Result | null>(null) const [error, setError] = useState<ApiError | null>(null) + const { method, url } = typeof input === 'string' + ? { method: 'POST', url: input } + : input + const submit = useCallback(() => { setLoading(true) setResult(null) setError(null) !(async () => { - const res = await fetch(input, { - method: 'POST', + const res = await fetch(url, { + method: method, headers: { 'Content-Type': 'application/json', }, @@ -83,7 +88,7 @@ export const useForm = <T extends FormFields, Result = unknown> ( console.error('useForm: unexpected error:', e) setError({ code: 'internal_error', message: 'Internal error' }) }) - }, [fields, input]) + }, [onSuccess, fields, input]) return [fields, updateFields, submit, isLoading, result, error] } diff --git a/lib/model_helpers.ts b/lib/model_helpers.ts @@ -3,21 +3,30 @@ import { Connection, PoolConnection } from 'mysql2/promise' type ConnHandler<T> = (conn: Connection) => Promise<T> -export function withConnection <T> (conn: PoolConnection | null, fn: ConnHandler<T>): Promise<T> -export function withConnection <T> (fn: ConnHandler<T>): Promise<T> -export async function withConnection <T> ( - connOrFn: PoolConnection | null | ConnHandler<T>, - fn?: ConnHandler<T>, -): Promise<T> { - let conn: PoolConnection | null = fn == null - ? null - : connOrFn as PoolConnection | null - if (conn == null) { - conn = await db.getConnection() +function transactionWrapper<T> ( + conn: PoolConnection, + fn: ConnHandler<T>, +): ConnHandler<T> { + return async (conn: Connection) => { + try { + await conn.beginTransaction() + const result = await fn(conn) + await conn.commit() + return result + } finally { + await conn.rollback() + } } +} + +export async function withConnection<T> ( + fn: ConnHandler<T>, + useTransaction?: boolean, +): Promise<T> { + const conn = await db.getConnection() - const handler = fn == null - ? connOrFn as ConnHandler<T> + const handler = useTransaction + ? transactionWrapper(conn, fn) : fn try { @@ -31,13 +40,13 @@ export type ModelBehaviour<TArgs, TResult> = ((conn: Connection, args: TArgs) => Promise<TResult>) & ((args: TArgs) => Promise<TResult>) -export function modelBehaviour <TArgs, TResult> ( +export function modelBehaviour<TArgs, TResult> ( fn: (conn: Connection, args: TArgs) => Promise<TResult>, ): ModelBehaviour<TArgs, TResult> { return (connOrArgs: Connection | TArgs, args?: TArgs): Promise<TResult> => { if (args != null) { return fn(connOrArgs as Connection, args) } - return withConnection(null, (conn) => fn(conn, connOrArgs as TArgs)) + return withConnection((conn) => fn(conn, connOrArgs as TArgs)) } } diff --git a/lib/models/wiki_acl.ts b/lib/models/wiki_acl.ts @@ -0,0 +1,60 @@ +import { modelBehaviour } from '@/lib/model_helpers' +import { ACL } from '@/lib/security/acl' +import { RowDataPacket } from 'mysql2' + +const SQL_GET_WIKI_AND_PAGE_ACL = ` + select w.id as wiki_id, w.acl_data as wiki_acl, p.id as page_id, p.acl_data as page_acl + from wikis w + left join wiki_pages p on p.wiki_id = w.id + where w.slug = ? + and p.path = ? +` + +export const getWikiAndPageACLViaPath = modelBehaviour< + [slug: string, path: string], + { wikiId: number, wiki?: ACL, pageId?: number, page?: ACL } | null +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_AND_PAGE_ACL, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return { + wikiId: row[0], + wiki: row[1], + pageId: row[2], + page: row[3], + } +}) + +const SQL_GET_WIKI_AND_PAGE_ACL_VIA_PAGE_ID = ` + select w.id, w.acl_data, p.id, p.acl_data + from wiki_pages p + inner join wikis w on p.wiki_id = w.id + where p.id = ? +` + +export const getWikiAndPageACLViaPageId = modelBehaviour< + [pageId: number], + { wikiId: number, wiki?: ACL, pageId?: number, page?: ACL } | null +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_AND_PAGE_ACL_VIA_PAGE_ID, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return { + wikiId: row[0], + wiki: row[1], + pageId: row[2], + page: row[3], + } +}) diff --git a/lib/models/wiki_change.ts b/lib/models/wiki_change.ts @@ -0,0 +1,53 @@ +import { modelBehaviour } from '@/lib/model_helpers' +import { OkPacket, RowDataPacket } from 'mysql2' + +export interface WikiChange { + id: number + pageId: number + authorId?: number + authorIp?: string + textId: number + createdAt: Date +} + +const SQL_CREATE_WIKI_CHANGE = ` + insert into wiki_changes (page_id, author_id, author_ip, text_id) + values (?, ?, ?, ?) +` + +export const createWikiChange = modelBehaviour< + [pageId: number, authorId: number | null, authorIp: string | null, textId: number], + number +>(async (conn, args) => { + const [rows] = await conn.query<OkPacket>({ + sql: SQL_CREATE_WIKI_CHANGE, + }, args) + + return rows.insertId +}) + +const SQL_GET_WIKI_CHANGES = ` + select id, page_id, author_id, author_ip, text_id, created_at + from wiki_changes + where page_id = ? + order by created_at desc + limit ? offset ? +` + +export const getWikiChanges = modelBehaviour< + [pageId: number, limit: number, offset: number], + WikiChange[] +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_CHANGES, + }, args) + + return rows.map(row => ({ + id: row[0], + pageId: row[1], + authorId: row[2], + authorIp: row[3], + textId: row[4], + createdAt: row[5], + })) +}) diff --git a/lib/models/wiki_page.ts b/lib/models/wiki_page.ts @@ -6,36 +6,32 @@ import { OkPacket, RowDataPacket } from 'mysql2' export interface WikiPage { wikiId: number path: string - content: string - html: string + textId: number acl?: ACL createdAt: Date updatedAt?: Date } const SQL_PUT_WIKI_PAGE = ` - insert into wiki_pages (wiki_id, path, content, html) + insert into wiki_pages (wiki_id, path, text_id, html) values (?, ?, ?, ?) - on duplicate key update content = values(content), + on duplicate key update text_id = values(text_id), html = values(html) ` -export const putWikiPage = modelBehaviour(async ( - conn: Connection, - args: [ - wikiId: number, - path: string, - content: string, - html: string, - ], -) => { - await conn.query<OkPacket>({ +export const putWikiPage = modelBehaviour< + [wikiId: number, path: string, textId: number, html: string], + number +>(async (conn, args) => { + const [result] = await conn.query<OkPacket>({ sql: SQL_PUT_WIKI_PAGE, }, args) + + return result.insertId }) const SQL_GET_WIKI_PAGE = ` - select wiki_id, path, content, html, acl_data, created_at, updated_at + select wiki_id, path, text_id, acl_data, created_at, updated_at from wiki_pages where wiki_id = ? and path = ? @@ -57,28 +53,66 @@ export const getWikiPage = modelBehaviour< return { wikiId: row[0], path: row[1], - content: row[2], - html: row[3], + textId: row[2], + acl: row[3], + createdAt: row[4], + updatedAt: row[5], + } +}) + +export interface WikiHtmlPage extends WikiPage { + html: string +} + +const SQL_GET_WIKI_HTML_PAGE = ` + select wiki_id, path, html, text_id, acl_data, created_at, updated_at + from wiki_pages + where wiki_id = ? + and path = ? +` + +export const getWikiHtmlPage = modelBehaviour< + [wikiId: number, path: string], + WikiHtmlPage | null +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_HTML_PAGE, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return { + wikiId: row[0], + path: row[1], + html: row[2], + textId: row[3], acl: row[4], createdAt: row[5], updatedAt: row[6], } }) -const SQL_GET_WIKI_AND_PAGE_ACL = ` - select w.id as wiki_id, w.acl_data as wiki_acl, p.id as page_id, p.acl_data as page_acl - from wikis w - inner join wiki_pages p on p.wiki_id = w.id - where w.slug = ? - and p.path = ? +export interface WikiRawPage extends WikiPage { + content: string +} + +const SQL_GET_WIKI_RAW_PAGE = ` + select wiki_id, path, text_id, wt.content, acl_data, created_at, updated_at + from wiki_pages wp + inner join wiki_texts wt on wt.id = wp.text_id + where wiki_id = ? + and path = ? ` -export const getWikiAndPageACLViaPath = modelBehaviour< - [slug: string, path: string], - { wikiId: number, wiki: ACL, pageId: number, page: ACL } | null +export const getWikiRawPage = modelBehaviour< + [wikiId: number, path: string], + WikiRawPage | null >(async (conn, args) => { const [rows] = await conn.query<RowDataPacket[]>({ - sql: SQL_GET_WIKI_AND_PAGE_ACL, + sql: SQL_GET_WIKI_RAW_PAGE, }, args) if (rows.length === 0) { @@ -88,8 +122,12 @@ export const getWikiAndPageACLViaPath = modelBehaviour< const row = rows[0] return { wikiId: row[0], - wiki: row[1], - pageId: row[2], - page: row[3], + path: row[1], + textId: row[2], + content: row[3], + acl: row[4], + createdAt: row[5], + updatedAt: row[6], } }) + diff --git a/lib/models/wiki_text.ts b/lib/models/wiki_text.ts @@ -0,0 +1,50 @@ +import { modelBehaviour } from '@/lib/model_helpers' +import { OkPacket, RowDataPacket } from 'mysql2' + +export interface WikiText { + id: number + content: string + encoding: 'utf-8' | 'gzip' +} + +const SQL_GET_WIKI_TEXT = ` + select id, content, encoding + from wiki_texts + where id = ? +` + +export const getWikiText = modelBehaviour< + [id: number], + WikiText | null +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_TEXT, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return { + id: row[0], + content: row[1], + encoding: row[2], + } +}) + +const SQL_INSERT_WIKI_TEXT = ` + insert into wiki_texts (content, encoding) + values (?, ?) +` + +export const createWikiText = modelBehaviour< + [content: string, encoding: 'utf-8' | 'gzip'], + number +>(async (conn, args) => { + const [rows] = await conn.query<OkPacket>({ + sql: SQL_INSERT_WIKI_TEXT, + }, args) + + return rows.insertId +}) diff --git a/lib/security/acl.ts b/lib/security/acl.ts @@ -18,8 +18,34 @@ export const ACL_ACTION_MANAGE = 'manage' export const ACL_ACTION_TALK = 'talk' export const ACL_ACTION_CREATE_THREAD = 'create_thread' +type ArrayOrElement<T> = T | T[] + export function resolveACL ( token: AccessTokenPayload | null, + acl: ArrayOrElement<ACL | null | undefined> | null | undefined, + action: string +): boolean { + const acls = acl == null + ? null + : Array.isArray(acl) + ? acl + : [acl] + + if (acls == null || acls.length === 0) { + return true + } + + for (const acl of acls) { + if (!resolveACLInternal(token, acl, action)) { + return false + } + } + + return true +} + +export function resolveACLInternal ( + token: AccessTokenPayload | null, acl: ACL | null | undefined, action: string ): boolean { diff --git a/lib/utils/ip.ts b/lib/utils/ip.ts @@ -0,0 +1,7 @@ +import { GetServerSidePropsContext, NextApiRequest } from 'next' + +export function getRemoteIp (context: GetServerSidePropsContext | NextApiRequest): string | null { + const req = 'req' in context ? context.req : context + const ip = req.headers['x-forwarded-for'] ?? req.connection.remoteAddress + return (Array.isArray(ip) ? ip[0] : ip) ?? null +} diff --git a/lib/utils/wiki.ts b/lib/utils/wiki.ts @@ -0,0 +1,11 @@ +import { GetServerSidePropsContext, NextApiRequest } from 'next' + +export const getSlugAndPath = ( + context: GetServerSidePropsContext | NextApiRequest +): [slug: string | null, path: string | null] => { + const { slug, path } = context.query + if (typeof slug !== 'string' || path == null || !Array.isArray(path)) { + return [null, null] + } + return [slug, path.join('')] +} diff --git a/pages/api/talks/[slug]/[...path].ts b/pages/api/talks/[slug]/[...path].ts @@ -8,7 +8,7 @@ import { } from '@/lib/apierror' import { withConnection } from '@/lib/model_helpers' import { createThreadComment } from '@/lib/models/thread' -import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_page' +import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_acl' import { createWikiTalk } from '@/lib/models/wiki_talk' import { ACL_ACTION_CREATE_THREAD, resolveACL } from '@/lib/security/acl' import { authenticationFromCookies } from '@/lib/security/token' @@ -63,13 +63,12 @@ async function handlePost ( await withConnection(async conn => { // ACL Check const aclInfo = await getWikiAndPageACLViaPath(conn, [slug, path]) - if (aclInfo == null) { + if (aclInfo == null || aclInfo.pageId == null) { res.status(404).json(ERR_NOT_FOUND) return } - if (!resolveACL(token, aclInfo.wiki, ACL_ACTION_CREATE_THREAD) || - !resolveACL(token, aclInfo.page, ACL_ACTION_CREATE_THREAD)) { + if (!resolveACL(token, [aclInfo.wiki, aclInfo.page], ACL_ACTION_CREATE_THREAD)) { res.status(403).json(ERR_FORBIDDEN) return } diff --git a/pages/api/users/[id]/wikis.ts b/pages/api/users/[id]/wikis.ts @@ -24,8 +24,8 @@ export default async function handler (req: NextApiRequest, res: NextApiResponse try { const out = req.query.format === 'links' - ? await listWikiLinksByOwnerId(id) - : await listWikiByOwnerId(id) + ? await listWikiLinksByOwnerId([id]) + : await listWikiByOwnerId([id]) res.status(200).json(out) } catch (e) { diff --git a/pages/api/wiki/[slug]/[...path].tsx b/pages/api/wiki/[slug]/[...path].tsx @@ -1,9 +1,16 @@ -import { ERR_INTERNAL } from '@/lib/apierror' +import { ApiError, ERR_INTERNAL, ERR_NOT_FOUND } from '@/lib/apierror' +import { ERR_CODE_EMPTY_CONTENT } from '@/lib/error_codes' +import { render } from '@/lib/markup' 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 } from '@/lib/models/wiki_page' -import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' +import { getWikiPage as modelGetWikiPage, putWikiPage } 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 { AccessTokenPayload, authenticationFromCookies } from '@/lib/security/token' +import { getRemoteIp } from '@/lib/utils/ip' +import { getSlugAndPath } from '@/lib/utils/wiki' import { Connection } from 'mysql2/promise' import { NextApiRequest, NextApiResponse } from 'next' @@ -11,6 +18,10 @@ export default async function handler (req: NextApiRequest, res: NextApiResponse switch (req.method) { case 'GET': return await handleGet(req, res) + + case 'PUT': + return await handlePut(req, res) + default: res.status(405).json({ status: 'method not allowed' }) } @@ -30,7 +41,7 @@ export async function getWikiPage ( conn: Connection, token: AccessTokenPayload | null, wikiIdOrSlug: number | string, - path: string + path: string, ) { const wiki = typeof wikiIdOrSlug === 'string' ? await getWikiViaSlug(conn, [wikiIdOrSlug]) @@ -56,15 +67,16 @@ export async function getWikiPage ( } async function handleGet (req: NextApiRequest, res: NextApiResponse) { - const params = getParams(req, res) - if (params == null) { + const [slug, path] = getSlugAndPath(req) + if (slug == null || path == null) { + console.error('slug or path is null?') + res.status(404).json({ status: 'not found' }) return } - const { slug, path } = params const token = await authenticationFromCookies(req.cookies) - const wikiPage = await withConnection(async (conn) => { + const wikiPage = await withConnection(async (conn) => { return await getWikiPage(conn, token, slug, path) }) if (wikiPage == null) { @@ -74,3 +86,49 @@ async function handleGet (req: NextApiRequest, res: NextApiResponse) { res.status(200).json(wikiPage) } + +export interface WikiPagePutRequest { + content?: string +} + +async function handlePut ( + req: NextApiRequest, + res: NextApiResponse<ApiError | { status: 'ok' }>, +) { + const [slug, path] = getSlugAndPath(req) + if (slug == null || path == null) { + console.error('slug or path is null?') + res.status(404).json(ERR_NOT_FOUND) + return + } + + const { content } = req.body as WikiPagePutRequest + if (content == null || content.length === 0) { + res.status(400).json({ code: ERR_CODE_EMPTY_CONTENT, message: 'content is empty' }) + return + } + + const html = render(content) + + 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)) { + 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, html]) + await createWikiChange(conn, [ + pageId, + token?.uid ?? null, + token?.uid == null ? getRemoteIp(req) : null, + textId, + ]) + + res.status(200).json({ status: 'ok' }) + return + }, true) +} diff --git a/pages/edit/[slug]/[...path].tsx b/pages/edit/[slug]/[...path].tsx @@ -0,0 +1,90 @@ +import { SubmitButton } from '@/components/elements/Button' +import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' +import WikiBase from '@/components/wiki/WikiBase' +import WikiEditor from '@/components/wiki/WikiEditor' +import { useForm } from '@/lib/hooks/use_form' +import { withConnection } from '@/lib/model_helpers' +import { getWikiViaSlug, WikiInfo } from '@/lib/models/wiki_info' +import { getWikiRawPage, WikiRawPage } from '@/lib/models/wiki_page' +import { ACL_ACTION_WRITE, resolveACL } from '@/lib/security/acl' +import { authenticationFromCookies } from '@/lib/security/token' +import { getSlugAndPath } from '@/lib/utils/wiki' +import { GetServerSideProps } from 'next' + +export interface WikiEditPageProps { + slug: string + path: string + wiki: WikiInfo + page?: WikiRawPage +} + +export const getServerSideProps: GetServerSideProps<WikiEditPageProps> = async (context) => { + const [slug, path] = getSlugAndPath(context) + if (slug == null || path == null) { + return { notFound: true } + } + + const token = authenticationFromCookies(context.req.cookies) + + const result = await withConnection(async (conn) => { + const wiki = await getWikiViaSlug(conn, [slug]) + if (wiki == null) { + return null + } + + if (!resolveACL(token, wiki.acl, ACL_ACTION_WRITE)) { + return null + } + + const page = await getWikiRawPage(conn, [wiki.id, path]) + if (page == null) { + return null + } + + if (!resolveACL(token, page.acl, ACL_ACTION_WRITE)) { + return null + } + + return { wiki, page } + }) + + if (result == null) { + return { notFound: true } + } + + return { + props: { + slug: slug, + path: path, + wiki: result.wiki, + page: result.page, + }, + } +} + +interface WikiEditFormFields { + content: string +} + +export default function WikiEditPage (props: WikiEditPageProps) { + const [fields, updateFields, submit, isLoading, result, error] = useForm<WikiEditFormFields>( + { method: 'PUT', url: `/api/wiki/${props.slug}/${props.path}` }, + { content: props.page?.content ?? '' }, + ) + + return ( + <WikiBase title={`수정중: ${props.path}`} pageKind="edit"> + <WikiEditor + value={fields.content} + onValueChange={updateFields.bind(null, 'content')} + /> + + <Form onSubmit={submit}> + <Fields> + <SubmitButton color="primary" disabled={isLoading} value="저장"/> + </Fields> + </Form> + </WikiBase> + ) +} diff --git a/pages/talk/[slug]/[...path].tsx b/pages/talk/[slug]/[...path].tsx @@ -12,7 +12,7 @@ import ThreadList from '@/components/threads/ThreadList' import { ApiError } from '@/lib/apierror' import { withConnection } from '@/lib/model_helpers' import { Thread } from '@/lib/models/thread' -import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_page' +import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_acl' import { countWikiTalks, listWikiTalks } from '@/lib/models/wiki_talk' import { parseIntOrDefault } from '@/lib/utils/number' import { CreateTalkResponse } from '@/pages/api/talks/[slug]/[...path]' @@ -43,39 +43,26 @@ export const getServerSideProps: GetServerSideProps<TestPageProps> = async (cont const page = Math.max(1, parseIntOrDefault(context.query.page, 1)) - const result = await withConnection(async (conn): Promise<[ - aclInfo: Exclude<Awaited<ReturnType<typeof getWikiAndPageACLViaPath>>, null>, - talks: Awaited<ReturnType<typeof listWikiTalks>>, - counts: number, - ] | null> => { + return await withConnection(async (conn) => { const aclInfo = await getWikiAndPageACLViaPath(conn, [slug, path]) - if (aclInfo === null) { - return null + if (aclInfo == null || aclInfo.pageId == null) { + return { notFound: true } } const talks = await listWikiTalks(conn, [aclInfo.pageId, PAGE_SIZE, (page - 1) * PAGE_SIZE]) const counts = await countWikiTalks(conn, [aclInfo.pageId]) - return [aclInfo, talks, counts] - }) - if (result === null) { return { - notFound: true, + props: { + wikiSlug: slug, + pageId: aclInfo.pageId, + pagePath: path, + talks: talks, + counts: counts, + page: page, + }, } - } - - const [aclInfo, talks, counts] = result - - return { - props: { - wikiSlug: slug, - pageId: aclInfo.pageId, - pagePath: path, - talks: talks, - counts: counts, - page: page, - }, - } + }) } export default function TestPage (props: TestPageProps) { diff --git a/pages/wiki/[slug]/[...path].tsx b/pages/wiki/[slug]/[...path].tsx @@ -1,7 +1,9 @@ import WikiArticle from '@/components/wiki/WikiArticle' import { withConnection } from '@/lib/model_helpers' +import { getWikiViaSlug } from '@/lib/models/wiki_info' +import { getWikiHtmlPage } from '@/lib/models/wiki_page' +import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' import { authenticationFromCookies } from '@/lib/security/token' -import { getWikiPage } from '@/pages/api/wiki/[slug]/[...path]' import { GetServerSideProps } from 'next' export interface WikiViewPageProps { @@ -21,7 +23,16 @@ export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async ( const token = await authenticationFromCookies(context.req.cookies) const wikiPage = await withConnection(async (conn) => { - return await getWikiPage(conn, token, slug, path) + const wiki = await getWikiViaSlug(conn, [slug]) + if (wiki == null) { + return null + } + + if (!resolveACL(token, wiki.acl, ACL_ACTION_READ)) { + return null + } + + return await getWikiHtmlPage(conn, [wiki.id, path]) }) if (wikiPage == null) { return { notFound: true } diff --git a/sql/0001_base.sql b/sql/0001_base.sql @@ -50,6 +50,13 @@ create table wikis updated_at datetime null on update current_timestamp ); +create table wiki_texts +( + id int not null auto_increment primary key, + content mediumtext not null check ( content <> '' ), + encoding varchar(255) not null default 'utf-8' +); + create table wiki_pages ( id int not null auto_increment primary key, @@ -57,12 +64,13 @@ create table wiki_pages on delete cascade on update cascade, path varchar(255) not null check ( path <> '' ), html text not null, - content text not null check ( content <> '' ), + text_id int not null references wiki_texts (id) + on delete restrict on update cascade, acl_data json null, created_at datetime not null default current_timestamp, updated_at datetime null on update current_timestamp, - index (wiki_id, path) + unique (wiki_id, path) ); create table wiki_changes @@ -70,25 +78,27 @@ create table wiki_changes id int not null auto_increment primary key, page_id int not null references wiki_pages (id) on delete cascade on update cascade, - author_id int not null references logins (id) + author_id int null references logins (id) on delete set null on update cascade, - diff text not null, + author_ip varchar(255) null, + text_id int not null references wiki_texts (id) + on delete restrict on update cascade, created_at datetime not null default current_timestamp ); create table threads ( - id int not null auto_increment primary key, - author_id int not null references logins (id) + id int not null auto_increment primary key, + author_id int not null references logins (id) on delete set null on update cascade, - title varchar(255) not null, - created_at datetime not null default current_timestamp + title varchar(255) not null, + created_at datetime not null default current_timestamp ); create table thread_comments ( id int not null auto_increment primary key, - thread_id int not null references threads (id) + thread_id int not null references threads (id) on delete cascade on update cascade, author_id int not null references logins (id) on delete set null on update cascade, @@ -99,9 +109,9 @@ create table thread_comments create table wiki_talks ( - page_id int not null references wiki_pages (id) + page_id int not null references wiki_pages (id) on delete cascade on update cascade, - thread_id int not null references threads (id) + thread_id int not null references threads (id) on delete cascade on update cascade, primary key (page_id, thread_id)