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까지 업로드할 수 있어.
`));
});
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}`);
});