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 }) {
@@ -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,
+ `
- `);
+ `
+ );
}
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(
+ "관리자 로그인",
+ `
- `));
+ `
+ )
+ );
});
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(
+ "사진 요청 관리자",
+ `
- `));
+ `
+ )
+ );
});
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}개
- ${fileItems || `- 파일 기록이 없어.
`}
+ ${
+ fileItems || `- 파일 기록이 없어.
`
+ }
`;
})
.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,
+ })
+ );
}
});
});