diff --git a/src/server.js b/src/server.js index bc88b33..ae1f7f0 100644 --- a/src/server.js +++ b/src/server.js @@ -15,18 +15,37 @@ const publicDir = path.resolve("src", "public"); const adminPassword = process.env.ADMIN_PASSWORD; const sessionSecret = process.env.SESSION_SECRET; const publicBaseUrl = (process.env.PUBLIC_BASE_URL || "").replace(/\/$/, ""); -const adminAllowedIps = parseIpList(process.env.ADMIN_ALLOWED_IPS || "112.159.64.27,192.168.219.100,192.168.219.1"); +const adminAllowedIps = parseIpList( + process.env.ADMIN_ALLOWED_IPS || "112.159.64.27,192.168.219.100,192.168.219.1" +); 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( + express.urlencoded({ + extended: false, + limit: `${Number(process.env.MAX_FIELDS_SIZE_MB || 2)}mb`, + }) +); app.use(cookieParser(sessionSecret)); app.use("/assets", express.static(publicDir, { fallthrough: true })); -const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "avif"]; +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]; @@ -52,7 +71,10 @@ function sanitizeName(value, fallback = "request") { 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(); + const ext = parsed.ext + .replace(/[^\w.]/g, "") + .slice(0, 16) + .toLowerCase(); return `${base}${ext}`; } @@ -75,7 +97,10 @@ function normalizeExts(value) { .split(/[\s,;]+/) .map((item) => item.trim().replace(/^\./, "").toLowerCase()) .filter(Boolean) - .filter((item, index, list) => /^[a-z0-9]+$/.test(item) && list.indexOf(item) === index); + .filter( + (item, index, list) => + /^[a-z0-9]+$/.test(item) && list.indexOf(item) === index + ); } async function ensureDb() { @@ -102,7 +127,10 @@ function sign(value) { } function makeSession() { - const payload = JSON.stringify({ admin: true, exp: Date.now() + 1000 * 60 * 60 * 12 }); + const payload = JSON.stringify({ + admin: true, + exp: Date.now() + 1000 * 60 * 60 * 12, + }); const encoded = Buffer.from(payload).toString("base64url"); return `${encoded}.${sign(encoded)}`; } @@ -112,7 +140,9 @@ function isAuthed(req) { const [encoded, mac] = token.split("."); if (!encoded || !mac || sign(encoded) !== mac) return false; try { - const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + const payload = JSON.parse( + Buffer.from(encoded, "base64url").toString("utf8") + ); return payload.admin === true && payload.exp > Date.now(); } catch { return false; @@ -169,7 +199,10 @@ function page(title, body) { } function renderTemplate(template, values) { - return template.replaceAll(/\{\{(\w+)\}\}/g, (match, key) => values[key] ?? match); + return template.replaceAll( + /\{\{(\w+)\}\}/g, + (match, key) => values[key] ?? match + ); } function wantsJson(req) { @@ -196,21 +229,33 @@ function clientIp(req) { .split(",") .map(normalizeIp) .filter(Boolean); - const closestPublicIp = forwarded.toReversed().find((ip) => !isPrivateIp(ip)) || ""; - return normalizeIp(closestPublicIp || forwarded[0] || req.get("x-real-ip") || req.ip || req.socket.remoteAddress || ""); + const closestPublicIp = + forwarded.toReversed().find((ip) => !isPrivateIp(ip)) || ""; + return normalizeIp( + closestPublicIp || + forwarded[0] || + req.get("x-real-ip") || + req.ip || + req.socket.remoteAddress || + "" + ); } function isPrivateIp(ip) { if (ip === "localhost" || ip === "::1") return true; - if (ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd")) return true; + if (ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd")) + return true; const parts = ip.split(".").map((part) => Number(part)); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) + return false; const [a, b] = parts; - return a === 10 - || a === 127 - || (a === 172 && b >= 16 && b <= 31) - || (a === 192 && b === 168) - || (a === 169 && b === 254); + return ( + a === 10 || + a === 127 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ); } function requireAdminIp(req, res, next) { @@ -219,8 +264,12 @@ function requireAdminIp(req, res, next) { next(); return; } - console.warn(`blocked admin path from ${ip || "unknown"}: ${req.method} ${req.originalUrl}`); - res.status(404).send(page("페이지 없음", `

페이지를 찾을 수 없어

`)); + console.warn( + `blocked admin path from ${ip || "unknown"}: ${req.method} ${ + req.originalUrl + }` + ); + res.status(404).send(page("잘못된 접근", "")); } function uploadResultPage(request, { heading, message, isError = false }) { @@ -240,10 +289,14 @@ function uploadResultPage(request, { heading, message, isError = false }) {
-

${isError ? "PLEASE TRY AGAIN" : "THANK YOU"}

+

${ + isError ? "PLEASE TRY AGAIN" : "THANK YOU" + }

${escapeHtml(heading)}

${escapeHtml(message)}

- ${isError ? "다시 올리기" : "사진 더 올리기"} + ${ + isError ? "다시 올리기" : "사진 더 올리기" + }
@@ -261,10 +314,12 @@ async function uploadPage(request) { accept: escapeHtml(accept), allowedExts: escapeHtml(request.allowedExts.join(", ")), maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)), - cssPath: "/assets/upload.css" + cssPath: "/assets/upload.css", }; if (template) return renderTemplate(template, values); - return page(request.title, ` + return page( + request.title, + `

${values.title}

@@ -277,15 +332,18 @@ async function uploadPage(request) {
- `); + ` + ); } async function directorySize(dir) { let total = 0; - const entries = await fsp.readdir(dir, { withFileTypes: true }).catch((error) => { - if (error.code === "ENOENT") return []; - throw error; - }); + const entries = await fsp + .readdir(dir, { withFileTypes: true }) + .catch((error) => { + if (error.code === "ENOENT") return []; + throw error; + }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { @@ -310,7 +368,10 @@ async function moveUploadedFiles(files, targetDir) { } } -async function appendUploadMetadata(targetDir, { guestPhone, folderName, files }) { +async function appendUploadMetadata( + targetDir, + { guestPhone, folderName, files } +) { const entry = { uploadedAt: new Date().toISOString(), guestPhone, @@ -318,19 +379,25 @@ async function appendUploadMetadata(targetDir, { guestPhone, folderName, files } files: files.map((file) => ({ storedName: file.filename, originalName: Buffer.from(file.originalname, "latin1").toString("utf8"), - size: file.size - })) + size: file.size, + })), }; - await fsp.appendFile(path.join(targetDir, "upload-metadata.jsonl"), `${JSON.stringify(entry)}\n`, "utf8"); + await fsp.appendFile( + path.join(targetDir, "upload-metadata.jsonl"), + `${JSON.stringify(entry)}\n`, + "utf8" + ); } async function readUploadMetadata(folderName) { const requestDir = path.join(uploadsDir, folderName); const paths = [path.join(requestDir, "upload-metadata.jsonl")]; - const entries = await fsp.readdir(requestDir, { withFileTypes: true }).catch((error) => { - if (error.code === "ENOENT") return []; - throw error; - }); + const entries = await fsp + .readdir(requestDir, { withFileTypes: true }) + .catch((error) => { + if (error.code === "ENOENT") return []; + throw error; + }); for (const entry of entries) { if (entry.isDirectory()) { paths.push(path.join(requestDir, entry.name, "upload-metadata.jsonl")); @@ -351,7 +418,9 @@ async function readUploadMetadata(folderName) { } } } - return submissions.sort((a, b) => String(a.uploadedAt || "").localeCompare(String(b.uploadedAt || ""))); + return submissions.sort((a, b) => + String(a.uploadedAt || "").localeCompare(String(b.uploadedAt || "")) + ); } function formatDateTime(value) { @@ -360,18 +429,28 @@ function formatDateTime(value) { return new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", dateStyle: "medium", - timeStyle: "short" + timeStyle: "short", }).format(date); } app.get("/", (req, res) => { - res.status(404).send(page("페이지 없음", `

페이지를 찾을 수 없어

`)); + res + .status(404) + .send( + page( + "페이지 없음", + `

페이지를 찾을 수 없어

` + ) + ); }); app.use("/admin", requireAdminIp); app.get("/admin/login", (req, res) => { - res.send(page("관리자 로그인", ` + res.send( + page( + "관리자 로그인", + `

사진 요청 관리자

@@ -380,18 +459,34 @@ app.get("/admin/login", (req, res) => {
- `)); + ` + ) + ); }); 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); + const ok = + supplied.length === expected.length && + crypto.timingSafeEqual(supplied, expected); if (!ok) { - res.status(401).send(page("로그인 실패", `

로그인 실패

비밀번호가 맞지 않아.

다시 시도
`)); + res + .status(401) + .send( + page( + "로그인 실패", + `

로그인 실패

비밀번호가 맞지 않아.

다시 시도
` + ) + ); return; } - res.cookie("session", makeSession(), { httpOnly: true, sameSite: "lax", secure: req.secure, maxAge: 1000 * 60 * 60 * 12 }); + res.cookie("session", makeSession(), { + httpOnly: true, + sameSite: "lax", + secure: req.secure, + maxAge: 1000 * 60 * 60 * 12, + }); res.redirect("/admin"); }); @@ -408,22 +503,34 @@ app.get("/admin", requireAdmin, async (req, res) => { .map((item) => { const url = `${publicBaseUrl}/r/${item.slug}`; return ` - ${escapeHtml(item.title)}
${escapeHtml(item.folderName)} + ${escapeHtml( + item.title + )}
${escapeHtml( + item.folderName + )} ${escapeHtml(url)} ${Math.round(item.maxBytes / 1024 / 1024)} MB ${escapeHtml(item.allowedExts.join(", "))}
- 제출 내역 -
+ 제출 내역 +
`; - }).join(""); + }) + .join(""); - res.send(page("사진 요청 관리자", ` + res.send( + page( + "사진 요청 관리자", + `

사진 요청 관리자

@@ -439,23 +546,37 @@ app.get("/admin", requireAdmin, async (req, res) => { - +

활성 링크

- ${rows || ``} + ${ + rows || + `` + }
요청링크총 용량 제한확장자
아직 만든 링크가 없어.
아직 만든 링크가 없어.
- `)); + ` + ) + ); }); app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => { const db = await readDb(); const item = db.requests.find((request) => request.slug === req.params.slug); if (!item) { - res.status(404).send(page("제출 내역 없음", `

제출 내역 없음

해당 링크를 찾을 수 없어.

돌아가기
`)); + res + .status(404) + .send( + page( + "제출 내역 없음", + `

제출 내역 없음

해당 링크를 찾을 수 없어.

돌아가기
` + ) + ); return; } @@ -476,25 +597,39 @@ app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
연락처
${escapeHtml(submission.guestPhone || "-")}
저장 폴더
${escapeHtml(submission.folderName || "-")}
-
제출 시간
${escapeHtml(formatDateTime(submission.uploadedAt))}
+
제출 시간
${escapeHtml( + formatDateTime(submission.uploadedAt) + )}
파일 수
${files.length}개
- + `; }) .join(""); - res.send(page(`${item.title} 제출 내역`, ` + res.send( + page( + `${item.title} 제출 내역`, + `
관리자 홈

${escapeHtml(item.title)} 제출 내역

-

저장 위치: ${escapeHtml(path.join(uploadsDir, item.folderName))}

+

저장 위치: ${escapeHtml( + path.join(uploadsDir, item.folderName) + )}

제출 정보 파일: 각 연락처/익명 폴더의 upload-metadata.jsonl

- ${rows || `
아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.
`} + ${ + rows || + `
아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.
` + }
- `)); + ` + ) + ); }); app.post("/admin/requests", requireAdmin, async (req, res) => { @@ -503,11 +638,20 @@ app.post("/admin/requests", requireAdmin, async (req, res) => { 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("입력 오류", `

입력 오류

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

돌아가기
`)); + 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)}`; + 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, @@ -515,7 +659,7 @@ app.post("/admin/requests", requireAdmin, async (req, res) => { folderName, maxBytes: maxMb * 1024 * 1024, allowedExts, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), }); await writeDb(db); res.redirect("/admin"); @@ -523,7 +667,9 @@ app.post("/admin/requests", requireAdmin, async (req, res) => { 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); + 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"); @@ -531,7 +677,9 @@ app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => { async function findRequest(slug) { const db = await readDb(); - return db.requests.find((request) => request.slug === slug && !request.deletedAt); + return db.requests.find( + (request) => request.slug === slug && !request.deletedAt + ); } function uploadMiddleware(request) { @@ -546,25 +694,40 @@ function uploadMiddleware(request) { } }, filename: (req, file, cb) => { - const safe = sanitizeFileName(Buffer.from(file.originalname, "latin1").toString("utf8")); - cb(null, `${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`); - } + const safe = sanitizeFileName( + Buffer.from(file.originalname, "latin1").toString("utf8") + ); + cb( + null, + `${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}` + ); + }, }); return multer({ storage, limits: { files: 100 }, fileFilter: (req, file, cb) => { - const ext = path.extname(file.originalname).replace(".", "").toLowerCase(); + 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("링크 없음", `

링크가 없거나 삭제됐어

`)); + res + .status(404) + .send( + page( + "링크 없음", + `

링크가 없거나 삭제됐어

` + ) + ); return; } res.send(await uploadPage(request)); @@ -573,7 +736,14 @@ app.get("/r/:slug", async (req, res) => { app.post("/r/:slug", async (req, res) => { const request = await findRequest(req.params.slug); if (!request) { - res.status(404).send(page("링크 없음", `

링크가 없거나 삭제됐어

`)); + res + .status(404) + .send( + page( + "링크 없음", + `

링크가 없거나 삭제됐어

` + ) + ); return; } const upload = uploadMiddleware(request); @@ -585,7 +755,15 @@ app.post("/r/:slug", async (req, res) => { res.status(400).json({ ok: false, message }); return; } - res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message, isError: true })); + res + .status(400) + .send( + uploadResultPage(request, { + heading: "업로드 실패", + message, + isError: true, + }) + ); return; } try { @@ -598,12 +776,28 @@ app.post("/r/:slug", async (req, res) => { const incomingBytes = files.reduce((total, file) => total + file.size, 0); if (existingBytes + incomingBytes > request.maxBytes) { await removeFiles(files); - const remainMb = Math.max(0, Math.floor((request.maxBytes - existingBytes) / 1024 / 1024)); + const remainMb = Math.max( + 0, + Math.floor((request.maxBytes - existingBytes) / 1024 / 1024) + ); if (wantsJson(req)) { - res.status(400).json({ ok: false, message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.` }); + res + .status(400) + .json({ + ok: false, + message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`, + }); return; } - res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`, isError: true })); + res + .status(400) + .send( + uploadResultPage(request, { + heading: "업로드 실패", + message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`, + isError: true, + }) + ); return; } await moveUploadedFiles(files, targetDir); @@ -612,14 +806,32 @@ app.post("/r/:slug", async (req, res) => { res.json({ ok: true, count: files.length }); return; } - res.send(uploadResultPage(request, { heading: "감사합니다", message: `${files.length}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.` })); + res.send( + uploadResultPage(request, { + heading: "감사합니다", + message: `${files.length}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.`, + }) + ); } catch { await removeFiles(req.files); if (wantsJson(req)) { - res.status(500).json({ ok: false, message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요." }); + res + .status(500) + .json({ + ok: false, + message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.", + }); return; } - res.status(500).send(uploadResultPage(request, { heading: "업로드 실패", message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.", isError: true })); + res + .status(500) + .send( + uploadResultPage(request, { + heading: "업로드 실패", + message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.", + isError: true, + }) + ); } }); });