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:
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)