From b85202ad1b6be21f5aa86a8a8988a8651699be06 Mon Sep 17 00:00:00 2001 From: 2620227 Date: Mon, 15 Jun 2026 08:02:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EA=B0=A4=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20CRUD=20PoC=20(DB=20+=20MinIO=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB(메타데이터)와 MinIO(바이트)를 함께 실제로 쓰는 PoC. 신규 런타임 의존성 없음. - src/files.js: files 테이블 + saveFile/list/get/rename/delete + getObjectStream - src/server.js: POST/GET/PATCH/DELETE /files, 정적 UI, 부팅 시 테이블 보장 · 업로드는 express.raw(멀티파트 파서 불필요), 다운로드는 S3 스트림 파이프 · 텍스트 charset=utf-8 + RFC5987 파일명으로 한글 깨짐 방지 - public/index.html: 드래그앤드롭 업로드 + 목록/열기/이름변경/삭제 UI - scripts/check-files.js(files:check): DB+S3 라운드트립 자가 검증 Co-Authored-By: Claude Opus 4.8 --- package.json | 3 +- public/index.html | 297 +++++++++++++++++++++++++++++++++++++++++ scripts/check-files.js | 49 +++++++ src/files.js | 92 +++++++++++++ src/server.js | 118 +++++++++++++++- 5 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 public/index.html create mode 100644 scripts/check-files.js create mode 100644 src/files.js diff --git a/package.json b/package.json index 9705560..935d08d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "node --watch src/server.js", "start": "node src/server.js", "db:check": "node scripts/check-db.js", - "minio:check": "node scripts/check-minio.js" + "minio:check": "node scripts/check-minio.js", + "files:check": "node scripts/check-files.js" }, "engines": { "node": ">=22" diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1bb5bfb --- /dev/null +++ b/public/index.html @@ -0,0 +1,297 @@ + + + + + + 파일 갤러리 — DB + MinIO PoC + + + +
+
+ DB + MinIO 연결됨 +

파일 갤러리

+

업로드하면 MinIO(S3)에 저장되고 메타데이터는 Postgres에 기록됩니다.

+
+ +
+ + +
+ 선택된 파일 없음 + +
+
+
+ +
+

파일 목록

+ +
+
    +
    + + + + diff --git a/scripts/check-files.js b/scripts/check-files.js new file mode 100644 index 0000000..9cf5339 --- /dev/null +++ b/scripts/check-files.js @@ -0,0 +1,49 @@ +// DB+S3 라운드트립 한 방 검증: `npm run files:check` +// ensureFilesTable → saveFile → listFiles(포함 확인) → getObjectStream(바이트 일치) → 성공/실패. +import { pool } from "../src/db.js"; +import { + ensureFilesTable, + saveFile, + listFiles, + getObjectStream, +} from "../src/files.js"; + +async function readStream(stream) { + const chunks = []; + for await (const c of stream) chunks.push(c); + return Buffer.concat(chunks); +} + +try { + await ensureFilesTable(); + + const payload = Buffer.from(`poc check ${new Date().toISOString()}`); + const row = await saveFile({ + name: "poc-check.txt", + contentType: "text/plain", + body: payload, + }); + console.log("saveFile OK:", { id: row.id, key: row.key, size: row.size }); + + const files = await listFiles(); + if (!files.some((f) => f.key === row.key)) { + throw new Error("listFiles 에 방금 업로드한 key 가 없음"); + } + console.log("listFiles OK:", files.length, "rows"); + + const obj = await getObjectStream(row.key); + const got = await readStream(obj.body); + if (!got.equals(payload)) { + throw new Error( + `다운로드 바이트 불일치: expected ${payload.length}, got ${got.length}` + ); + } + console.log("getObject OK: 바이트 일치", got.length, "bytes"); + + console.log("FILES CHECK OK ✅ (DB insert/select + MinIO put/get 라운드트립 성공)"); +} catch (e) { + console.error("FILES CHECK FAIL:", e.message); + process.exitCode = 1; +} finally { + await pool.end(); +} diff --git a/src/files.js b/src/files.js new file mode 100644 index 0000000..a51e548 --- /dev/null +++ b/src/files.js @@ -0,0 +1,92 @@ +// 파일 갤러리 도메인 로직 — DB(메타데이터) + MinIO(바이트)를 함께 검증한다. +// 자격증명/접속정보는 src/config.js 한 곳을 거친 pool/s3 를 그대로 재사용한다(코드에 비밀값 없음). +import crypto from "node:crypto"; +import { + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; +import { pool } from "./db.js"; +import { s3 } from "./s3.js"; +import { config } from "./config.js"; + +const BUCKET = config.s3.bucket; + +// 직원 전용 schema가 search_path에 고정돼 있으므로 이 테이블은 본인 schema에 생성된다. +export async function ensureFilesTable() { + await pool.query(` + create table if not exists files ( + id bigserial primary key, + key text unique not null, + name text not null, + size bigint not null, + content_type text, + created_at timestamptz not null default now() + ) + `); +} + +// 파일명에서 경로/위험문자 제거(S3 key 안전). +function safeName(name) { + return String(name).replace(/[^\w.\-]+/g, "_").slice(0, 200) || "file"; +} + +// MinIO 에 업로드 + 메타데이터 insert. 생성된 row 반환. +export async function saveFile({ name, contentType, body }) { + const key = `uploads/${Date.now()}-${crypto.randomUUID()}-${safeName(name)}`; + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: body, + ContentType: contentType || "application/octet-stream", + }) + ); + const { rows } = await pool.query( + `insert into files (key, name, size, content_type) + values ($1, $2, $3, $4) + returning *`, + [key, name, body.length, contentType || null] + ); + return rows[0]; +} + +export async function listFiles() { + const { rows } = await pool.query( + "select * from files order by created_at desc limit 100" + ); + return rows; +} + +export async function getFile(id) { + const { rows } = await pool.query("select * from files where id = $1", [id]); + return rows[0] || null; +} + +// 이름 변경(메타데이터만 수정 — S3 객체/key 는 그대로). 없으면 null. +export async function renameFile(id, name) { + const { rows } = await pool.query( + "update files set name = $2 where id = $1 returning *", + [id, name] + ); + return rows[0] || null; +} + +// 삭제 — MinIO 객체와 DB row 를 함께 제거. 삭제된 row 반환(없으면 null). +export async function deleteFile(id) { + const row = await getFile(id); + if (!row) return null; + await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: row.key })); + await pool.query("delete from files where id = $1", [id]); + return row; +} + +// MinIO 에서 객체를 스트림으로 가져온다(다운로드 시 res 로 파이프). +export async function getObjectStream(key) { + const out = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + return { + body: out.Body, // Node Readable + contentType: out.ContentType, + contentLength: out.ContentLength, + }; +} diff --git a/src/server.js b/src/server.js index e94230a..52d4f74 100644 --- a/src/server.js +++ b/src/server.js @@ -1,11 +1,23 @@ -// 최소 Express 서버 — /healthz, /db, /s3 로 연결 확인. +// 최소 Express 서버 — /healthz, /db, /s3 연결 확인 + /files 갤러리(DB+S3 실사용 검증). import express from "express"; import { config } from "./config.js"; import { pingDb } from "./db.js"; import { pingS3 } from "./s3.js"; +import { + ensureFilesTable, + saveFile, + listFiles, + getFile, + renameFile, + deleteFile, + getObjectStream, +} from "./files.js"; const app = express(); +// 최소 업로드 UI(public/index.html). +app.use(express.static("public")); + app.get("/healthz", (_req, res) => res.json({ ok: true })); app.get("/db", async (_req, res) => { @@ -24,10 +36,110 @@ app.get("/s3", async (_req, res) => { } }); -app.get("/", (_req, res) => - res.json({ name: "sample", endpoints: ["/healthz", "/db", "/s3"] }) +// 업로드: 원시 바이트 본문(멀티파트 파서 불필요). 파일명은 ?name= 또는 X-Filename 헤더. +// curl -X POST "localhost:3000/files?name=a.txt" -H "Content-Type: text/plain" --data-binary "hi" +// 브라우저: fetch('/files?name='+file.name, { method:'POST', body:file }) +app.post( + "/files", + express.raw({ type: "*/*", limit: "25mb" }), + async (req, res) => { + try { + const name = req.query.name || req.headers["x-filename"]; + if (!name) { + return res + .status(400) + .json({ ok: false, error: "파일명이 필요합니다(?name= 또는 X-Filename)" }); + } + if (!req.body || !req.body.length) { + return res.status(400).json({ ok: false, error: "빈 본문입니다" }); + } + const row = await saveFile({ + name: String(name), + contentType: req.headers["content-type"], + body: req.body, + }); + res.status(201).json({ ok: true, ...row }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } + } ); +app.get("/files", async (_req, res) => { + try { + res.json({ ok: true, files: await listFiles() }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } +}); + +// 텍스트류인데 charset 이 없으면 UTF-8 명시(한글 본문 깨짐 방지). +function withCharset(ct) { + const type = ct || "application/octet-stream"; + const isText = + /^text\//i.test(type) || /(json|xml|javascript|csv|svg)/i.test(type); + return isText && !/charset=/i.test(type) ? `${type}; charset=utf-8` : type; +} + +// 한글 등 비ASCII 파일명을 RFC 5987 방식으로 내려준다(ASCII fallback + filename*). +function contentDisposition(name) { + const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; +} + +app.get("/files/:id", async (req, res) => { + try { + const row = await getFile(req.params.id); + if (!row) return res.status(404).json({ ok: false, error: "not found" }); + const obj = await getObjectStream(row.key); + res.setHeader("Content-Type", withCharset(obj.contentType)); + res.setHeader("Content-Disposition", contentDisposition(row.name)); + if (obj.contentLength != null) + res.setHeader("Content-Length", obj.contentLength); + obj.body.on("error", (e) => res.destroy(e)); + obj.body.pipe(res); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } +}); + +// 이름 변경(U): PATCH /files/:id { name } +app.patch("/files/:id", express.json(), async (req, res) => { + try { + const name = req.body && req.body.name; + if (!name || !String(name).trim()) { + return res.status(400).json({ ok: false, error: "name 이 필요합니다" }); + } + const row = await renameFile(req.params.id, String(name).trim()); + if (!row) return res.status(404).json({ ok: false, error: "not found" }); + res.json({ ok: true, ...row }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } +}); + +// 삭제(D): DELETE /files/:id — MinIO 객체 + DB row 함께 제거. +app.delete("/files/:id", async (req, res) => { + try { + const row = await deleteFile(req.params.id); + if (!row) return res.status(404).json({ ok: false, error: "not found" }); + res.json({ ok: true, deleted: { id: row.id, name: row.name } }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } +}); + +app.get("/", (_req, res) => + res.json({ name: "sample", endpoints: ["/healthz", "/db", "/s3", "/files"] }) +); + +// 부팅 시 테이블 보장(실패해도 서버는 뜨게 두고 로그만 남김 — /db 로 원인 확인). +try { + await ensureFilesTable(); +} catch (e) { + console.error("ensureFilesTable FAIL:", e.message); +} + app.listen(config.port, "0.0.0.0", () => { console.log(`sample listening on :${config.port}`); });