|
|
|
@@ -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("<", "<")
|
|
|
|
|
.replaceAll(">", ">")
|
|
|
|
|
.replaceAll('"', """)
|
|
|
|
|
.replaceAll("'", "'");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
});
|