dh_demo

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

commit 25b7706df4d6ea97ea5dd1606cde40bd40bff77b
parent 8a0c4e70212c95d6eeadbff2a4a2df280cbf5ab5
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Tue, 24 Jan 2023 04:33:23 +0900

feat: 위키 문서 불러오는 API 추가

추가로, 다음 기능을 추가함.
- modelBehaviour: 트랜잭션을 사용하기 위한 optional connection 파라메터를 추가,
  connection이 제공되지 않았을 경우 자동으로 pool에서 받아 사용함.
- Wiki, Wiki page 관련 models
- 데이터베이스 수정
  - ACL 관련 필드 추가
  - wiki의 slug와 구분하기 위해, page의 slug를 path로 변경

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

Diffstat:
Mlib/apierror.ts | 35++++++++++++-----------------------
Mlib/error_codes.ts | 1+
Mlib/model_helpers.ts | 24++++++++++++++++++------
Mlib/models/wiki_info.ts | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mlib/models/wiki_page.ts | 22+++++++++++-----------
Mlib/security/token.ts | 3++-
Apages/api/wiki/[slug]/[...path].tsx | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msql/0001_base.sql | 37+++++++++++++++++++++++++++----------
8 files changed, 196 insertions(+), 77 deletions(-)

diff --git a/lib/apierror.ts b/lib/apierror.ts @@ -1,4 +1,7 @@ +/* eslint sort-vars: "error" */ + import { + ERR_CODE_FORBIDDEN, ERR_CODE_INTERNAL, ERR_CODE_INVALID_REQUEST, ERR_CODE_METHOD_NOT_ALLOWED, @@ -9,34 +12,20 @@ import { export interface ApiError { code: string message?: string - field?: string } export function isApiError (obj: any): obj is ApiError { return obj.code != null } -export const ERR_METHOD_NOT_ALLOWED: ApiError = { - code: ERR_CODE_METHOD_NOT_ALLOWED, - message: 'Method Not Allowed', -} - -export const ERR_INVALID_REQUEST: ApiError = { - code: ERR_CODE_INVALID_REQUEST, - message: 'Invalid request', -} - -export const ERR_UNAUTHORIZED: ApiError = { - code: ERR_CODE_UNAUTHORIZED, - message: 'Unauthorized', -} - -export const ERR_NOT_FOUND: ApiError = { - code: ERR_CODE_NOT_FOUND, - message: 'Not Found', +function newErr (code: string, message?: string): ApiError { + return { code, message } } -export const ERR_INTERNAL: ApiError = { - code: ERR_CODE_INTERNAL, - message: 'Internal Error', -} +export const + ERR_FORBIDDEN = newErr(ERR_CODE_FORBIDDEN, 'Forbidden'), + ERR_INTERNAL = newErr(ERR_CODE_INTERNAL, 'Internal Error'), + ERR_INVALID_REQUEST = newErr(ERR_CODE_INVALID_REQUEST, 'Invalid request'), + ERR_METHOD_NOT_ALLOWED = newErr(ERR_CODE_METHOD_NOT_ALLOWED, 'Method Not Allowed'), + ERR_NOT_FOUND = newErr(ERR_CODE_NOT_FOUND, 'Not Found'), + ERR_UNAUTHORIZED = newErr(ERR_CODE_UNAUTHORIZED, 'Unauthorized') diff --git a/lib/error_codes.ts b/lib/error_codes.ts @@ -2,6 +2,7 @@ export const ERR_CODE_DUPLICATED_SLUG = 'duplicated_slug', + ERR_CODE_FORBIDDEN = 'forbidden', ERR_CODE_INTERNAL = 'internal_error', ERR_CODE_INVALID_REQUEST = 'invalid_request', ERR_CODE_INVALID_SLUG = 'invalid_slug', diff --git a/lib/model_helpers.ts b/lib/model_helpers.ts @@ -1,30 +1,42 @@ import db from '@/lib/db' 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> ( - conn: PoolConnection | null, - fn: (conn: Connection) => Promise<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() } + + const handler = fn == null + ? connOrFn as ConnHandler<T> + : fn + try { - return await fn(conn) + return await handler(conn) } finally { conn.release() } } export type ModelBehaviour<TArgs, TResult> = - ((conn: PoolConnection, args: TArgs) => Promise<TResult>) & + ((conn: Connection, args: TArgs) => Promise<TResult>) & ((args: TArgs) => Promise<TResult>) export function modelBehaviour <TArgs, TResult> ( fn: (conn: Connection, args: TArgs) => Promise<TResult>, ): ModelBehaviour<TArgs, TResult> { - return (connOrArgs: PoolConnection | TArgs, args?: TArgs): Promise<TResult> => { + return (connOrArgs: Connection | TArgs, args?: TArgs): Promise<TResult> => { if (args != null) { - return fn(connOrArgs as PoolConnection, args) + return fn(connOrArgs as Connection, args) } return withConnection(null, (conn) => fn(conn, connOrArgs as TArgs)) } diff --git a/lib/models/wiki_info.ts b/lib/models/wiki_info.ts @@ -1,4 +1,6 @@ import db from '@/lib/db' +import { modelBehaviour } from '@/lib/model_helpers' +import { ACL } from '@/lib/security/acl' import { OkPacket, RowDataPacket } from 'mysql2' export interface WikiInfo { @@ -7,6 +9,7 @@ export interface WikiInfo { slug: string title: string description?: string + acl?: ACL createdAt: Date updatedAt?: Date } @@ -16,18 +19,14 @@ const SQL_CREATE_WIKI = ` values (?, ?, ?, ?) ` -export async function createWiki ( - ownerId: number, - slug: string, - title: string, - description: string | null, -) { - await db.query<OkPacket>({ +export const createWiki = modelBehaviour< + [ ownerId: number, slug: string, title: string, description: string | null ], + void +>(async (conn, args) => { + await conn.query<OkPacket>({ sql: SQL_CREATE_WIKI, - }, [ownerId, slug, title, description]) - - return true -} + }, args) +}) const SQL_LIST_WIKI_LINKS_BY_OWNER_ID = ` select slug, title @@ -35,27 +34,33 @@ const SQL_LIST_WIKI_LINKS_BY_OWNER_ID = ` where owner_id = ? ` -export async function listWikiLinksByOwnerId (ownerId: number): Promise<Array<{ slug: string, title: string }>> { - const [rows] = await db.query<RowDataPacket[]>({ - sql: SQL_LIST_WIKI_LINKS_BY_OWNER_ID, - }, [ownerId]) +export const listWikiLinksByOwnerId = modelBehaviour< + [ ownerId: number ], + Array<{ slug: string, title: string }> +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_LIST_WIKI_LINKS_BY_OWNER_ID, + }, args) - return rows.map(row => ({ - slug: row[0], - title: row[1], - })) -} + return rows.map(row => ({ + slug: row[0], + title: row[1], + })) +}) const SQL_LIST_WIKI_BY_OWNER_ID = ` - select id, owner_id, slug, title, description, created_at, updated_at + select id, owner_id, slug, title, description, acl_data, created_at, updated_at from wikis where owner_id = ? ` -export async function listWikiByOwnerId (ownerId: number): Promise<WikiInfo[]> { +export const listWikiByOwnerId = modelBehaviour< + [ ownerId: number ], + WikiInfo[] +>(async (conn, args) => { const [rows] = await db.query<RowDataPacket[]>({ sql: SQL_LIST_WIKI_BY_OWNER_ID, - }, [ownerId]) + }, [args]) return rows.map(row => ({ id: row[0], @@ -63,7 +68,39 @@ export async function listWikiByOwnerId (ownerId: number): Promise<WikiInfo[]> { slug: row[2], title: row[3], description: row[4], - createdAt: row[5], - updatedAt: row[6], + acl: row[5], + createdAt: row[6], + updatedAt: row[7], })) -} +}) + +const SQL_GET_WIKI_VIA_SLUG = ` + select id, owner_id, slug, title, description, acl_data, created_at, updated_at + from wikis + where slug = ? +` + +export const getWikiViaSlug = modelBehaviour< + [slug: string], + WikiInfo | null +>(async (conn, args) => { + const [rows] = await db.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_VIA_SLUG, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return { + id: row[0], + ownerId: row[1], + slug: row[2], + title: row[3], + description: row[4], + acl: row[5], + createdAt: row[6], + updatedAt: row[7], + } +}) diff --git a/lib/models/wiki_page.ts b/lib/models/wiki_page.ts @@ -1,5 +1,6 @@ -import db, { Connection } from '@/lib/db' +import { Connection } from '@/lib/db' import { modelBehaviour } from '@/lib/model_helpers' +import { ACL } from '@/lib/security/acl' import { OkPacket, RowDataPacket } from 'mysql2' export interface WikiPage { @@ -7,6 +8,7 @@ export interface WikiPage { path: string content: string html: string + acl?: ACL createdAt: Date updatedAt?: Date } @@ -33,19 +35,16 @@ export const putWikiPage = modelBehaviour(async ( }) const SQL_GET_WIKI_PAGE = ` - select wiki_id, path, content, html, created_at, updated_at + select wiki_id, path, content, html, acl_data, created_at, updated_at from wiki_pages where wiki_id = ? and path = ? ` -export const getWikiPage = modelBehaviour(async ( - conn: Connection, - args: [ - wikiId: number, - path: string, - ], -) => { +export const getWikiPage = modelBehaviour< + [wikiId: number, path: string], + WikiPage | null +>(async (conn, args) => { const [rows] = await conn.query<RowDataPacket[]>({ sql: SQL_GET_WIKI_PAGE, }, args) @@ -60,7 +59,8 @@ export const getWikiPage = modelBehaviour(async ( path: row[1], content: row[2], html: row[3], - createdAt: row[4], - updatedAt: row[5], + acl: row[4], + createdAt: row[5], + updatedAt: row[6], } }) diff --git a/lib/security/token.ts b/lib/security/token.ts @@ -10,10 +10,11 @@ export const REFRESH_TOKEN_EXPIRES_IN = 30 * 24 * 60 * 60 // 30일 export interface AccessTokenPayload extends JwtPayload { tid?: string uid?: number + aclGroup?: string[] } export function getTokenSecret () { - return process.env.TOKEN_SECRET ?? 'dangerously_insecure_s3cr3t' + return process.env.WIKI_JWT_SECRET ?? 'dangerously_insecure_s3cr3t' } /** diff --git a/pages/api/wiki/[slug]/[...path].tsx b/pages/api/wiki/[slug]/[...path].tsx @@ -0,0 +1,62 @@ +import { ERR_FORBIDDEN, ERR_INTERNAL } from '@/lib/apierror' +import { withConnection } from '@/lib/model_helpers' +import { getWikiViaSlug } from '@/lib/models/wiki_info' +import { getWikiPage } from '@/lib/models/wiki_page' +import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' +import { authenticationFromCookies } from '@/lib/security/token' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler (req: NextApiRequest, res: NextApiResponse) { + switch (req.method) { + case 'GET': + return await handleGet(req, res) + default: + res.status(405).json({ status: 'method not allowed' }) + } +} + +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('/') } +} + +async function handleGet (req: NextApiRequest, res: NextApiResponse) { + const params = getParams(req, res) + if (params == null) { + return + } + + const { slug, path } = params + const token = await authenticationFromCookies(req.cookies) + + await withConnection(async (conn) => { + const wiki = await getWikiViaSlug(conn, [slug]) + if (wiki == null) { + res.status(404).json({ status: 'not found' }) + return + } + + if (!resolveACL(token, wiki.acl, ACL_ACTION_READ)) { + res.status(403).json(ERR_FORBIDDEN) + return + } + + const page = await getWikiPage(conn, [wiki.id, path]) + if (page == null) { + res.status(404).json({ status: 'not found' }) + return + } + + if (!resolveACL(token, page.acl, ACL_ACTION_READ)) { + res.status(403).json(ERR_FORBIDDEN) + return + } + + res.status(200).json(page) + }) +} diff --git a/sql/0001_base.sql b/sql/0001_base.sql @@ -34,6 +34,7 @@ create table wikis slug varchar(48) not null unique check ( slug <> '' ), title varchar(255) not null, description text null, + acl_data json null, created_at datetime not null default current_timestamp, updated_at datetime null on update current_timestamp ); @@ -42,27 +43,43 @@ create table wiki_pages ( wiki_id int not null references wikis (id) on delete cascade on update cascade, - slug varchar(255) not null check ( slug <> '' ), - title varchar(255) not null check ( title <> '' ), + path varchar(255) not null check ( path <> '' ), html text not null, content text not null check ( content <> '' ), + acl_data json null, created_at datetime not null default current_timestamp, updated_at datetime null on update current_timestamp, - primary key (wiki_id, slug) + primary key (wiki_id, path) ); create table wiki_changes ( - id int not null auto_increment primary key, - wiki_id int not null references wikis (id) + id int not null auto_increment primary key, + wiki_id int not null references wikis (id) on delete cascade on update cascade, - page_slug varchar(255) not null, - author_id int not null references logins (id) + page_slug varchar(255) not null, + author_id int not null references logins (id) on delete set null on update cascade, - diff text not null, - created_at datetime not null default current_timestamp, + diff text not null, + created_at datetime not null default current_timestamp, - foreign key (wiki_id, page_slug) references wiki_pages (wiki_id, slug) + foreign key (wiki_id, page_slug) references wiki_pages (wiki_id, path) on delete cascade on update cascade ); + +create table acl_groups +( + id int not null auto_increment primary key, + name varchar(255) not null unique +); + +create table acl_group_members +( + group_id int not null references acl_groups (id) + on delete cascade on update cascade, + login_id int not null references logins (id) + on delete cascade on update cascade, + + primary key (group_id, login_id) +);