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
+
+
+
+
+
+
+
+
+
+
+ 선택된 파일 없음
+
+
+
+
+
+
+
파일 목록
+
+
+
+
+
+
+
+
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}`);
});