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