Limit uploads by request total size

This commit is contained in:
shkim
2026-05-10 20:22:33 +09:00
parent c07777371d
commit 00e0fe60b4
5 changed files with 283 additions and 26 deletions
+3
View File
@@ -32,5 +32,8 @@ The server cron runs this once per minute.
- DB: `data/db.json` - DB: `data/db.json`
- Deploy log: `data/deploy.log` - Deploy log: `data/deploy.log`
- Uploads: `/volume2/AKA Drive/사진 받기` - Uploads: `/volume2/AKA Drive/사진 받기`
- Upload page template: `src/public/upload.html`
- Upload page style: `src/public/upload.css`
- Upload page image: `src/public/hero.svg`
Each request creates its own folder under the upload directory. Each request creates its own folder under the upload directory.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-label="">
<rect width="1600" height="900" fill="#20313b"/>
<rect x="980" y="90" width="420" height="300" rx="18" fill="#e6eef5" opacity=".92"/>
<rect x="1060" y="160" width="260" height="28" rx="14" fill="#7393a7"/>
<rect x="1060" y="220" width="190" height="22" rx="11" fill="#94aebd"/>
<circle cx="1240" cy="455" r="115" fill="#f2c94c" opacity=".85"/>
<path d="M0 610c160-54 270-78 430-18 142 53 248 42 370-24 146-80 268-72 428 15 132 72 238 78 372 24v293H0z" fill="#0f766e"/>
<path d="M0 690c146-44 272-48 420 6 126 46 246 42 390-12 168-62 318-48 460 24 124 63 220 70 330 30v162H0z" fill="#115e59"/>
</svg>

After

Width:  |  Height:  |  Size: 703 B

+147
View File
@@ -0,0 +1,147 @@
:root {
color-scheme: light;
--ink: #16202a;
--muted: #667085;
--line: #d4dce7;
--bg: #f5f7fb;
--panel: #ffffff;
--accent: #0f766e;
--accent-dark: #115e59;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.upload-page {
width: min(920px, calc(100% - 28px));
margin: 24px auto;
}
.hero {
min-height: 300px;
position: relative;
overflow: hidden;
border-radius: 8px;
background: #20313b;
}
.hero__media {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(12, 20, 28, 0.78), rgba(12, 20, 28, 0.18)),
url("/assets/hero.svg");
background-size: cover;
background-position: center;
}
.hero__content {
position: relative;
width: min(620px, calc(100% - 36px));
padding: 48px 28px;
color: white;
}
.eyebrow {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: 34px;
line-height: 1.18;
}
.summary {
margin: 14px 0 0;
max-width: 520px;
color: rgba(255, 255, 255, 0.86);
font-size: 16px;
}
.upload-panel {
margin-top: 16px;
padding: 20px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.dropzone {
display: block;
padding: 24px;
border: 2px dashed var(--line);
border-radius: 8px;
background: #fbfcfe;
cursor: pointer;
}
.dropzone__title,
.dropzone__meta {
display: block;
}
.dropzone__title {
font-size: 18px;
font-weight: 750;
}
.dropzone__meta {
margin-top: 6px;
color: var(--muted);
font-size: 14px;
}
input[type="file"] {
margin-top: 16px;
width: 100%;
}
button {
margin-top: 16px;
width: 100%;
border: 0;
border-radius: 6px;
padding: 13px 16px;
background: var(--accent);
color: white;
font: inherit;
font-weight: 750;
cursor: pointer;
}
button:hover {
background: var(--accent-dark);
}
@media (max-width: 640px) {
.upload-page {
width: calc(100% - 18px);
margin: 9px auto;
}
.hero {
min-height: 260px;
}
.hero__content {
width: 100%;
padding: 36px 20px;
}
h1 {
font-size: 28px;
}
}
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
<link rel="stylesheet" href="{{cssPath}}">
</head>
<body>
<main class="upload-page">
<section class="hero">
<div class="hero__media" aria-hidden="true"></div>
<div class="hero__content">
<p class="eyebrow">Photo Request</p>
<h1>{{title}}</h1>
<p class="summary">이 링크 전체에서 총 {{maxMb}} MB까지 업로드할 수 있어.</p>
</div>
</section>
<section class="upload-panel">
<form method="post" action="/r/{{slug}}" enctype="multipart/form-data">
<label class="dropzone">
<span class="dropzone__title">사진이나 영상을 선택해줘</span>
<span class="dropzone__meta">허용 형식: {{allowedExts}}</span>
<input type="file" name="files" accept="{{accept}}" multiple required>
</label>
<button type="submit">업로드</button>
</form>
</section>
</main>
</body>
</html>
+92 -26
View File
@@ -11,6 +11,7 @@ const port = Number(process.env.PORT || 8080);
const dataDir = "/data"; const dataDir = "/data";
const uploadsDir = "/uploads"; const uploadsDir = "/uploads";
const dbPath = path.join(dataDir, "db.json"); const dbPath = path.join(dataDir, "db.json");
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(/\/$/, "");
@@ -22,6 +23,7 @@ if (!adminPassword || !sessionSecret) {
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 }));
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"];
@@ -143,6 +145,69 @@ function page(title, body) {
</html>`; </html>`;
} }
function renderTemplate(template, values) {
return template.replaceAll(/\{\{(\w+)\}\}/g, (match, key) => values[key] ?? match);
}
async function uploadPage(request) {
const accept = request.allowedExts.map((ext) => `.${ext}`).join(",");
const templatePath = path.join(publicDir, "upload.html");
const template = await fsp.readFile(templatePath, "utf8").catch(() => "");
const values = {
title: escapeHtml(request.title),
slug: escapeHtml(request.slug),
accept: escapeHtml(accept),
allowedExts: escapeHtml(request.allowedExts.join(", ")),
maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)),
cssPath: "/assets/upload.css"
};
if (template) return renderTemplate(template, values);
return page(request.title, `
<section class="panel">
<h1>${values.title}</h1>
<p class="muted">이 링크 전체에서 총 ${values.maxMb} MB까지 업로드할 수 있어.</p>
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
<div class="upload-box">
<label>사진/영상 선택</label>
<input type="file" name="files" accept="${values.accept}" multiple required>
<p class="muted">허용 형식: ${values.allowedExts}</p>
</div>
<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) => {
if (error.code === "ENOENT") return [];
throw error;
});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
total += await directorySize(fullPath);
} else if (entry.isFile()) {
total += (await fsp.stat(fullPath)).size;
}
}
return total;
}
async function removeFiles(files = []) {
await Promise.all(files.map((file) => fsp.unlink(file.path).catch(() => {})));
}
async function moveUploadedFiles(files, targetDir) {
await fsp.mkdir(targetDir, { recursive: true });
for (const file of files) {
const targetPath = path.join(targetDir, file.filename);
await fsp.copyFile(file.path, targetPath);
await fsp.unlink(file.path);
}
}
app.get("/", (req, res) => res.redirect("/admin")); app.get("/", (req, res) => res.redirect("/admin"));
app.get("/admin/login", (req, res) => { app.get("/admin/login", (req, res) => {
@@ -206,7 +271,7 @@ app.get("/admin", requireAdmin, async (req, res) => {
<input name="title" placeholder="예: 2026 가족 모임" required> <input name="title" placeholder="예: 2026 가족 모임" required>
</div> </div>
<div> <div>
<label>파일당 최대 용량(MB)</label> <label>링크 총 업로드 용량(MB)</label>
<input name="maxMb" type="number" min="1" max="20480" value="500" required> <input name="maxMb" type="number" min="1" max="20480" value="500" required>
</div> </div>
</div> </div>
@@ -216,7 +281,7 @@ app.get("/admin", requireAdmin, async (req, res) => {
</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>
@@ -261,12 +326,12 @@ async function findRequest(slug) {
} }
function uploadMiddleware(request) { function uploadMiddleware(request) {
const tempDir = path.join(dataDir, "tmp", request.slug);
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: async (req, file, cb) => { destination: async (req, file, cb) => {
try { try {
const target = path.join(uploadsDir, request.folderName); await fsp.mkdir(tempDir, { recursive: true });
await fsp.mkdir(target, { recursive: true }); cb(null, tempDir);
cb(null, target);
} catch (error) { } catch (error) {
cb(error); cb(error);
} }
@@ -279,7 +344,7 @@ function uploadMiddleware(request) {
return multer({ return multer({
storage, storage,
limits: { fileSize: request.maxBytes, 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));
@@ -293,21 +358,7 @@ app.get("/r/:slug", async (req, res) => {
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 accept = request.allowedExts.map((ext) => `.${ext}`).join(","); res.send(await uploadPage(request));
res.send(page(request.title, `
<section class="panel">
<h1>${escapeHtml(request.title)}</h1>
<p class="muted">파일당 최대 ${Math.round(request.maxBytes / 1024 / 1024)} MB까지 업로드할 수 있어.</p>
<form method="post" action="/r/${escapeHtml(request.slug)}" enctype="multipart/form-data">
<div class="upload-box">
<label>사진/영상 선택</label>
<input type="file" name="files" accept="${escapeHtml(accept)}" multiple required>
<p class="muted">허용 형식: ${escapeHtml(request.allowedExts.join(", "))}</p>
</div>
<div class="actions"><button type="submit">업로드</button></div>
</form>
</section>
`));
}); });
app.post("/r/:slug", async (req, res) => { app.post("/r/:slug", async (req, res) => {
@@ -317,15 +368,30 @@ app.post("/r/:slug", async (req, res) => {
return; return;
} }
const upload = uploadMiddleware(request); const upload = uploadMiddleware(request);
upload(req, res, (error) => { upload(req, res, async (error) => {
if (error) { if (error) {
const message = error.code === "LIMIT_FILE_SIZE" await removeFiles(req.files);
? "파일 용량이 설정된 제한보다 커." const message = "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘.";
: "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘.";
res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">${escapeHtml(message)}</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`)); res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">${escapeHtml(message)}</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
return; return;
} }
res.send(page("업로드 완료", `<section class="panel"><h1>업로드 완료</h1><div class="notice">${escapeHtml((req.files || []).length)}개 파일을 받았어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">더 올리기</a></section>`)); try {
const files = req.files || [];
const targetDir = path.join(uploadsDir, request.folderName);
const existingBytes = await directorySize(targetDir);
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));
res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">이 링크의 총 업로드 용량 제한을 넘었어. 남은 용량은 약 ${escapeHtml(remainMb)} MB야.</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
return;
}
await moveUploadedFiles(files, targetDir);
res.send(page("업로드 완료", `<section class="panel"><h1>업로드 완료</h1><div class="notice">${escapeHtml(files.length)}개 파일을 받았어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">더 올리기</a></section>`));
} catch {
await removeFiles(req.files);
res.status(500).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">파일을 저장하는 중 오류가 났어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
}
}); });
}); });