이상한 접근 금지

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-17 11:13:01 +09:00
parent f9634b37a1
commit 31e0f8e5ca
+284 -72
View File
@@ -15,18 +15,37 @@ const publicDir = path.resolve("src", "public");
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
const sessionSecret = process.env.SESSION_SECRET; const sessionSecret = process.env.SESSION_SECRET;
const publicBaseUrl = (process.env.PUBLIC_BASE_URL || "").replace(/\/$/, ""); 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) { if (!adminPassword || !sessionSecret) {
throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required."); throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required.");
} }
app.set("trust proxy", true); 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(cookieParser(sessionSecret));
app.use("/assets", express.static(publicDir, { fallthrough: true })); 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 videoExts = ["mp4", "mov", "m4v", "avi", "mkv", "webm"];
const defaultExts = [...imageExts, ...videoExts]; const defaultExts = [...imageExts, ...videoExts];
@@ -52,7 +71,10 @@ function sanitizeName(value, fallback = "request") {
function sanitizeFileName(value) { function sanitizeFileName(value) {
const parsed = path.parse(sanitizeName(value, "upload")); const parsed = path.parse(sanitizeName(value, "upload"));
const base = sanitizeName(parsed.name, "upload").replace(/^\.+$/, "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}`; return `${base}${ext}`;
} }
@@ -75,7 +97,10 @@ function normalizeExts(value) {
.split(/[\s,;]+/) .split(/[\s,;]+/)
.map((item) => item.trim().replace(/^\./, "").toLowerCase()) .map((item) => item.trim().replace(/^\./, "").toLowerCase())
.filter(Boolean) .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() { async function ensureDb() {
@@ -102,7 +127,10 @@ function sign(value) {
} }
function makeSession() { 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"); const encoded = Buffer.from(payload).toString("base64url");
return `${encoded}.${sign(encoded)}`; return `${encoded}.${sign(encoded)}`;
} }
@@ -112,7 +140,9 @@ function isAuthed(req) {
const [encoded, mac] = token.split("."); const [encoded, mac] = token.split(".");
if (!encoded || !mac || sign(encoded) !== mac) return false; if (!encoded || !mac || sign(encoded) !== mac) return false;
try { 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(); return payload.admin === true && payload.exp > Date.now();
} catch { } catch {
return false; return false;
@@ -169,7 +199,10 @@ function page(title, body) {
} }
function renderTemplate(template, values) { 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) { function wantsJson(req) {
@@ -196,21 +229,33 @@ function clientIp(req) {
.split(",") .split(",")
.map(normalizeIp) .map(normalizeIp)
.filter(Boolean); .filter(Boolean);
const closestPublicIp = forwarded.toReversed().find((ip) => !isPrivateIp(ip)) || ""; const closestPublicIp =
return normalizeIp(closestPublicIp || forwarded[0] || req.get("x-real-ip") || req.ip || req.socket.remoteAddress || ""); forwarded.toReversed().find((ip) => !isPrivateIp(ip)) || "";
return normalizeIp(
closestPublicIp ||
forwarded[0] ||
req.get("x-real-ip") ||
req.ip ||
req.socket.remoteAddress ||
""
);
} }
function isPrivateIp(ip) { function isPrivateIp(ip) {
if (ip === "localhost" || ip === "::1") return true; 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)); 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; const [a, b] = parts;
return a === 10 return (
|| a === 127 a === 10 ||
|| (a === 172 && b >= 16 && b <= 31) a === 127 ||
|| (a === 192 && b === 168) (a === 172 && b >= 16 && b <= 31) ||
|| (a === 169 && b === 254); (a === 192 && b === 168) ||
(a === 169 && b === 254)
);
} }
function requireAdminIp(req, res, next) { function requireAdminIp(req, res, next) {
@@ -219,8 +264,12 @@ function requireAdminIp(req, res, next) {
next(); next();
return; return;
} }
console.warn(`blocked admin path from ${ip || "unknown"}: ${req.method} ${req.originalUrl}`); console.warn(
res.status(404).send(page("페이지 없음", `<section class="panel"><h1>페이지를 찾을 수 없어</h1></section>`)); `blocked admin path from ${ip || "unknown"}: ${req.method} ${
req.originalUrl
}`
);
res.status(404).send(page("잘못된 접근", ""));
} }
function uploadResultPage(request, { heading, message, isError = false }) { function uploadResultPage(request, { heading, message, isError = false }) {
@@ -240,10 +289,14 @@ function uploadResultPage(request, { heading, message, isError = false }) {
<img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt=""> <img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt="">
</div> </div>
<section class="thank-you${isError ? " thank-you--error" : ""}"> <section class="thank-you${isError ? " thank-you--error" : ""}">
<p class="thank-you__eyebrow">${isError ? "PLEASE TRY AGAIN" : "THANK YOU"}</p> <p class="thank-you__eyebrow">${
isError ? "PLEASE TRY AGAIN" : "THANK YOU"
}</p>
<h2>${escapeHtml(heading)}</h2> <h2>${escapeHtml(heading)}</h2>
<p>${escapeHtml(message)}</p> <p>${escapeHtml(message)}</p>
<a class="upload-again" href="/r/${slug}">${isError ? "다시 올리기" : "사진 더 올리기"}</a> <a class="upload-again" href="/r/${slug}">${
isError ? "다시 올리기" : "사진 더 올리기"
}</a>
</section> </section>
</section> </section>
</main> </main>
@@ -261,10 +314,12 @@ async function uploadPage(request) {
accept: escapeHtml(accept), accept: escapeHtml(accept),
allowedExts: escapeHtml(request.allowedExts.join(", ")), allowedExts: escapeHtml(request.allowedExts.join(", ")),
maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)), maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)),
cssPath: "/assets/upload.css" cssPath: "/assets/upload.css",
}; };
if (template) return renderTemplate(template, values); if (template) return renderTemplate(template, values);
return page(request.title, ` return page(
request.title,
`
<section class="panel"> <section class="panel">
<h1>${values.title}</h1> <h1>${values.title}</h1>
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data"> <form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
@@ -277,12 +332,15 @@ async function uploadPage(request) {
<div class="actions"><button type="submit">업로드</button></div> <div class="actions"><button type="submit">업로드</button></div>
</form> </form>
</section> </section>
`); `
);
} }
async function directorySize(dir) { async function directorySize(dir) {
let total = 0; let total = 0;
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch((error) => { const entries = await fsp
.readdir(dir, { withFileTypes: true })
.catch((error) => {
if (error.code === "ENOENT") return []; if (error.code === "ENOENT") return [];
throw error; throw error;
}); });
@@ -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 = { const entry = {
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
guestPhone, guestPhone,
@@ -318,16 +379,22 @@ async function appendUploadMetadata(targetDir, { guestPhone, folderName, files }
files: files.map((file) => ({ files: files.map((file) => ({
storedName: file.filename, storedName: file.filename,
originalName: Buffer.from(file.originalname, "latin1").toString("utf8"), 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) { async function readUploadMetadata(folderName) {
const requestDir = path.join(uploadsDir, folderName); const requestDir = path.join(uploadsDir, folderName);
const paths = [path.join(requestDir, "upload-metadata.jsonl")]; const paths = [path.join(requestDir, "upload-metadata.jsonl")];
const entries = await fsp.readdir(requestDir, { withFileTypes: true }).catch((error) => { const entries = await fsp
.readdir(requestDir, { withFileTypes: true })
.catch((error) => {
if (error.code === "ENOENT") return []; if (error.code === "ENOENT") return [];
throw error; throw error;
}); });
@@ -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) { function formatDateTime(value) {
@@ -360,18 +429,28 @@ function formatDateTime(value) {
return new Intl.DateTimeFormat("ko-KR", { return new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul", timeZone: "Asia/Seoul",
dateStyle: "medium", dateStyle: "medium",
timeStyle: "short" timeStyle: "short",
}).format(date); }).format(date);
} }
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.status(404).send(page("페이지 없음", `<section class="panel"><h1>페이지를 찾을 수 없어</h1></section>`)); res
.status(404)
.send(
page(
"페이지 없음",
`<section class="panel"><h1>페이지를 찾을 수 없어</h1></section>`
)
);
}); });
app.use("/admin", requireAdminIp); app.use("/admin", requireAdminIp);
app.get("/admin/login", (req, res) => { app.get("/admin/login", (req, res) => {
res.send(page("관리자 로그인", ` res.send(
page(
"관리자 로그인",
`
<section class="panel"> <section class="panel">
<h1>사진 요청 관리자</h1> <h1>사진 요청 관리자</h1>
<form method="post" action="/admin/login"> <form method="post" action="/admin/login">
@@ -380,18 +459,34 @@ app.get("/admin/login", (req, res) => {
<div class="actions"><button type="submit">로그인</button></div> <div class="actions"><button type="submit">로그인</button></div>
</form> </form>
</section> </section>
`)); `
)
);
}); });
app.post("/admin/login", (req, res) => { app.post("/admin/login", (req, res) => {
const supplied = Buffer.from(req.body.password || ""); const supplied = Buffer.from(req.body.password || "");
const expected = Buffer.from(adminPassword); 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) { if (!ok) {
res.status(401).send(page("로그인 실패", `<section class="panel"><h1>로그인 실패</h1><p class="muted">비밀번호가 맞지 않아.</p><a class="button" href="/admin/login">다시 시도</a></section>`)); res
.status(401)
.send(
page(
"로그인 실패",
`<section class="panel"><h1>로그인 실패</h1><p class="muted">비밀번호가 맞지 않아.</p><a class="button" href="/admin/login">다시 시도</a></section>`
)
);
return; 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"); res.redirect("/admin");
}); });
@@ -408,22 +503,34 @@ app.get("/admin", requireAdmin, async (req, res) => {
.map((item) => { .map((item) => {
const url = `${publicBaseUrl}/r/${item.slug}`; const url = `${publicBaseUrl}/r/${item.slug}`;
return `<tr> return `<tr>
<td><strong>${escapeHtml(item.title)}</strong><br><span class="muted">${escapeHtml(item.folderName)}</span></td> <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><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></td>
<td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td> <td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td>
<td>${escapeHtml(item.allowedExts.join(", "))}</td> <td>${escapeHtml(item.allowedExts.join(", "))}</td>
<td> <td>
<div class="stack"> <div class="stack">
<a class="button secondary" href="/admin/requests/${escapeHtml(item.slug)}/submissions">제출 내역</a> <a class="button secondary" href="/admin/requests/${escapeHtml(
<form method="post" action="/admin/requests/${escapeHtml(item.slug)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');"> item.slug
)}/submissions">제출 내역</a>
<form method="post" action="/admin/requests/${escapeHtml(
item.slug
)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
<button class="danger" type="submit">삭제</button> <button class="danger" type="submit">삭제</button>
</form> </form>
</div> </div>
</td> </td>
</tr>`; </tr>`;
}).join(""); })
.join("");
res.send(page("사진 요청 관리자", ` res.send(
page(
"사진 요청 관리자",
`
<section class="panel"> <section class="panel">
<form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form> <form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form>
<h1>사진 요청 관리자</h1> <h1>사진 요청 관리자</h1>
@@ -439,23 +546,37 @@ app.get("/admin", requireAdmin, async (req, res) => {
</div> </div>
</div> </div>
<label>허용 확장자</label> <label>허용 확장자</label>
<input name="allowedExts" value="${escapeHtml(defaultExts.join(", "))}" required> <input name="allowedExts" value="${escapeHtml(
defaultExts.join(", ")
)}" required>
<div class="actions"><button type="submit">링크 만들기</button></div> <div class="actions"><button type="submit">링크 만들기</button></div>
</form> </form>
<h2>활성 링크</h2> <h2>활성 링크</h2>
<table class="table"> <table class="table">
<thead><tr><th>요청</th><th>링크</th><th>총 용량 제한</th><th>확장자</th><th></th></tr></thead> <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> <tbody>${
rows ||
`<tr><td colspan="5" class="muted">아직 만든 링크가 없어.</td></tr>`
}</tbody>
</table> </table>
</section> </section>
`)); `
)
);
}); });
app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => { app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
const db = await readDb(); const db = await readDb();
const item = db.requests.find((request) => request.slug === req.params.slug); const item = db.requests.find((request) => request.slug === req.params.slug);
if (!item) { if (!item) {
res.status(404).send(page("제출 내역 없음", `<section class="panel"><h1>제출 내역 없음</h1><p class="muted">해당 링크를 찾을 수 없어.</p><a class="button" href="/admin">돌아가기</a></section>`)); res
.status(404)
.send(
page(
"제출 내역 없음",
`<section class="panel"><h1>제출 내역 없음</h1><p class="muted">해당 링크를 찾을 수 없어.</p><a class="button" href="/admin">돌아가기</a></section>`
)
);
return; return;
} }
@@ -476,25 +597,39 @@ app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
<dl> <dl>
<dt>연락처</dt><dd>${escapeHtml(submission.guestPhone || "-")}</dd> <dt>연락처</dt><dd>${escapeHtml(submission.guestPhone || "-")}</dd>
<dt>저장 폴더</dt><dd>${escapeHtml(submission.folderName || "-")}</dd> <dt>저장 폴더</dt><dd>${escapeHtml(submission.folderName || "-")}</dd>
<dt>제출 시간</dt><dd>${escapeHtml(formatDateTime(submission.uploadedAt))}</dd> <dt>제출 시간</dt><dd>${escapeHtml(
formatDateTime(submission.uploadedAt)
)}</dd>
<dt>파일 수</dt><dd>${files.length}개</dd> <dt>파일 수</dt><dd>${files.length}개</dd>
</dl> </dl>
<ul class="file-list-admin">${fileItems || `<li class="muted">파일 기록이 없어.</li>`}</ul> <ul class="file-list-admin">${
fileItems || `<li class="muted">파일 기록이 없어.</li>`
}</ul>
</article>`; </article>`;
}) })
.join(""); .join("");
res.send(page(`${item.title} 제출 내역`, ` res.send(
page(
`${item.title} 제출 내역`,
`
<section class="panel"> <section class="panel">
<div class="actions" style="float:right"> <div class="actions" style="float:right">
<a class="button secondary" href="/admin">관리자 홈</a> <a class="button secondary" href="/admin">관리자 홈</a>
</div> </div>
<h1>${escapeHtml(item.title)} 제출 내역</h1> <h1>${escapeHtml(item.title)} 제출 내역</h1>
<p class="muted">저장 위치: ${escapeHtml(path.join(uploadsDir, item.folderName))}</p> <p class="muted">저장 위치: ${escapeHtml(
path.join(uploadsDir, item.folderName)
)}</p>
<p class="muted">제출 정보 파일: 각 연락처/익명 폴더의 upload-metadata.jsonl</p> <p class="muted">제출 정보 파일: 각 연락처/익명 폴더의 upload-metadata.jsonl</p>
${rows || `<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`} ${
rows ||
`<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`
}
</section> </section>
`)); `
)
);
}); });
app.post("/admin/requests", requireAdmin, async (req, res) => { 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 allowedExts = normalizeExts(req.body.allowedExts);
const maxMb = Math.max(1, Math.min(20480, Number(req.body.maxMb || 500))); const maxMb = Math.max(1, Math.min(20480, Number(req.body.maxMb || 500)));
if (allowedExts.length === 0) { 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>`)); res
.status(400)
.send(
page(
"입력 오류",
`<section class="panel"><h1>입력 오류</h1><p class="muted">허용 확장자를 하나 이상 넣어야 해.</p><a class="button" href="/admin">돌아가기</a></section>`
)
);
return; return;
} }
const slug = crypto.randomBytes(16).toString("base64url"); 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 }); await fsp.mkdir(path.join(uploadsDir, folderName), { recursive: true });
db.requests.push({ db.requests.push({
slug, slug,
@@ -515,7 +659,7 @@ app.post("/admin/requests", requireAdmin, async (req, res) => {
folderName, folderName,
maxBytes: maxMb * 1024 * 1024, maxBytes: maxMb * 1024 * 1024,
allowedExts, allowedExts,
createdAt: new Date().toISOString() createdAt: new Date().toISOString(),
}); });
await writeDb(db); await writeDb(db);
res.redirect("/admin"); 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) => { app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
const db = await readDb(); 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(); if (item) item.deletedAt = new Date().toISOString();
await writeDb(db); await writeDb(db);
res.redirect("/admin"); res.redirect("/admin");
@@ -531,7 +677,9 @@ app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
async function findRequest(slug) { async function findRequest(slug) {
const db = await readDb(); 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) { function uploadMiddleware(request) {
@@ -546,25 +694,40 @@ function uploadMiddleware(request) {
} }
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
const safe = sanitizeFileName(Buffer.from(file.originalname, "latin1").toString("utf8")); const safe = sanitizeFileName(
cb(null, `${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`); Buffer.from(file.originalname, "latin1").toString("utf8")
} );
cb(
null,
`${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`
);
},
}); });
return multer({ return multer({
storage, storage,
limits: { files: 100 }, limits: { files: 100 },
fileFilter: (req, file, cb) => { 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)); cb(null, request.allowedExts.includes(ext));
} },
}).array("files", 100); }).array("files", 100);
} }
app.get("/r/:slug", async (req, res) => { app.get("/r/:slug", async (req, res) => {
const request = await findRequest(req.params.slug); const request = await findRequest(req.params.slug);
if (!request) { if (!request) {
res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`)); res
.status(404)
.send(
page(
"링크 없음",
`<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`
)
);
return; return;
} }
res.send(await uploadPage(request)); res.send(await uploadPage(request));
@@ -573,7 +736,14 @@ app.get("/r/:slug", async (req, res) => {
app.post("/r/:slug", async (req, res) => { app.post("/r/:slug", async (req, res) => {
const request = await findRequest(req.params.slug); const request = await findRequest(req.params.slug);
if (!request) { if (!request) {
res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`)); res
.status(404)
.send(
page(
"링크 없음",
`<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`
)
);
return; return;
} }
const upload = uploadMiddleware(request); const upload = uploadMiddleware(request);
@@ -585,7 +755,15 @@ app.post("/r/:slug", async (req, res) => {
res.status(400).json({ ok: false, message }); res.status(400).json({ ok: false, message });
return; return;
} }
res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message, isError: true })); res
.status(400)
.send(
uploadResultPage(request, {
heading: "업로드 실패",
message,
isError: true,
})
);
return; return;
} }
try { try {
@@ -598,12 +776,28 @@ app.post("/r/:slug", async (req, res) => {
const incomingBytes = files.reduce((total, file) => total + file.size, 0); const incomingBytes = files.reduce((total, file) => total + file.size, 0);
if (existingBytes + incomingBytes > request.maxBytes) { if (existingBytes + incomingBytes > request.maxBytes) {
await removeFiles(files); 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)) { if (wantsJson(req)) {
res.status(400).json({ ok: false, message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.` }); res
.status(400)
.json({
ok: false,
message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`,
});
return; 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; return;
} }
await moveUploadedFiles(files, targetDir); await moveUploadedFiles(files, targetDir);
@@ -612,14 +806,32 @@ app.post("/r/:slug", async (req, res) => {
res.json({ ok: true, count: files.length }); res.json({ ok: true, count: files.length });
return; return;
} }
res.send(uploadResultPage(request, { heading: "감사합니다", message: `${files.length}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.` })); res.send(
uploadResultPage(request, {
heading: "감사합니다",
message: `${files.length}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.`,
})
);
} catch { } catch {
await removeFiles(req.files); await removeFiles(req.files);
if (wantsJson(req)) { if (wantsJson(req)) {
res.status(500).json({ ok: false, message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요." }); res
.status(500)
.json({
ok: false,
message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.",
});
return; return;
} }
res.status(500).send(uploadResultPage(request, { heading: "업로드 실패", message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.", isError: true })); res
.status(500)
.send(
uploadResultPage(request, {
heading: "업로드 실패",
message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.",
isError: true,
})
);
} }
}); });
}); });