|
|
|
@@ -15,17 +15,38 @@ 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,162.12.184.41,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];
|
|
|
|
|
|
|
|
|
@@ -51,7 +72,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}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -66,7 +90,7 @@ function cleanGuestField(value) {
|
|
|
|
|
|
|
|
|
|
function submissionFolderName(guestPhone) {
|
|
|
|
|
const phone = cleanGuestField(guestPhone).replace(/[^\d]/g, "");
|
|
|
|
|
return phone || "익명";
|
|
|
|
|
return /^010\d{8}$/.test(phone) ? phone : "익명";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeExts(value) {
|
|
|
|
@@ -74,7 +98,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() {
|
|
|
|
@@ -101,7 +128,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)}`;
|
|
|
|
|
}
|
|
|
|
@@ -111,7 +141,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;
|
|
|
|
@@ -168,13 +200,82 @@ 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) {
|
|
|
|
|
return String(req.get("accept") || "").includes("application/json");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseIpList(value) {
|
|
|
|
|
return String(value || "")
|
|
|
|
|
.split(/[\s,;]+/)
|
|
|
|
|
.map(normalizeIp)
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeIp(value) {
|
|
|
|
|
let ip = String(value || "").trim();
|
|
|
|
|
if (!ip) return "";
|
|
|
|
|
if (ip.startsWith("[") && ip.endsWith("]")) ip = ip.slice(1, -1);
|
|
|
|
|
if (ip.startsWith("::ffff:")) ip = ip.slice("::ffff:".length);
|
|
|
|
|
return ip;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clientIp(req) {
|
|
|
|
|
const cloudflareIp = normalizeIp(req.get("cf-connecting-ip") || "");
|
|
|
|
|
if (cloudflareIp) return cloudflareIp;
|
|
|
|
|
|
|
|
|
|
const forwarded = String(req.get("x-forwarded-for") || "")
|
|
|
|
|
.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 ||
|
|
|
|
|
""
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isPrivateIp(ip) {
|
|
|
|
|
if (ip === "localhost" || ip === "::1") 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;
|
|
|
|
|
const [a, b] = parts;
|
|
|
|
|
return (
|
|
|
|
|
a === 10 ||
|
|
|
|
|
a === 127 ||
|
|
|
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
|
|
|
(a === 192 && b === 168) ||
|
|
|
|
|
(a === 169 && b === 254)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireAdminIp(req, res, next) {
|
|
|
|
|
const ip = clientIp(req);
|
|
|
|
|
if (adminAllowedIps.includes(ip)) {
|
|
|
|
|
next();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
console.warn(
|
|
|
|
|
`blocked admin path from ${ip || "unknown"}: ${req.method} ${
|
|
|
|
|
req.originalUrl
|
|
|
|
|
}`
|
|
|
|
|
);
|
|
|
|
|
res.status(404).send(page("잘못된 접근", ""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function uploadResultPage(request, { heading, message, isError = false }) {
|
|
|
|
|
const slug = escapeHtml(request.slug);
|
|
|
|
|
return `<!doctype html>
|
|
|
|
@@ -192,10 +293,14 @@ function uploadResultPage(request, { heading, message, isError = false }) {
|
|
|
|
|
<img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt="">
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
<p>${escapeHtml(message)}</p>
|
|
|
|
|
<a class="upload-again" href="/r/${slug}">${isError ? "다시 올리기" : "사진 더 올리기"}</a>
|
|
|
|
|
<a class="upload-again" href="/r/${slug}">${
|
|
|
|
|
isError ? "다시 올리기" : "사진 더 올리기"
|
|
|
|
|
}</a>
|
|
|
|
|
</section>
|
|
|
|
|
</section>
|
|
|
|
|
</main>
|
|
|
|
@@ -213,10 +318,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,
|
|
|
|
|
`
|
|
|
|
|
<section class="panel">
|
|
|
|
|
<h1>${values.title}</h1>
|
|
|
|
|
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
|
|
|
|
@@ -229,12 +336,15 @@ async function uploadPage(request) {
|
|
|
|
|
<div class="actions"><button type="submit">업로드</button></div>
|
|
|
|
|
</form>
|
|
|
|
|
</section>
|
|
|
|
|
`);
|
|
|
|
|
`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function directorySize(dir) {
|
|
|
|
|
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 [];
|
|
|
|
|
throw error;
|
|
|
|
|
});
|
|
|
|
@@ -262,7 +372,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,
|
|
|
|
@@ -270,16 +383,22 @@ 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) => {
|
|
|
|
|
const entries = await fsp
|
|
|
|
|
.readdir(requestDir, { withFileTypes: true })
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
if (error.code === "ENOENT") return [];
|
|
|
|
|
throw error;
|
|
|
|
|
});
|
|
|
|
@@ -303,7 +422,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) {
|
|
|
|
@@ -312,14 +433,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.redirect("/admin"));
|
|
|
|
|
app.get("/", (req, res) => {
|
|
|
|
|
res
|
|
|
|
|
.status(404)
|
|
|
|
|
.send(
|
|
|
|
|
page(
|
|
|
|
|
"페이지 없음",
|
|
|
|
|
`error`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.use("/admin", requireAdminIp);
|
|
|
|
|
|
|
|
|
|
app.get("/admin/login", (req, res) => {
|
|
|
|
|
res.send(page("관리자 로그인", `
|
|
|
|
|
res.send(
|
|
|
|
|
page(
|
|
|
|
|
"관리자 로그인",
|
|
|
|
|
`
|
|
|
|
|
<section class="panel">
|
|
|
|
|
<h1>사진 요청 관리자</h1>
|
|
|
|
|
<form method="post" action="/admin/login">
|
|
|
|
@@ -328,18 +463,34 @@ app.get("/admin/login", (req, res) => {
|
|
|
|
|
<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);
|
|
|
|
|
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>`));
|
|
|
|
|
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.cookie("session", makeSession(), {
|
|
|
|
|
httpOnly: true,
|
|
|
|
|
sameSite: "lax",
|
|
|
|
|
secure: req.secure,
|
|
|
|
|
maxAge: 1000 * 60 * 60 * 12,
|
|
|
|
|
});
|
|
|
|
|
res.redirect("/admin");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -356,22 +507,34 @@ app.get("/admin", requireAdmin, async (req, res) => {
|
|
|
|
|
.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><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>
|
|
|
|
|
<div class="stack">
|
|
|
|
|
<a class="button secondary" href="/admin/requests/${escapeHtml(item.slug)}/submissions">제출 내역</a>
|
|
|
|
|
<form method="post" action="/admin/requests/${escapeHtml(item.slug)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
|
|
|
|
|
<a class="button secondary" href="/admin/requests/${escapeHtml(
|
|
|
|
|
item.slug
|
|
|
|
|
)}/submissions">제출 내역</a>
|
|
|
|
|
<form method="post" action="/admin/requests/${escapeHtml(
|
|
|
|
|
item.slug
|
|
|
|
|
)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
|
|
|
|
|
<button class="danger" type="submit">삭제</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>`;
|
|
|
|
|
}).join("");
|
|
|
|
|
})
|
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
|
|
res.send(page("사진 요청 관리자", `
|
|
|
|
|
res.send(
|
|
|
|
|
page(
|
|
|
|
|
"사진 요청 관리자",
|
|
|
|
|
`
|
|
|
|
|
<section class="panel">
|
|
|
|
|
<form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form>
|
|
|
|
|
<h1>사진 요청 관리자</h1>
|
|
|
|
@@ -387,23 +550,37 @@ app.get("/admin", requireAdmin, async (req, res) => {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
</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>
|
|
|
|
|
<tbody>${
|
|
|
|
|
rows ||
|
|
|
|
|
`<tr><td colspan="5" class="muted">아직 만든 링크가 없어.</td></tr>`
|
|
|
|
|
}</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</section>
|
|
|
|
|
`));
|
|
|
|
|
`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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("제출 내역 없음", `<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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -424,25 +601,39 @@ app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
|
|
|
|
|
<dl>
|
|
|
|
|
<dt>연락처</dt><dd>${escapeHtml(submission.guestPhone || "-")}</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>
|
|
|
|
|
</dl>
|
|
|
|
|
<ul class="file-list-admin">${fileItems || `<li class="muted">파일 기록이 없어.</li>`}</ul>
|
|
|
|
|
<ul class="file-list-admin">${
|
|
|
|
|
fileItems || `<li class="muted">파일 기록이 없어.</li>`
|
|
|
|
|
}</ul>
|
|
|
|
|
</article>`;
|
|
|
|
|
})
|
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
|
|
res.send(page(`${item.title} 제출 내역`, `
|
|
|
|
|
res.send(
|
|
|
|
|
page(
|
|
|
|
|
`${item.title} 제출 내역`,
|
|
|
|
|
`
|
|
|
|
|
<section class="panel">
|
|
|
|
|
<div class="actions" style="float:right">
|
|
|
|
|
<a class="button secondary" href="/admin">관리자 홈</a>
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
${rows || `<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`}
|
|
|
|
|
${
|
|
|
|
|
rows ||
|
|
|
|
|
`<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`
|
|
|
|
|
}
|
|
|
|
|
</section>
|
|
|
|
|
`));
|
|
|
|
|
`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/admin/requests", requireAdmin, async (req, res) => {
|
|
|
|
@@ -451,11 +642,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("입력 오류", `<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;
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
@@ -463,7 +663,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");
|
|
|
|
@@ -471,7 +671,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");
|
|
|
|
@@ -479,7 +681,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) {
|
|
|
|
@@ -494,25 +698,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("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
|
|
|
|
|
res
|
|
|
|
|
.status(404)
|
|
|
|
|
.send(
|
|
|
|
|
page(
|
|
|
|
|
"링크 없음",
|
|
|
|
|
`<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
res.send(await uploadPage(request));
|
|
|
|
@@ -521,7 +740,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("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
|
|
|
|
|
res
|
|
|
|
|
.status(404)
|
|
|
|
|
.send(
|
|
|
|
|
page(
|
|
|
|
|
"링크 없음",
|
|
|
|
|
`<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const upload = uploadMiddleware(request);
|
|
|
|
@@ -533,7 +759,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 {
|
|
|
|
@@ -546,12 +780,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);
|
|
|
|
@@ -560,14 +810,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,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|