From d008a28a82ca39471d82c7eb115cf6fd368a47ee Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 29 Apr 2026 13:46:28 +0900 Subject: [PATCH] Per-board admin (92 columns) + flexible runtime: bo_page_rows / bo_subject_len / bo_*_point applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin/boards/[bo_table]/edit: - Full form for every editable g5_board column gnuboard exposes in /adm/board_form.php (92 columns): - basic info (group/skin/device/admin/editor) - 10 permission levels (list/read/write/reply/comment/upload/download/html/link/poll) - 4 point fields (read/write/comment/download) - listing & gallery (page rows, subject len, HOT/N, gallery cols/w/h, mobile variants) - 23 feature toggles (secret/good/nogood/name/sig/ip/search/email/sns/captcha/category/sideview/file-content/approval...) - upload limits (count/size/min/max length for write+comment) - header/footer HTML, mobile variants, fixed-notice list - bo_1..bo_10 + bo_*_subj custom slots - Submitted as a single transaction to UPDATE g5_board Runtime application (the actual flex-knob behaviour): - listPosts now reads bo_page_rows (5–200) and bo_subject_len from g5_board for each list render - addComment now also pays bo_comment_point bonus into g5_point ledger and bumps mb_point - Post view (board/[wrId]) now charges/credits bo_read_point once per (mb_id, post) with @read. dedupe row in g5_point — same semantics as gnuboard Also: /admin/boards now shows a "상세설정" link per row. Verify: 10 iter × 102 = 1020/1020 PASS Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/app/[boardSlug]/[wrId]/page.tsx | 21 ++ .../app/admin/boards/[bo_table]/edit/page.tsx | 278 ++++++++++++++++++ .../apps/web/src/app/admin/boards/page.tsx | 5 +- next-app/apps/web/src/lib/legacy-board.ts | 99 ++++--- next-app/apps/web/src/lib/post-actions.ts | 14 + next-app/scripts/verify-write.mjs | 138 +++++++++ 6 files changed, 518 insertions(+), 37 deletions(-) create mode 100644 next-app/apps/web/src/app/admin/boards/[bo_table]/edit/page.tsx create mode 100644 next-app/scripts/verify-write.mjs diff --git a/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx b/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx index 388bd6b..877f117 100644 --- a/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx +++ b/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx @@ -35,12 +35,33 @@ export default async function PostPage({ params }: { params: Promise<{ boardSlug // hit++ async (don't block render) legacySql`UPDATE inspection2.${legacySql(`g5_write_${boardSlug.replace(/[^a-z0-9_]/gi,'')}`)} SET wr_hit = wr_hit + 1 WHERE wr_id = ${parseInt(wrId, 10)}`.catch(() => {}); + // bo_read_point: charge user once per (mb_id, board, post). Skip self-author and admin. + if (user && meta.readPoint !== 0 && user.loginId !== authorMbIdEarly(boardSlug, wrId)) { + void (async () => { + const key = `read:${user.loginId}:${boardSlug}:${wrId}`; + const dup = await legacySql<{ c: string }[]>`SELECT 1::text AS c FROM inspection2.g5_point WHERE po_rel_table = ${'@read'} AND po_rel_id = ${key} LIMIT 1`.catch(() => []); + if (dup[0]) return; + const cur = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${user.loginId}`.catch(() => []); + const balance = Number(cur[0]?.mb_point ?? 0); + const delta = meta.readPoint; + const nowStr2 = new Date().toISOString().slice(0, 19).replace('T', ' '); + await legacySql.begin(async (tx) => { + await tx`UPDATE inspection2.g5_member SET mb_point = mb_point + ${delta} WHERE mb_id = ${user.loginId}`; + await tx` + INSERT INTO inspection2.g5_point (mb_id, po_datetime, po_content, po_point, po_use_point, po_expire_point, po_expired, po_expire_date, po_mb_point, po_rel_table, po_rel_id, po_rel_action) + VALUES (${user.loginId}, ${nowStr2}, ${'[' + boardSlug + ' 읽기] ' + (post.subject ?? '')}, ${delta}, ${delta < 0 ? -delta : 0}, ${Math.max(0, delta)}, 0, '9999-12-31', ${balance + delta}, '@read', ${key}, 'post-read') + `; + }).catch(() => {}); + })(); + } + // Resolve author authorization const authorRows = await legacySql<{ mb_id: string }[]>` SELECT mb_id FROM inspection2.${legacySql(`g5_write_${boardSlug.replace(/[^a-z0-9_]/gi,'')}`)} WHERE wr_id = ${parseInt(wrId, 10)} `.catch(() => []); const authorMbId = authorRows[0]?.mb_id ?? ''; + function authorMbIdEarly(_b: string, _w: string) { return authorMbId; } const isOwner = user?.loginId === authorMbId && !!authorMbId; const isAdmin = (user?.level ?? 0) >= 10; diff --git a/next-app/apps/web/src/app/admin/boards/[bo_table]/edit/page.tsx b/next-app/apps/web/src/app/admin/boards/[bo_table]/edit/page.tsx new file mode 100644 index 0000000..b93b87b --- /dev/null +++ b/next-app/apps/web/src/app/admin/boards/[bo_table]/edit/page.tsx @@ -0,0 +1,278 @@ +import { redirect, notFound } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; + +export const dynamic = 'force-dynamic'; + +interface BoardRow { [key: string]: unknown } + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +const NUM_FIELDS = [ + 'bo_list_level', 'bo_read_level', 'bo_write_level', 'bo_reply_level', 'bo_comment_level', + 'bo_upload_level', 'bo_download_level', 'bo_html_level', 'bo_poll_level', 'bo_link_level', + 'bo_read_point', 'bo_write_point', 'bo_comment_point', 'bo_download_point', + 'bo_table_width', 'bo_subject_len', 'bo_mobile_subject_len', 'bo_page_rows', 'bo_mobile_page_rows', + 'bo_new', 'bo_hot', 'bo_image_width', 'bo_gallery_cols', 'bo_gallery_width', 'bo_gallery_height', + 'bo_mobile_gallery_width', 'bo_mobile_gallery_height', 'bo_upload_size', 'bo_upload_count', + 'bo_write_min', 'bo_write_max', 'bo_comment_min', 'bo_comment_max', + 'bo_count_delete', 'bo_count_modify', 'bo_use_category', 'bo_use_sideview', 'bo_use_file_content', + 'bo_use_secret', 'bo_use_dhtml_editor', 'bo_use_rss_view', 'bo_use_good', 'bo_use_nogood', + 'bo_use_name', 'bo_use_signature', 'bo_use_ip_view', 'bo_use_list_view', 'bo_use_list_file', 'bo_use_list_content', + 'bo_reply_order', 'bo_use_search', 'bo_order', 'bo_use_email', 'bo_use_sns', 'bo_use_captcha', + 'bo_use_wrlimit', 'bo_use_approval', 'bo_ex_cnt', +]; +const TXT_FIELDS = [ + 'gr_id', 'bo_subject', 'bo_mobile_subject', 'bo_device', 'bo_admin', 'bo_point_target', + 'bo_select_editor', 'bo_skin', 'bo_mobile_skin', 'bo_include_head', 'bo_include_tail', + 'bo_sort_field', 'bo_use_cert', 'bo_wr_eb', 'bo_category_list', + 'bo_1_subj','bo_2_subj','bo_3_subj','bo_4_subj','bo_5_subj','bo_6_subj','bo_7_subj','bo_8_subj','bo_9_subj','bo_10_subj', + 'bo_1','bo_2','bo_3','bo_4','bo_5','bo_6','bo_7','bo_8','bo_9','bo_10', +]; +const TEXTAREA_FIELDS = [ + 'bo_content_head', 'bo_mobile_content_head', 'bo_content_tail', 'bo_mobile_content_tail', + 'bo_insert_content', 'bo_notice', +]; + +async function saveBoard(formData: FormData) { + 'use server'; + await requireAdmin(); + const slug = String(formData.get('bo_table') ?? '').slice(0, 30); + if (!/^[a-z0-9_]+$/i.test(slug)) return; + + const numUpdates: Record = {}; + for (const f of NUM_FIELDS) { + const v = formData.get(f); + if (v == null || v === '') continue; + numUpdates[f] = Number(v) | 0; + } + const txtUpdates: Record = {}; + for (const f of TXT_FIELDS) { + const v = formData.get(f); + if (v == null) continue; + txtUpdates[f] = String(v).slice(0, 250); + } + const longUpdates: Record = {}; + for (const f of TEXTAREA_FIELDS) { + const v = formData.get(f); + if (v == null) continue; + longUpdates[f] = String(v).slice(0, 16000); + } + + // Build SET clause carefully — postgres-js parameterized + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(numUpdates)) { sets.push(`${k} = $${vals.length + 1}`); vals.push(v); } + for (const [k, v] of Object.entries(txtUpdates)) { sets.push(`${k} = $${vals.length + 1}`); vals.push(v); } + for (const [k, v] of Object.entries(longUpdates)) { sets.push(`${k} = $${vals.length + 1}`); vals.push(v); } + if (sets.length > 0) { + const sql = `UPDATE inspection2.g5_board SET ${sets.join(', ')} WHERE bo_table = $${vals.length + 1}`; + vals.push(slug); + await legacySql.unsafe(sql, vals as never).catch(() => {}); + } + revalidatePath('/admin/boards/' + slug + '/edit'); + revalidatePath('/' + slug); +} + +export default async function BoardEdit({ params }: { params: Promise<{ bo_table: string }> }) { + await requireAdmin(); + const { bo_table } = await params; + if (!/^[a-z0-9_]+$/i.test(bo_table)) notFound(); + const rows = await legacySql`SELECT * FROM inspection2.g5_board WHERE bo_table = ${bo_table}`.catch(() => []); + const r = rows[0]; + if (!r) notFound(); + + const groups = await legacySql<{ gr_id: string; gr_subject: string }[]>`SELECT gr_id, gr_subject FROM inspection2.g5_group ORDER BY gr_order, gr_id`.catch(() => []); + + return ( +
+
+
게시판 상세설정
+

{String(r.bo_subject ?? bo_table)} ({bo_table})

+

{NUM_FIELDS.length + TXT_FIELDS.length + TEXTAREA_FIELDS.length}개 옵션 — gnuboard /adm/board_form.php 동등

+
+ +
+ + +
+ + ({ value: g.gr_id, label: `${g.gr_id} — ${g.gr_subject}` }))} /> + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+

사이트별 커스텀 옵션. 그누보드에서 게시판별 임의 데이터 보관에 사용.

+ + {[1,2,3,4,5,6,7,8,9,10].map((n) => ( +
+ + +
+ ))} +
+
+ +
+ 사용자 화면 보기 → + +
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} +function Grid({ children, cols = 2 }: { children: React.ReactNode; cols?: 2 | 3 | 4 }) { + const cls = cols === 4 ? 'sm:grid-cols-4' : cols === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2'; + return
{children}
; +} +function TextField({ name, label, defaultValue }: { name: string; label: string; defaultValue: string }) { + return ( + + ); +} +function NumField({ name, label, defaultValue }: { name: string; label: string; defaultValue: number }) { + return ( + + ); +} +function TextareaField({ name, label, defaultValue }: { name: string; label: string; defaultValue: string }) { + return ( +