first commit

This commit is contained in:
shkim
2026-05-10 20:08:22 +09:00
commit 94ff09dc98
8 changed files with 415 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
import crypto from "crypto";
import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import express from "express";
import cookieParser from "cookie-parser";
import multer from "multer";
const app = express();
const port = Number(process.env.PORT || 8080);
const dataDir = "/data";
const uploadsDir = "/uploads";
const dbPath = path.join(dataDir, "db.json");
const adminPassword = process.env.ADMIN_PASSWORD;
const sessionSecret = process.env.SESSION_SECRET;
const publicBaseUrl = (process.env.PUBLIC_BASE_URL || "").replace(/\/$/, "");
if (!adminPassword || !sessionSecret) {
throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required.");
}
app.set("trust proxy", true);
app.use(express.urlencoded({ extended: false, limit: `${Number(process.env.MAX_FIELDS_SIZE_MB || 2)}mb` }));
app.use(cookieParser(sessionSecret));
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "avif"];
const videoExts = ["mp4", "mov", "m4v", "avi", "mkv", "webm"];
const defaultExts = [...imageExts, ...videoExts];
function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function sanitizeName(value, fallback = "request") {
const safe = String(value || "")
.normalize("NFKC")
.replace(/[\\/:*?"<>|]/g, "_")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80);
return safe || fallback;
}
function sanitizeFileName(value) {
const parsed = path.parse(sanitizeName(value, "upload"));
const base = sanitizeName(parsed.name, "upload").replace(/^\.+$/, "upload");
const ext = parsed.ext.replace(/[^\w.]/g, "").slice(0, 16).toLowerCase();
return `${base}${ext}`;
}
function normalizeExts(value) {
return String(value || "")
.split(/[\s,;]+/)
.map((item) => item.trim().replace(/^\./, "").toLowerCase())
.filter(Boolean)
.filter((item, index, list) => /^[a-z0-9]+$/.test(item) && list.indexOf(item) === index);
}
async function ensureDb() {
await fsp.mkdir(dataDir, { recursive: true });
await fsp.mkdir(uploadsDir, { recursive: true });
if (!fs.existsSync(dbPath)) {
await writeDb({ requests: [] });
}
}
async function readDb() {
await ensureDb();
return JSON.parse(await fsp.readFile(dbPath, "utf8"));
}
async function writeDb(db) {
const tmp = `${dbPath}.${process.pid}.tmp`;
await fsp.writeFile(tmp, JSON.stringify(db, null, 2));
await fsp.rename(tmp, dbPath);
}
function sign(value) {
return crypto.createHmac("sha256", sessionSecret).update(value).digest("hex");
}
function makeSession() {
const payload = JSON.stringify({ admin: true, exp: Date.now() + 1000 * 60 * 60 * 12 });
const encoded = Buffer.from(payload).toString("base64url");
return `${encoded}.${sign(encoded)}`;
}
function isAuthed(req) {
const token = req.cookies.session || "";
const [encoded, mac] = token.split(".");
if (!encoded || !mac || sign(encoded) !== mac) return false;
try {
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
return payload.admin === true && payload.exp > Date.now();
} catch {
return false;
}
}
function requireAdmin(req, res, next) {
if (isAuthed(req)) return next();
res.redirect("/admin/login");
}
function page(title, body) {
return `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
:root { color-scheme: light; --ink:#17202a; --muted:#667085; --line:#d6dde7; --bg:#f6f8fb; --panel:#fff; --accent:#0f766e; --danger:#b42318; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--ink); }
main { width:min(960px, calc(100% - 32px)); margin:32px auto; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; }
h1 { font-size:24px; margin:0 0 18px; }
h2 { font-size:18px; margin:26px 0 12px; }
label { display:block; font-size:14px; color:var(--muted); margin:14px 0 6px; }
input, textarea, select { width:100%; border:1px solid var(--line); border-radius:6px; padding:10px 12px; font:inherit; background:#fff; }
input[type=file] { padding:9px; }
button, .button { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:6px; padding:10px 14px; background:var(--accent); color:#fff; font-weight:650; cursor:pointer; text-decoration:none; }
button.danger { background:var(--danger); }
.row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
.muted { color:var(--muted); }
.table { width:100%; border-collapse:collapse; margin-top:12px; }
.table th, .table td { text-align:left; border-top:1px solid var(--line); padding:10px; vertical-align:top; }
.table th { color:var(--muted); font-size:13px; }
.notice { border:1px solid #bae6fd; background:#ecfeff; padding:12px; border-radius:6px; margin:12px 0; }
.error { border-color:#fecaca; background:#fff1f2; }
.upload-box { border:2px dashed var(--line); border-radius:8px; background:#fff; padding:18px; }
@media (max-width:720px) { main { width:calc(100% - 20px); margin:16px auto; } .row { grid-template-columns:1fr; } .table { font-size:14px; } }
</style>
</head>
<body><main>${body}</main></body>
</html>`;
}
app.get("/", (req, res) => res.redirect("/admin"));
app.get("/admin/login", (req, res) => {
res.send(page("관리자 로그인", `
<section class="panel">
<h1>사진 요청 관리자</h1>
<form method="post" action="/admin/login">
<label>비밀번호</label>
<input type="password" name="password" autocomplete="current-password" autofocus>
<div class="actions"><button type="submit">로그인</button></div>
</form>
</section>
`));
});
app.post("/admin/login", (req, res) => {
const supplied = Buffer.from(req.body.password || "");
const expected = Buffer.from(adminPassword);
const ok = supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
if (!ok) {
res.status(401).send(page("로그인 실패", `<section class="panel"><h1>로그인 실패</h1><p class="muted">비밀번호가 맞지 않아.</p><a class="button" href="/admin/login">다시 시도</a></section>`));
return;
}
res.cookie("session", makeSession(), { httpOnly: true, sameSite: "lax", secure: req.secure, maxAge: 1000 * 60 * 60 * 12 });
res.redirect("/admin");
});
app.post("/admin/logout", (req, res) => {
res.clearCookie("session");
res.redirect("/admin/login");
});
app.get("/admin", requireAdmin, async (req, res) => {
const db = await readDb();
const rows = db.requests
.filter((item) => !item.deletedAt)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((item) => {
const url = `${publicBaseUrl}/r/${item.slug}`;
return `<tr>
<td><strong>${escapeHtml(item.title)}</strong><br><span class="muted">${escapeHtml(item.folderName)}</span></td>
<td><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></td>
<td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td>
<td>${escapeHtml(item.allowedExts.join(", "))}</td>
<td>
<form method="post" action="/admin/requests/${escapeHtml(item.slug)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
<button class="danger" type="submit">삭제</button>
</form>
</td>
</tr>`;
}).join("");
res.send(page("사진 요청 관리자", `
<section class="panel">
<form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form>
<h1>사진 요청 관리자</h1>
<form method="post" action="/admin/requests">
<div class="row">
<div>
<label>요청 이름</label>
<input name="title" placeholder="예: 2026 가족 모임" required>
</div>
<div>
<label>파일당 최대 용량(MB)</label>
<input name="maxMb" type="number" min="1" max="20480" value="500" required>
</div>
</div>
<label>허용 확장자</label>
<input name="allowedExts" value="${escapeHtml(defaultExts.join(", "))}" required>
<div class="actions"><button type="submit">링크 만들기</button></div>
</form>
<h2>활성 링크</h2>
<table class="table">
<thead><tr><th>요청</th><th>링크</th><th>용량</th><th>확장자</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="5" class="muted">아직 만든 링크가 없어.</td></tr>`}</tbody>
</table>
</section>
`));
});
app.post("/admin/requests", requireAdmin, async (req, res) => {
const db = await readDb();
const title = sanitizeName(req.body.title, "사진 요청");
const allowedExts = normalizeExts(req.body.allowedExts);
const maxMb = Math.max(1, Math.min(20480, Number(req.body.maxMb || 500)));
if (allowedExts.length === 0) {
res.status(400).send(page("입력 오류", `<section class="panel"><h1>입력 오류</h1><p class="muted">허용 확장자를 하나 이상 넣어야 해.</p><a class="button" href="/admin">돌아가기</a></section>`));
return;
}
const slug = crypto.randomBytes(16).toString("base64url");
const folderName = `${new Date().toISOString().slice(0, 10)}_${title}_${slug.slice(0, 6)}`;
await fsp.mkdir(path.join(uploadsDir, folderName), { recursive: true });
db.requests.push({
slug,
title,
folderName,
maxBytes: maxMb * 1024 * 1024,
allowedExts,
createdAt: new Date().toISOString()
});
await writeDb(db);
res.redirect("/admin");
});
app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
const db = await readDb();
const item = db.requests.find((request) => request.slug === req.params.slug && !request.deletedAt);
if (item) item.deletedAt = new Date().toISOString();
await writeDb(db);
res.redirect("/admin");
});
async function findRequest(slug) {
const db = await readDb();
return db.requests.find((request) => request.slug === slug && !request.deletedAt);
}
function uploadMiddleware(request) {
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
try {
const target = path.join(uploadsDir, request.folderName);
await fsp.mkdir(target, { recursive: true });
cb(null, target);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
const safe = sanitizeFileName(Buffer.from(file.originalname, "latin1").toString("utf8"));
cb(null, `${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`);
}
});
return multer({
storage,
limits: { fileSize: request.maxBytes, files: 100 },
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).replace(".", "").toLowerCase();
cb(null, request.allowedExts.includes(ext));
}
}).array("files", 100);
}
app.get("/r/:slug", async (req, res) => {
const request = await findRequest(req.params.slug);
if (!request) {
res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
return;
}
const accept = request.allowedExts.map((ext) => `.${ext}`).join(",");
res.send(page(request.title, `
<section class="panel">
<h1>${escapeHtml(request.title)}</h1>
<p class="muted">파일당 최대 ${Math.round(request.maxBytes / 1024 / 1024)} MB까지 업로드할 수 있어.</p>
<form method="post" action="/r/${escapeHtml(request.slug)}" enctype="multipart/form-data">
<div class="upload-box">
<label>사진/영상 선택</label>
<input type="file" name="files" accept="${escapeHtml(accept)}" multiple required>
<p class="muted">허용 형식: ${escapeHtml(request.allowedExts.join(", "))}</p>
</div>
<div class="actions"><button type="submit">업로드</button></div>
</form>
</section>
`));
});
app.post("/r/:slug", async (req, res) => {
const request = await findRequest(req.params.slug);
if (!request) {
res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
return;
}
const upload = uploadMiddleware(request);
upload(req, res, (error) => {
if (error) {
const message = error.code === "LIMIT_FILE_SIZE"
? "파일 용량이 설정된 제한보다 커."
: "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘.";
res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">${escapeHtml(message)}</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
return;
}
res.send(page("업로드 완료", `<section class="panel"><h1>업로드 완료</h1><div class="notice">${escapeHtml((req.files || []).length)}개 파일을 받았어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">더 올리기</a></section>`));
});
});
app.listen(port, () => {
console.log(`photo request service listening on ${port}`);
});