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 ` ${escapeHtml(title)}
${body}
`; } app.get("/", (req, res) => res.redirect("/admin")); app.get("/admin/login", (req, res) => { res.send(page("관리자 로그인", `

사진 요청 관리자

`)); }); 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("로그인 실패", `

로그인 실패

비밀번호가 맞지 않아.

다시 시도
`)); 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 ` ${escapeHtml(item.title)}
${escapeHtml(item.folderName)} ${escapeHtml(url)} ${Math.round(item.maxBytes / 1024 / 1024)} MB ${escapeHtml(item.allowedExts.join(", "))}
`; }).join(""); res.send(page("사진 요청 관리자", `

사진 요청 관리자

활성 링크

${rows || ``}
요청링크용량확장자
아직 만든 링크가 없어.
`)); }); 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("입력 오류", `

입력 오류

허용 확장자를 하나 이상 넣어야 해.

돌아가기
`)); 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("링크 없음", `

링크가 없거나 삭제됐어

`)); return; } const accept = request.allowedExts.map((ext) => `.${ext}`).join(","); res.send(page(request.title, `

${escapeHtml(request.title)}

파일당 최대 ${Math.round(request.maxBytes / 1024 / 1024)} MB까지 업로드할 수 있어.

허용 형식: ${escapeHtml(request.allowedExts.join(", "))}

`)); }); app.post("/r/:slug", async (req, res) => { const request = await findRequest(req.params.slug); if (!request) { res.status(404).send(page("링크 없음", `

링크가 없거나 삭제됐어

`)); return; } const upload = uploadMiddleware(request); upload(req, res, (error) => { if (error) { const message = error.code === "LIMIT_FILE_SIZE" ? "파일 용량이 설정된 제한보다 커." : "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘."; res.status(400).send(page("업로드 실패", `

업로드 실패

${escapeHtml(message)}
다시 올리기
`)); return; } res.send(page("업로드 완료", `

업로드 완료

${escapeHtml((req.files || []).length)}개 파일을 받았어.
더 올리기
`)); }); }); app.listen(port, () => { console.log(`photo request service listening on ${port}`); });