From ac062300a36d26223a447ce79a5a3d35f891aeac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 12:20:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9E=90=EC=B2=B4=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20(Turso/libSQ?= =?UTF-8?q?L=20+=20Vercel=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 블루스카이 대신 직접 만든 코멘트 기능. 정적 GH Pages 사이트가 Vercel 서버리스 API(/api/comments)를 CORS로 호출하고, libSQL(Turso)에 익명+이름 코멘트를 저장한다. - src/lib/comments.ts: libSQL 클라이언트 + 조회/작성 + 검증/레이트리밋/IP 해시 - src/pages/api/comments.ts: GET(목록)/POST(작성)/OPTIONS, 허니팟 - src/components/Comments/: React 아일랜드(SWR), client:visible로 메모 하단 삽입 - scripts/init-comments.mjs, moderate-comments.mjs: 초기화/직접 모더레이션 - 스팸 방어: 허니팟 + IP 레이트리밋(60초당 3) + status(approved/hidden/spam) - vite 8.1.0 고정: libsql 추가 시 tailwind가 vite5로 재바인딩되는 문제 방지 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MFFjbY1BjD6UEUj3ko7N7N --- .env.example | 7 + .gitignore | 4 + README.md | 23 ++ package.json | 4 +- pnpm-lock.yaml | 255 +++++++++++++------ scripts/init-comments.mjs | 35 +++ scripts/moderate-comments.mjs | 70 +++++ src/components/Comments/CommentForm.tsx | 88 +++++++ src/components/Comments/CommentList.tsx | 45 ++++ src/components/Comments/CommentPrimitive.tsx | 25 ++ src/components/Comments/Comments.tsx | 44 ++++ src/components/Comments/Comments.types.ts | 52 ++++ src/lib/comments.ts | 127 +++++++++ src/pages/api/comments.ts | 58 +++++ src/pages/memo/[id].astro | 2 + tsconfig.json | 1 + vercel.json | 4 + 17 files changed, 762 insertions(+), 82 deletions(-) create mode 100644 .env.example create mode 100644 scripts/init-comments.mjs create mode 100644 scripts/moderate-comments.mjs create mode 100644 src/components/Comments/CommentForm.tsx create mode 100644 src/components/Comments/CommentList.tsx create mode 100644 src/components/Comments/CommentPrimitive.tsx create mode 100644 src/components/Comments/Comments.tsx create mode 100644 src/components/Comments/Comments.types.ts create mode 100644 src/lib/comments.ts create mode 100644 src/pages/api/comments.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae9e2b0 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# 코멘트 저장소 (Turso / libSQL) +# Vercel 프로젝트 환경변수에도 동일하게 등록하세요. +TURSO_DATABASE_URL=libsql://your-db.turso.io +TURSO_AUTH_TOKEN=your-turso-auth-token + +# IP 해시용 솔트 (레이트리밋/중복 판별에만 사용, 원본 IP는 저장하지 않음) +COMMENT_IP_SALT=change-me diff --git a/.gitignore b/.gitignore index e8393fc..17effdb 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ yarn-error.log* .cached +# local comment db (libSQL/Turso dev) +comments.db +comments.db-* + dist .astro public/search-db.json diff --git a/README.md b/README.md index 29bba70..b0f9534 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,26 @@ CREATE TABLE link ( url TEXT NOT NULL ); ``` + +## 코멘트 + +메모별 코멘트는 별도 libSQL(Turso) DB에 저장합니다. Vercel 서버리스 API(`/api/comments`)가 +읽기/쓰기를 담당하고, 정적 사이트가 CORS로 호출합니다. + +```sql +CREATE TABLE comment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memo_id TEXT NOT NULL, + author TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'approved' + CHECK(status IN ('approved', 'hidden', 'spam')), + ip_hash TEXT, + ctime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +- 초기화: `node scripts/init-comments.mjs` +- 모더레이션: `node scripts/moderate-comments.mjs list|hide|spam|approve|delete` +- 환경변수: `.env.example` 참고 (`TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, `COMMENT_IP_SALT`) +- 스팸 방어: 허니팟 필드 + IP 레이트리밋(60초당 3개) + 승인 상태 기반 노출 diff --git a/package.json b/package.json index db8492e..0d83b25 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@astrojs/sitemap": "^3.7.3", "@astrojs/vercel": "^11.0.0", "@formkit/tempo": "^0.0.16", + "@libsql/client": "^0.17.4", "@shikijs/transformers": "^1.10.0", "@tailwindcss/vite": "4.3.1", "@testing-library/jest-dom": "latest", @@ -59,7 +60,8 @@ "eslint-plugin-astro": "^1.2.3", "eslint-plugin-jsx-a11y": "^6.9.0", "fast-glob": "^3.3.2", - "husky": "^9.1.7" + "husky": "^9.1.7", + "vite": "8.1.0" }, "packageManager": "pnpm@9.7.0", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2d75ee..392c4fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@formkit/tempo': specifier: ^0.0.16 version: 0.0.16 + '@libsql/client': + specifier: ^0.17.4 + version: 0.17.4 '@shikijs/transformers': specifier: ^1.10.0 version: 1.24.2 @@ -135,6 +138,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + vite: + specifier: 8.1.0 + version: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.1) packages: @@ -158,28 +164,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@astrojs/compiler-binding-linux-arm64-musl@0.2.3': resolution: {integrity: sha512-O3e2CbN4yTsRguWYNnRd0p5YQ0H3fb7KpcR0W4R319q/gq5B1pJ7eqNbiO3b8g2AuiEcRTiUz5jeGT9j69cxOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@astrojs/compiler-binding-linux-x64-gnu@0.2.3': resolution: {integrity: sha512-hbLBjXVp+96psMe7/7uqyrquGiULXANrq6REVxxPK/I5VzebZ7LHmSfykmByUbLyR1u+K6CTBKgvdQsK2L+2Xw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@astrojs/compiler-binding-linux-x64-musl@0.2.3': resolution: {integrity: sha512-vIiEvOwrJfHZMaTmqUCrFTIwMYL0+PD3Rvy7kFDQgERyx3zhaw8CPa01MCCqa+/sj344BGrXKZ6ti37SgNLMhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@astrojs/compiler-binding-wasm32-wasi@0.2.3': resolution: {integrity: sha512-p9S2X8z/mUR2SMzAVJRFMCt8YaalKR+pjl2DgpdjzCQc6ww4bo8kiy54tgKqxZeNF5c+/2tCDTQIxVSm9V1FsA==} @@ -258,10 +260,6 @@ packages: peerDependencies: astro: ^7.0.0-alpha.0 - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -388,25 +386,21 @@ packages: resolution: {integrity: sha512-A/pWy8Jb/PhDYc2/JFuYh06gFJcsfBUBDl81YydGYBrL/Z4nItDfhNDNOibyeSN/lKKDRlycIHEIajjErk00sQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@bruits/satteri-linux-arm64-musl@0.9.3': resolution: {integrity: sha512-L6YxmyOSickzo4pE5WmZfNTJnjX0MtgKOsuwQfNZECTx9Ir5vl2B37EIwnxe2AybuPPHl+FqVQtthNDUdH4Vgg==} cpu: [arm64] os: [linux] - libc: [musl] '@bruits/satteri-linux-x64-gnu@0.9.3': resolution: {integrity: sha512-RgH6GPihg9Lzs2yHUsMjqiLxfLyOdmBty8sg9pBY9B4CBnvdOzvg8vklqN+C4qrEEdA9TwpbDpHr1AshLKyRpw==} cpu: [x64] os: [linux] - libc: [glibc] '@bruits/satteri-linux-x64-musl@0.9.3': resolution: {integrity: sha512-BeWhVORjNTIomePznUKiMbHZTqC0j7sMXZFsISmbX+po5d33KLkqBqKh6K332CHJ8KUmCWx16FfPjwsoysttQg==} cpu: [x64] os: [linux] - libc: [musl] '@bruits/satteri-wasm32-wasi@0.9.3': resolution: {integrity: sha512-dFNcOHKWV2cztCPnYTn7kZ9D7kNOt8N239z5ysFkNHLxJrfK7zaKIXQbfXYN32C+JoVFqAcTIOeWH2+VnsCOHg==} @@ -825,105 +819,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -986,6 +964,63 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@libsql/client@0.17.4': + resolution: {integrity: sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==} + + '@libsql/core@0.17.4': + resolution: {integrity: sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.10.0': + resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + '@mapbox/node-pre-gyp@2.0.0': resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==} engines: {node: '>=18'} @@ -1000,6 +1035,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} @@ -1090,42 +1128,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -1238,127 +1270,106 @@ packages: resolution: {integrity: sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.40.2': resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.28.1': resolution: {integrity: sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.40.2': resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.28.1': resolution: {integrity: sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.40.2': resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.28.1': resolution: {integrity: sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-musl@4.40.2': resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.28.1': resolution: {integrity: sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loongarch64-gnu@4.40.2': resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.28.1': resolution: {integrity: sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.28.1': resolution: {integrity: sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.2': resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.2': resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.28.1': resolution: {integrity: sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.40.2': resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.28.1': resolution: {integrity: sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.28.1': resolution: {integrity: sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-linux-x64-musl@4.40.2': resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.28.1': resolution: {integrity: sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==} @@ -1477,28 +1488,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.1': resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.1': resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.1': resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.1': resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} @@ -1639,6 +1646,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/parser@8.18.1': resolution: {integrity: sha512-rBnTWHCdbYM2lh7hjyXqxk70wvon3p2FyaniZuey5TrcGBpfhVp0OxOa6gxr9Q9YhZFKyfbEnxc24ZnVbbUkCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2203,12 +2213,12 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} - detect-libc@2.0.4: - resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} detect-libc@2.1.2: @@ -2937,6 +2947,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-base64@3.8.1: + resolution: {integrity: sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2997,6 +3010,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3032,28 +3050,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3630,6 +3644,9 @@ packages: resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} engines: {node: '>=18.0.0'} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -4784,12 +4801,6 @@ snapshots: - vue-router - ws - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -5336,10 +5347,68 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@libsql/client@0.17.4': + dependencies: + '@libsql/core': 0.17.4 + '@libsql/hrana-client': 0.10.0 + js-base64: 3.8.1 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.17.4': + dependencies: + js-base64: 3.8.1 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.10.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.8.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + '@mapbox/node-pre-gyp@2.0.0': dependencies: consola: 3.2.3 - detect-libc: 2.0.4 + detect-libc: 2.1.2 https-proxy-agent: 7.0.6 node-fetch: 2.7.0 nopt: 8.0.0 @@ -5386,6 +5455,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@neon-rs/load@0.0.4': {} + '@nodable/entities@2.2.0': {} '@nodelib/fs.scandir@2.1.5': @@ -5755,7 +5826,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.27.1 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -5878,6 +5949,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.2 + '@typescript-eslint/parser@8.18.1(eslint@9.17.0(jiti@2.7.0))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.18.1 @@ -6538,12 +6613,11 @@ snapshots: destr@2.0.5: {} - detect-libc@2.0.3: {} + detect-libc@2.0.2: {} - detect-libc@2.0.4: {} + detect-libc@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} devalue@5.8.1: {} @@ -7504,6 +7578,8 @@ snapshots: jose@5.10.0: {} + js-base64@3.8.1: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -7576,6 +7652,21 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + lightningcss-android-arm64@1.32.0: optional: true @@ -7611,7 +7702,7 @@ snapshots: lightningcss@1.32.0: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 optionalDependencies: lightningcss-android-arm64: 1.32.0 lightningcss-darwin-arm64: 1.32.0 @@ -8412,6 +8503,8 @@ snapshots: process-ancestry@0.1.0: {} + promise-limit@2.7.0: {} + property-information@6.5.0: {} property-information@7.0.0: {} @@ -9337,7 +9430,7 @@ snapshots: vite@5.4.11(@types/node@24.13.2)(lightningcss@1.32.0): dependencies: esbuild: 0.21.5 - postcss: 8.4.49 + postcss: 8.5.15 rollup: 4.28.1 optionalDependencies: '@types/node': 24.13.2 diff --git a/scripts/init-comments.mjs b/scripts/init-comments.mjs new file mode 100644 index 0000000..7224191 --- /dev/null +++ b/scripts/init-comments.mjs @@ -0,0 +1,35 @@ +/** + * 코멘트 테이블 초기화. + * + * TURSO_DATABASE_URL=libsql://... TURSO_AUTH_TOKEN=... node scripts/init-comments.mjs + * + * 환경변수가 없으면 로컬 file:comments.db 에 생성합니다 (개발용). + */ +import { createClient } from '@libsql/client' + +const client = createClient({ + url: process.env.TURSO_DATABASE_URL ?? 'file:comments.db', + authToken: process.env.TURSO_AUTH_TOKEN, +}) + +await client.batch( + [ + `CREATE TABLE IF NOT EXISTS comment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memo_id TEXT NOT NULL, + author TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'approved' + CHECK(status IN ('approved', 'hidden', 'spam')), + ip_hash TEXT, + ctime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_comment_memo + ON comment(memo_id, status, ctime)`, + `CREATE INDEX IF NOT EXISTS idx_comment_ip_time + ON comment(ip_hash, ctime)`, + ], + 'write' +) + +console.log('✅ comment 테이블 준비 완료') diff --git a/scripts/moderate-comments.mjs b/scripts/moderate-comments.mjs new file mode 100644 index 0000000..b7fa7c7 --- /dev/null +++ b/scripts/moderate-comments.mjs @@ -0,0 +1,70 @@ +/** + * 코멘트 모더레이션 (직접 DB 접근 방식 — 관리 UI 없음). + * + * node scripts/moderate-comments.mjs list [memoId] + * node scripts/moderate-comments.mjs hide + * node scripts/moderate-comments.mjs spam + * node scripts/moderate-comments.mjs approve + * node scripts/moderate-comments.mjs delete + * + * 환경변수: TURSO_DATABASE_URL, TURSO_AUTH_TOKEN (미설정 시 file:comments.db) + */ +import { createClient } from '@libsql/client' + +const client = createClient({ + url: process.env.TURSO_DATABASE_URL ?? 'file:comments.db', + authToken: process.env.TURSO_AUTH_TOKEN, +}) + +const [command, arg] = process.argv.slice(2) + +async function setStatus(id, status) { + await client.execute({ + sql: `UPDATE comment SET status = ? WHERE id = ?`, + args: [status, Number(id)], + }) + console.log(`✅ #${id} → ${status}`) +} + +switch (command) { + case 'list': { + const rs = arg + ? await client.execute({ + sql: `SELECT id, memo_id, author, status, ctime, body + FROM comment WHERE memo_id = ? ORDER BY ctime DESC`, + args: [arg], + }) + : await client.execute( + `SELECT id, memo_id, author, status, ctime, body + FROM comment ORDER BY ctime DESC LIMIT 50` + ) + + for (const r of rs.rows) { + const preview = String(r.body).replace(/\s+/g, ' ').slice(0, 60) + console.log( + `#${r.id} [${r.status}] memo:${r.memo_id} ${r.author} (${r.ctime}) — ${preview}` + ) + } + break + } + case 'hide': + await setStatus(arg, 'hidden') + break + case 'spam': + await setStatus(arg, 'spam') + break + case 'approve': + await setStatus(arg, 'approved') + break + case 'delete': + await client.execute({ + sql: `DELETE FROM comment WHERE id = ?`, + args: [Number(arg)], + }) + console.log(`🗑️ #${arg} 삭제됨`) + break + default: + console.log( + 'usage: moderate-comments.mjs [arg]' + ) +} diff --git a/src/components/Comments/CommentForm.tsx b/src/components/Comments/CommentForm.tsx new file mode 100644 index 0000000..e2075f8 --- /dev/null +++ b/src/components/Comments/CommentForm.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import { LIMITS, postComment, type Comment } from './Comments.types' +import { + CommentFormRoot, + CommentInput, + CommentTextarea, + CommentSubmit, + CommentHint, + CommentError, +} from './CommentPrimitive' + +type Props = { + memoId: string + onCreated: (comment: Comment) => void +} + +export function CommentForm({ memoId, onCreated }: Props) { + const [author, setAuthor] = useState('') + const [body, setBody] = useState('') + const [website, setWebsite] = useState('') // 허니팟 + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + + const canSubmit = + author.trim().length >= LIMITS.author.min && + body.trim().length >= LIMITS.body.min && + !pending + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + if (!canSubmit) { + return + } + + setPending(true) + setError(null) + + try { + const comment = await postComment({ memoId, author, body, website }) + + onCreated(comment) + setBody('') + } catch (err) { + setError(err instanceof Error ? err.message : '작성에 실패했습니다.') + } finally { + setPending(false) + } + } + + return ( + + setAuthor(e.target.value)} + /> + setBody(e.target.value)} + /> + setWebsite(e.target.value)} + /> + {error && {error}} +
+ + {body.trim().length}/{LIMITS.body.max} + + + {pending ? '작성 중…' : '작성'} + +
+
+ ) +} diff --git a/src/components/Comments/CommentList.tsx b/src/components/Comments/CommentList.tsx new file mode 100644 index 0000000..0619572 --- /dev/null +++ b/src/components/Comments/CommentList.tsx @@ -0,0 +1,45 @@ +import { format } from '@formkit/tempo' +import type { Comment } from './Comments.types' +import { + CommentItem, + CommentMeta, + CommentAuthor, + CommentBody, +} from './CommentPrimitive' + +/** SQLite CURRENT_TIMESTAMP('YYYY-MM-DD HH:MM:SS', UTC)을 로컬 시각으로 파싱 */ +function parseUtc(ctime: string): Date { + return new Date(`${ctime.replace(' ', 'T')}Z`) +} + +type Props = { + comments: Comment[] +} + +export function CommentList({ comments }: Props) { + if (comments.length === 0) { + return ( +

+ 아직 코멘트가 없습니다. 첫 코멘트를 남겨보세요. +

+ ) + } + + return ( +
    + {comments.map((comment) => ( +
  • + + + {comment.author} + + + {comment.body} + +
  • + ))} +
+ ) +} diff --git a/src/components/Comments/CommentPrimitive.tsx b/src/components/Comments/CommentPrimitive.tsx new file mode 100644 index 0000000..4131958 --- /dev/null +++ b/src/components/Comments/CommentPrimitive.tsx @@ -0,0 +1,25 @@ +import { twc } from 'react-twc' + +export const CommentSection = twc.section`mt-4 flex flex-col gap-3` + +export const CommentSectionTitle = twc.h2`text-[10px] text-(--flexoki-400)` + +export const CommentItem = twc.article`border-2 border-(--flexoki-950) p-[10px] rounded-lg text-sm` + +export const CommentMeta = twc.div`flex items-center gap-2 pb-2 text-[10px] text-(--flexoki-400)` + +export const CommentAuthor = twc.span`font-bold text-(--flexoki-200)` + +export const CommentBody = twc.p`whitespace-pre-wrap break-words` + +export const CommentFormRoot = twc.form`flex flex-col gap-2 border-2 border-(--flexoki-950) p-[10px] rounded-lg` + +export const CommentInput = twc.input`bg-(--flexoki-950) rounded-md px-2 py-1 text-sm outline-none focus:ring-1 focus:ring-(--flexoki-700)` + +export const CommentTextarea = twc.textarea`bg-(--flexoki-950) rounded-md px-2 py-1 text-sm outline-none resize-y min-h-[64px] focus:ring-1 focus:ring-(--flexoki-700)` + +export const CommentSubmit = twc.button`self-end px-3 py-1 rounded-lg bg-(--flexoki-950) text-xs text-(--flexoki-cyan-600) hover:text-(--flexoki-cyan-400) disabled:opacity-50 disabled:cursor-not-allowed` + +export const CommentHint = twc.p`text-[10px] text-(--flexoki-400)` + +export const CommentError = twc.p`text-[10px] text-(--flexoki-red-400)` diff --git a/src/components/Comments/Comments.tsx b/src/components/Comments/Comments.tsx new file mode 100644 index 0000000..c3ada63 --- /dev/null +++ b/src/components/Comments/Comments.tsx @@ -0,0 +1,44 @@ +import useSWR from 'swr' +import { fetchComments, type Comment } from './Comments.types' +import { CommentList } from './CommentList' +import { CommentForm } from './CommentForm' +import { CommentSection, CommentSectionTitle } from './CommentPrimitive' + +type Props = { + memoId: string +} + +export function Comments({ memoId }: Props) { + const { data, error, isLoading, mutate } = useSWR( + ['comments', memoId], + () => fetchComments(memoId), + { revalidateOnFocus: false } + ) + + const comments = data ?? [] + + function handleCreated(comment: Comment) { + // 낙관적 반영 후 서버와 동기화 + mutate([...comments, comment], { revalidate: true }) + } + + return ( + + 코멘트 {comments.length} + + {isLoading && ( +

불러오는 중…

+ )} + + {error && ( +

+ 코멘트를 불러오지 못했습니다. +

+ )} + + {!isLoading && !error && } + + +
+ ) +} diff --git a/src/components/Comments/Comments.types.ts b/src/components/Comments/Comments.types.ts new file mode 100644 index 0000000..ccb9ef2 --- /dev/null +++ b/src/components/Comments/Comments.types.ts @@ -0,0 +1,52 @@ +export interface Comment { + id: number + memo_id: string + author: string + body: string + ctime: string +} + +export const LIMITS = { + author: { min: 1, max: 40 }, + body: { min: 1, max: 2000 }, +} as const + +const API_BASE = import.meta.env.PROD ? 'https://cbcruk-github-io.vercel.app' : '' + +export function commentsUrl(memoId: string): string { + return `${API_BASE}/api/comments?memoId=${encodeURIComponent(memoId)}` +} + +export async function fetchComments(memoId: string): Promise { + const res = await fetch(commentsUrl(memoId)) + + if (!res.ok) { + throw new Error('코멘트를 불러오지 못했습니다.') + } + + return res.json() +} + +export interface PostCommentInput { + memoId: string + author: string + body: string + /** 허니팟 — 항상 빈 문자열로 전송 */ + website: string +} + +export async function postComment(input: PostCommentInput): Promise { + const res = await fetch(`${API_BASE}/api/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + + const data = await res.json().catch(() => ({})) + + if (!res.ok) { + throw new Error(data?.error ?? '코멘트 작성에 실패했습니다.') + } + + return data +} diff --git a/src/lib/comments.ts b/src/lib/comments.ts new file mode 100644 index 0000000..42d6a88 --- /dev/null +++ b/src/lib/comments.ts @@ -0,0 +1,127 @@ +import { createClient, type Client } from '@libsql/client' +import { createHash } from 'node:crypto' + +/** + * 코멘트 저장소 (Turso / libSQL). + * + * 환경변수: + * - TURSO_DATABASE_URL 예) libsql://xxx.turso.io (미설정 시 로컬 file:comments.db) + * - TURSO_AUTH_TOKEN Turso 인증 토큰 + * - COMMENT_IP_SALT IP 해시용 솔트 (미설정 시 기본값) + */ + +const DATABASE_URL = process.env.TURSO_DATABASE_URL ?? 'file:comments.db' +const AUTH_TOKEN = process.env.TURSO_AUTH_TOKEN +const IP_SALT = process.env.COMMENT_IP_SALT ?? 'cbcruk-comment-salt' + +export const LIMITS = { + author: { min: 1, max: 40 }, + body: { min: 1, max: 2000 }, + /** 같은 IP에서 rate.seconds 안에 rate.count개 초과 작성 금지 */ + rate: { count: 3, seconds: 60 }, +} as const + +export type CommentStatus = 'approved' | 'hidden' | 'spam' + +export interface Comment { + id: number + memo_id: string + author: string + body: string + ctime: string +} + +let client: Client | null = null + +function getClient(): Client { + if (!client) { + client = createClient({ url: DATABASE_URL, authToken: AUTH_TOKEN }) + } + + return client +} + +export function hashIp(ip: string): string { + return createHash('sha256').update(`${IP_SALT}:${ip}`).digest('hex') +} + +export async function getComments(memoId: string): Promise { + const rs = await getClient().execute({ + sql: `SELECT id, memo_id, author, body, ctime + FROM comment + WHERE memo_id = ? AND status = 'approved' + ORDER BY ctime ASC`, + args: [memoId], + }) + + return rs.rows as unknown as Comment[] +} + +async function countRecentByIp(ipHash: string): Promise { + const rs = await getClient().execute({ + sql: `SELECT COUNT(*) AS n + FROM comment + WHERE ip_hash = ? AND ctime > datetime('now', ?)`, + args: [ipHash, `-${LIMITS.rate.seconds} seconds`], + }) + + return Number(rs.rows[0].n) +} + +export interface CreateCommentInput { + memoId: string + author: string + body: string + ip: string +} + +export type CreateResult = + | { ok: true; comment: Comment } + | { ok: false; status: number; error: string } + +export async function createComment( + input: CreateCommentInput +): Promise { + const memoId = input.memoId.trim() + const author = input.author.trim() + const body = input.body.trim() + + if (!memoId) { + return { ok: false, status: 400, error: '대상 메모가 없습니다.' } + } + + if (author.length < LIMITS.author.min || author.length > LIMITS.author.max) { + return { + ok: false, + status: 400, + error: `이름은 ${LIMITS.author.min}~${LIMITS.author.max}자여야 합니다.`, + } + } + + if (body.length < LIMITS.body.min || body.length > LIMITS.body.max) { + return { + ok: false, + status: 400, + error: `내용은 ${LIMITS.body.min}~${LIMITS.body.max}자여야 합니다.`, + } + } + + const ipHash = hashIp(input.ip) + + if ((await countRecentByIp(ipHash)) >= LIMITS.rate.count) { + return { + ok: false, + status: 429, + error: '잠시 후 다시 시도해주세요.', + } + } + + const rs = await getClient().execute({ + sql: `INSERT INTO comment (memo_id, author, body, ip_hash) + VALUES (?, ?, ?, ?) + RETURNING id, memo_id, author, body, ctime`, + args: [memoId, author, body, ipHash], + }) + + return { ok: true, comment: rs.rows[0] as unknown as Comment } +} diff --git a/src/pages/api/comments.ts b/src/pages/api/comments.ts new file mode 100644 index 0000000..d4a2ab5 --- /dev/null +++ b/src/pages/api/comments.ts @@ -0,0 +1,58 @@ +export const prerender = process.env.VERCEL ? false : true + +import type { APIRoute } from 'astro' +import { getComments, createComment } from '@lib/comments' + +// CORS 헤더는 vercel.json(/api/*)에서 주입 — search.ts와 동일 방식. +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function clientIp(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for') + + return forwarded?.split(',')[0]?.trim() || 'unknown' +} + +export const OPTIONS: APIRoute = () => new Response(null, { status: 204 }) + +export const GET: APIRoute = async ({ request }) => { + const memoId = new URL(request.url).searchParams.get('memoId') + + if (!memoId) { + return json({ error: 'memoId가 필요합니다.' }, 400) + } + + return json(await getComments(memoId)) +} + +export const POST: APIRoute = async ({ request }) => { + let payload: Record + + try { + payload = await request.json() + } catch { + return json({ error: '잘못된 요청입니다.' }, 400) + } + + // 허니팟: 사람은 비워두는 필드. 채워져 있으면 봇으로 간주하고 조용히 성공 처리. + if (typeof payload.website === 'string' && payload.website.trim() !== '') { + return json({ ok: true }, 200) + } + + const result = await createComment({ + memoId: String(payload.memoId ?? ''), + author: String(payload.author ?? ''), + body: String(payload.body ?? ''), + ip: clientIp(request), + }) + + if (!result.ok) { + return json({ error: result.error }, result.status) + } + + return json(result.comment, 201) +} diff --git a/src/pages/memo/[id].astro b/src/pages/memo/[id].astro index 65143c5..4733788 100644 --- a/src/pages/memo/[id].astro +++ b/src/pages/memo/[id].astro @@ -4,6 +4,7 @@ import Layout from '@layouts/Layout.astro' import { Memo } from '@components/Memo/Memo' import { RelatedMemos } from '@components/Memo/RelatedMemos' import { MemoLineage } from '@components/Memo/MemoLineage' +import { Comments } from '@components/Comments/Comments' import { getReleaseMemoCollection, getRelatedMemos, @@ -53,4 +54,5 @@ const PAGE_DESCRIPTION = memo.data.description || '' supersededBy={lineage.supersededBy} /> + diff --git a/tsconfig.json b/tsconfig.json index 24bea5d..aa8439b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "paths": { "@components/*": ["src/components/*"], "@layouts/*": ["src/layouts/*"], + "@lib/*": ["src/lib/*"], "@content/*": ["src/content/*"], "@test/*": ["src/test/*"], "@collection/*": ["src/collection/*"], diff --git a/vercel.json b/vercel.json index dde7c2a..4a8ea11 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,10 @@ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type" + }, { "key": "Access-Control-Allow-Credentials", "value": "true" } ] } From 846de64942e6a2903669d0c96ec2d518fbd0493c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:11:07 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=BD=94=EB=A9=98=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=8A=B9=EC=9D=B8=20=ED=81=90=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작성 즉시 노출 → 승인 후 노출로 변경. 익명 코멘트의 스팸/노출 통제 강화. - 스키마 기본값 approved → pending, CHECK에 pending 추가 - POST 응답을 {status:'pending'}으로, 폼은 "승인 후 표시" 안내 - Comments 아일랜드: 낙관적 삽입 제거, 승인 안내 문구 추가 - moderate: `pending` 큐 조회 추가, `list`는 대기 건 우선 정렬 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MFFjbY1BjD6UEUj3ko7N7N --- README.md | 9 +++--- scripts/init-comments.mjs | 4 +-- scripts/moderate-comments.mjs | 38 ++++++++++++++++------- src/components/Comments/CommentForm.tsx | 18 +++++++---- src/components/Comments/Comments.tsx | 16 +++++----- src/components/Comments/Comments.types.ts | 8 ++--- src/lib/comments.ts | 2 +- src/pages/api/comments.ts | 3 +- 8 files changed, 60 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b0f9534..ffbd657 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,15 @@ CREATE TABLE comment ( memo_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'approved' - CHECK(status IN ('approved', 'hidden', 'spam')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'approved', 'hidden', 'spam')), ip_hash TEXT, ctime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ``` - 초기화: `node scripts/init-comments.mjs` -- 모더레이션: `node scripts/moderate-comments.mjs list|hide|spam|approve|delete` +- 사전 승인 큐: 작성 시 `pending`으로 저장 → `approve` 해야 노출 +- 모더레이션: `node scripts/moderate-comments.mjs pending|list|approve|hide|spam|delete` - 환경변수: `.env.example` 참고 (`TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, `COMMENT_IP_SALT`) -- 스팸 방어: 허니팟 필드 + IP 레이트리밋(60초당 3개) + 승인 상태 기반 노출 +- 스팸 방어: 허니팟 필드 + IP 레이트리밋(60초당 3개) + 사전 승인 diff --git a/scripts/init-comments.mjs b/scripts/init-comments.mjs index 7224191..a9ae2da 100644 --- a/scripts/init-comments.mjs +++ b/scripts/init-comments.mjs @@ -19,8 +19,8 @@ await client.batch( memo_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'approved' - CHECK(status IN ('approved', 'hidden', 'spam')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'approved', 'hidden', 'spam')), ip_hash TEXT, ctime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, diff --git a/scripts/moderate-comments.mjs b/scripts/moderate-comments.mjs index b7fa7c7..f0e5343 100644 --- a/scripts/moderate-comments.mjs +++ b/scripts/moderate-comments.mjs @@ -1,10 +1,11 @@ /** * 코멘트 모더레이션 (직접 DB 접근 방식 — 관리 UI 없음). * - * node scripts/moderate-comments.mjs list [memoId] + * node scripts/moderate-comments.mjs pending 승인 대기 목록 + * node scripts/moderate-comments.mjs list [memoId] 전체(대기 먼저) + * node scripts/moderate-comments.mjs approve 승인 → 노출 * node scripts/moderate-comments.mjs hide * node scripts/moderate-comments.mjs spam - * node scripts/moderate-comments.mjs approve * node scripts/moderate-comments.mjs delete * * 환경변수: TURSO_DATABASE_URL, TURSO_AUTH_TOKEN (미설정 시 file:comments.db) @@ -26,25 +27,40 @@ async function setStatus(id, status) { console.log(`✅ #${id} → ${status}`) } +function print(rows) { + for (const r of rows) { + const preview = String(r.body).replace(/\s+/g, ' ').slice(0, 60) + console.log( + `#${r.id} [${r.status}] memo:${r.memo_id} ${r.author} (${r.ctime}) — ${preview}` + ) + } +} + switch (command) { + case 'pending': { + const rs = await client.execute( + `SELECT id, memo_id, author, status, ctime, body + FROM comment WHERE status = 'pending' ORDER BY ctime ASC` + ) + print(rs.rows) + console.log(`\n대기 ${rs.rows.length}건. approve 로 승인하세요.`) + break + } case 'list': { const rs = arg ? await client.execute({ sql: `SELECT id, memo_id, author, status, ctime, body - FROM comment WHERE memo_id = ? ORDER BY ctime DESC`, + FROM comment WHERE memo_id = ? + ORDER BY (status = 'pending') DESC, ctime DESC`, args: [arg], }) : await client.execute( `SELECT id, memo_id, author, status, ctime, body - FROM comment ORDER BY ctime DESC LIMIT 50` + FROM comment + ORDER BY (status = 'pending') DESC, ctime DESC LIMIT 50` ) - for (const r of rs.rows) { - const preview = String(r.body).replace(/\s+/g, ' ').slice(0, 60) - console.log( - `#${r.id} [${r.status}] memo:${r.memo_id} ${r.author} (${r.ctime}) — ${preview}` - ) - } + print(rs.rows) break } case 'hide': @@ -65,6 +81,6 @@ switch (command) { break default: console.log( - 'usage: moderate-comments.mjs [arg]' + 'usage: moderate-comments.mjs [arg]' ) } diff --git a/src/components/Comments/CommentForm.tsx b/src/components/Comments/CommentForm.tsx index e2075f8..a6477ae 100644 --- a/src/components/Comments/CommentForm.tsx +++ b/src/components/Comments/CommentForm.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { LIMITS, postComment, type Comment } from './Comments.types' +import { LIMITS, postComment } from './Comments.types' import { CommentFormRoot, CommentInput, @@ -11,15 +11,15 @@ import { type Props = { memoId: string - onCreated: (comment: Comment) => void } -export function CommentForm({ memoId, onCreated }: Props) { +export function CommentForm({ memoId }: Props) { const [author, setAuthor] = useState('') const [body, setBody] = useState('') const [website, setWebsite] = useState('') // 허니팟 const [pending, setPending] = useState(false) const [error, setError] = useState(null) + const [done, setDone] = useState(false) const canSubmit = author.trim().length >= LIMITS.author.min && @@ -37,10 +37,10 @@ export function CommentForm({ memoId, onCreated }: Props) { setError(null) try { - const comment = await postComment({ memoId, author, body, website }) + await postComment({ memoId, author, body, website }) - onCreated(comment) setBody('') + setDone(true) } catch (err) { setError(err instanceof Error ? err.message : '작성에 실패했습니다.') } finally { @@ -62,7 +62,10 @@ export function CommentForm({ memoId, onCreated }: Props) { placeholder="코멘트를 남겨주세요" value={body} maxLength={LIMITS.body.max} - onChange={(e) => setBody(e.target.value)} + onChange={(e) => { + setBody(e.target.value) + setDone(false) + }} /> setWebsite(e.target.value)} /> {error && {error}} + {done && ( + 등록되었습니다. 승인 후 표시됩니다. + )}
{body.trim().length}/{LIMITS.body.max} diff --git a/src/components/Comments/Comments.tsx b/src/components/Comments/Comments.tsx index c3ada63..8fa90b7 100644 --- a/src/components/Comments/Comments.tsx +++ b/src/components/Comments/Comments.tsx @@ -2,14 +2,18 @@ import useSWR from 'swr' import { fetchComments, type Comment } from './Comments.types' import { CommentList } from './CommentList' import { CommentForm } from './CommentForm' -import { CommentSection, CommentSectionTitle } from './CommentPrimitive' +import { + CommentSection, + CommentSectionTitle, + CommentHint, +} from './CommentPrimitive' type Props = { memoId: string } export function Comments({ memoId }: Props) { - const { data, error, isLoading, mutate } = useSWR( + const { data, error, isLoading } = useSWR( ['comments', memoId], () => fetchComments(memoId), { revalidateOnFocus: false } @@ -17,11 +21,6 @@ export function Comments({ memoId }: Props) { const comments = data ?? [] - function handleCreated(comment: Comment) { - // 낙관적 반영 후 서버와 동기화 - mutate([...comments, comment], { revalidate: true }) - } - return ( 코멘트 {comments.length} @@ -38,7 +37,8 @@ export function Comments({ memoId }: Props) { {!isLoading && !error && } - + + 코멘트는 승인 후 표시됩니다. ) } diff --git a/src/components/Comments/Comments.types.ts b/src/components/Comments/Comments.types.ts index ccb9ef2..5a6736d 100644 --- a/src/components/Comments/Comments.types.ts +++ b/src/components/Comments/Comments.types.ts @@ -35,18 +35,16 @@ export interface PostCommentInput { website: string } -export async function postComment(input: PostCommentInput): Promise { +export async function postComment(input: PostCommentInput): Promise { const res = await fetch(`${API_BASE}/api/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }) - const data = await res.json().catch(() => ({})) - if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data?.error ?? '코멘트 작성에 실패했습니다.') } - - return data } diff --git a/src/lib/comments.ts b/src/lib/comments.ts index 42d6a88..27a2f27 100644 --- a/src/lib/comments.ts +++ b/src/lib/comments.ts @@ -21,7 +21,7 @@ export const LIMITS = { rate: { count: 3, seconds: 60 }, } as const -export type CommentStatus = 'approved' | 'hidden' | 'spam' +export type CommentStatus = 'pending' | 'approved' | 'hidden' | 'spam' export interface Comment { id: number diff --git a/src/pages/api/comments.ts b/src/pages/api/comments.ts index d4a2ab5..938f512 100644 --- a/src/pages/api/comments.ts +++ b/src/pages/api/comments.ts @@ -54,5 +54,6 @@ export const POST: APIRoute = async ({ request }) => { return json({ error: result.error }, result.status) } - return json(result.comment, 201) + // 사전 승인 큐: 작성 직후엔 노출되지 않고 승인 대기 상태로 저장된다. + return json({ status: 'pending' }, 201) }