feat: 파일 갤러리 CRUD PoC (DB + MinIO 동작 검증)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"db:check": "node scripts/check-db.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": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
297
public/index.html
Normal file
297
public/index.html
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>파일 갤러리 — DB + MinIO PoC</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--ink: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--line: #e2e8f0;
|
||||||
|
--brand: #6366f1;
|
||||||
|
--brand-600: #4f46e5;
|
||||||
|
--ok: #10b981;
|
||||||
|
--err: #ef4444;
|
||||||
|
--radius: 14px;
|
||||||
|
--shadow: 0 10px 30px -12px rgba(2, 6, 23, 0.25);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 100% -10%, #312e81 0%, transparent 55%),
|
||||||
|
radial-gradient(1000px 500px at -10% 110%, #0e7490 0%, transparent 50%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
.wrap { max-width: 760px; margin: 0 auto; padding: 3rem 1.25rem 4rem; }
|
||||||
|
|
||||||
|
header { color: #e2e8f0; margin-bottom: 1.75rem; }
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: .4rem;
|
||||||
|
font-size: .72rem; font-weight: 600; letter-spacing: .02em;
|
||||||
|
color: #c7d2fe; background: rgba(99,102,241,.18);
|
||||||
|
border: 1px solid rgba(99,102,241,.35);
|
||||||
|
padding: .3rem .6rem; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.badge::before {
|
||||||
|
content: ""; width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: var(--ok); box-shadow: 0 0 0 3px rgba(16,185,129,.25);
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.6rem; margin: .8rem 0 .3rem; letter-spacing: -.01em; }
|
||||||
|
header p { margin: 0; color: #94a3b8; font-size: .92rem; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 드롭존 */
|
||||||
|
.drop {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: .75rem;
|
||||||
|
padding: 2rem 1rem; text-align: center;
|
||||||
|
border: 2px dashed var(--line); border-radius: var(--radius);
|
||||||
|
background: #f8fafc; cursor: pointer; transition: .15s ease;
|
||||||
|
}
|
||||||
|
.drop:hover, .drop.over { border-color: var(--brand); background: #eef2ff; }
|
||||||
|
.drop svg { width: 40px; height: 40px; color: var(--brand); }
|
||||||
|
.drop strong { color: var(--brand-600); }
|
||||||
|
.drop small { color: var(--muted); }
|
||||||
|
#file { display: none; }
|
||||||
|
|
||||||
|
.actionbar {
|
||||||
|
display: flex; align-items: center; gap: .75rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.filename {
|
||||||
|
flex: 1; min-width: 0; font-size: .9rem; color: var(--muted);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit; font-weight: 600; cursor: pointer;
|
||||||
|
color: #fff; background: var(--brand-600); border: 0;
|
||||||
|
padding: .6rem 1.1rem; border-radius: 10px; transition: .15s ease;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--brand); transform: translateY(-1px); }
|
||||||
|
button:disabled { opacity: .5; cursor: default; transform: none; }
|
||||||
|
|
||||||
|
#status { margin-top: .85rem; font-size: .88rem; min-height: 1.2em; }
|
||||||
|
#status.ok { color: var(--ok); }
|
||||||
|
#status.err { color: var(--err); }
|
||||||
|
#status.busy { color: var(--muted); }
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
margin: 2rem .25rem .75rem; color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.list-head h2 { font-size: 1rem; margin: 0; color: #e2e8f0; }
|
||||||
|
.count { font-size: .8rem; color: #94a3b8; }
|
||||||
|
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
|
||||||
|
li {
|
||||||
|
display: flex; align-items: center; gap: .85rem;
|
||||||
|
background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 12px; padding: .8rem .95rem;
|
||||||
|
transition: .15s ease;
|
||||||
|
}
|
||||||
|
li:hover { border-color: #c7d2fe; box-shadow: var(--shadow); }
|
||||||
|
.ico {
|
||||||
|
flex: none; width: 38px; height: 38px; border-radius: 9px;
|
||||||
|
display: grid; place-items: center; font-weight: 700; font-size: .7rem;
|
||||||
|
color: var(--brand-600); background: #eef2ff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.meta { flex: 1; min-width: 0; }
|
||||||
|
.meta a {
|
||||||
|
font-weight: 600; color: var(--ink); text-decoration: none;
|
||||||
|
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.meta a:hover { color: var(--brand-600); text-decoration: underline; }
|
||||||
|
.meta small { color: var(--muted); font-size: .78rem; }
|
||||||
|
.actions { flex: none; display: flex; gap: .4rem; }
|
||||||
|
.btn-sm {
|
||||||
|
color: var(--muted); background: #fff; text-decoration: none;
|
||||||
|
font: inherit; font-size: .82rem; font-weight: 600; cursor: pointer;
|
||||||
|
padding: .35rem .6rem; border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
transition: .15s ease;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { color: var(--brand-600); border-color: #c7d2fe; transform: none; }
|
||||||
|
.btn-sm.danger:hover { color: var(--err); border-color: #fecaca; background: #fef2f2; }
|
||||||
|
.empty { color: #94a3b8; font-size: .9rem; text-align: center; padding: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<span class="badge">DB + MinIO 연결됨</span>
|
||||||
|
<h1>파일 갤러리</h1>
|
||||||
|
<p>업로드하면 MinIO(S3)에 저장되고 메타데이터는 Postgres에 기록됩니다.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<label class="drop" id="drop" for="file">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<div><strong>클릭</strong>하거나 파일을 여기로 끌어다 놓으세요</div>
|
||||||
|
<small>최대 25MB</small>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="file" />
|
||||||
|
<div class="actionbar">
|
||||||
|
<span class="filename" id="filename">선택된 파일 없음</span>
|
||||||
|
<button id="upload" disabled>업로드</button>
|
||||||
|
</div>
|
||||||
|
<div id="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-head">
|
||||||
|
<h2>파일 목록</h2>
|
||||||
|
<span class="count" id="count"></span>
|
||||||
|
</div>
|
||||||
|
<ul id="list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const fileInput = $("#file");
|
||||||
|
const drop = $("#drop");
|
||||||
|
const statusEl = $("#status");
|
||||||
|
const uploadBtn = $("#upload");
|
||||||
|
|
||||||
|
function setStatus(msg, kind) {
|
||||||
|
statusEl.textContent = msg || "";
|
||||||
|
statusEl.className = kind || "";
|
||||||
|
}
|
||||||
|
function fmtSize(n) {
|
||||||
|
n = Number(n);
|
||||||
|
if (n < 1024) return n + " B";
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||||
|
return (n / 1024 / 1024).toFixed(1) + " MB";
|
||||||
|
}
|
||||||
|
function ext(name) {
|
||||||
|
const m = /\.([a-z0-9]+)$/i.exec(name || "");
|
||||||
|
return m ? m[1].slice(0, 4) : "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(file) {
|
||||||
|
fileInput._picked = file || null;
|
||||||
|
$("#filename").textContent = file ? file.name : "선택된 파일 없음";
|
||||||
|
uploadBtn.disabled = !file;
|
||||||
|
setStatus("");
|
||||||
|
}
|
||||||
|
fileInput.onchange = () => pick(fileInput.files[0]);
|
||||||
|
|
||||||
|
["dragenter", "dragover"].forEach((e) =>
|
||||||
|
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add("over"); })
|
||||||
|
);
|
||||||
|
["dragleave", "drop"].forEach((e) =>
|
||||||
|
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove("over"); })
|
||||||
|
);
|
||||||
|
drop.addEventListener("drop", (ev) => pick(ev.dataTransfer.files[0]));
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/files");
|
||||||
|
const { files } = await r.json();
|
||||||
|
$("#count").textContent = (files || []).length + "개";
|
||||||
|
if (!files || !files.length) {
|
||||||
|
$("#list").innerHTML = '<div class="empty">아직 업로드된 파일이 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("#list").innerHTML = files
|
||||||
|
.map(
|
||||||
|
(f) => `
|
||||||
|
<li data-id="${f.id}">
|
||||||
|
<div class="ico">${ext(f.name)}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<a href="/files/${f.id}" target="_blank">${f.name}</a>
|
||||||
|
<small>${fmtSize(f.size)} · ${new Date(f.created_at).toLocaleString("ko-KR")}</small>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn-sm" href="/files/${f.id}" target="_blank">열기</a>
|
||||||
|
<button class="btn-sm" data-act="rename">이름변경</button>
|
||||||
|
<button class="btn-sm danger" data-act="delete">삭제</button>
|
||||||
|
</div>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} catch (e) {
|
||||||
|
$("#list").innerHTML = '<div class="empty">목록을 불러오지 못했습니다.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록의 이름변경/삭제 (이벤트 위임)
|
||||||
|
$("#list").addEventListener("click", async (ev) => {
|
||||||
|
const btn = ev.target.closest("button[data-act]");
|
||||||
|
if (!btn) return;
|
||||||
|
const li = btn.closest("li");
|
||||||
|
const id = li.dataset.id;
|
||||||
|
const cur = li.querySelector(".meta a").textContent;
|
||||||
|
|
||||||
|
if (btn.dataset.act === "delete") {
|
||||||
|
if (!confirm(`"${cur}" 을(를) 삭제할까요? (MinIO 객체도 함께 삭제됩니다)`)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/files/" + id, { method: "DELETE" });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || r.status);
|
||||||
|
setStatus("삭제됨 — #" + id, "ok");
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("삭제 실패: " + e.message, "err");
|
||||||
|
}
|
||||||
|
} else if (btn.dataset.act === "rename") {
|
||||||
|
const name = prompt("새 이름", cur);
|
||||||
|
if (!name || name.trim() === cur) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/files/" + id, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: name.trim() }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || r.status);
|
||||||
|
setStatus("이름 변경됨 — #" + id, "ok");
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("이름 변경 실패: " + e.message, "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadBtn.onclick = async () => {
|
||||||
|
const file = fileInput._picked;
|
||||||
|
if (!file) return;
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
setStatus("업로드 중…", "busy");
|
||||||
|
try {
|
||||||
|
const r = await fetch("/files?name=" + encodeURIComponent(file.name), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || r.status);
|
||||||
|
setStatus("완료 — #" + j.id + " 저장됨", "ok");
|
||||||
|
fileInput.value = "";
|
||||||
|
pick(null);
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("실패: " + e.message, "err");
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
scripts/check-files.js
Normal file
49
scripts/check-files.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
92
src/files.js
Normal file
92
src/files.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
118
src/server.js
118
src/server.js
@@ -1,11 +1,23 @@
|
|||||||
// 최소 Express 서버 — /healthz, /db, /s3 로 연결 확인.
|
// 최소 Express 서버 — /healthz, /db, /s3 연결 확인 + /files 갤러리(DB+S3 실사용 검증).
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { pingDb } from "./db.js";
|
import { pingDb } from "./db.js";
|
||||||
import { pingS3 } from "./s3.js";
|
import { pingS3 } from "./s3.js";
|
||||||
|
import {
|
||||||
|
ensureFilesTable,
|
||||||
|
saveFile,
|
||||||
|
listFiles,
|
||||||
|
getFile,
|
||||||
|
renameFile,
|
||||||
|
deleteFile,
|
||||||
|
getObjectStream,
|
||||||
|
} from "./files.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// 최소 업로드 UI(public/index.html).
|
||||||
|
app.use(express.static("public"));
|
||||||
|
|
||||||
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
app.get("/db", async (_req, res) => {
|
app.get("/db", async (_req, res) => {
|
||||||
@@ -24,10 +36,110 @@ app.get("/s3", async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/", (_req, res) =>
|
// 업로드: 원시 바이트 본문(멀티파트 파서 불필요). 파일명은 ?name= 또는 X-Filename 헤더.
|
||||||
res.json({ name: "sample", endpoints: ["/healthz", "/db", "/s3"] })
|
// 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", () => {
|
app.listen(config.port, "0.0.0.0", () => {
|
||||||
console.log(`sample listening on :${config.port}`);
|
console.log(`sample listening on :${config.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user