Limit uploads by request total size
This commit is contained in:
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -11,6 +11,7 @@ const port = Number(process.env.PORT || 8080);
|
||||
const dataDir = "/data";
|
||||
const uploadsDir = "/uploads";
|
||||
const dbPath = path.join(dataDir, "db.json");
|
||||
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(/\/$/, "");
|
||||
@@ -22,6 +23,7 @@ if (!adminPassword || !sessionSecret) {
|
||||
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));
|
||||
app.use("/assets", express.static(publicDir, { fallthrough: true }));
|
||||
|
||||
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "avif"];
|
||||
const videoExts = ["mp4", "mov", "m4v", "avi", "mkv", "webm"];
|
||||
@@ -143,6 +145,69 @@ function page(title, body) {
|
||||
</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("/admin/login", (req, res) => {
|
||||
@@ -206,7 +271,7 @@ app.get("/admin", requireAdmin, async (req, res) => {
|
||||
<input name="title" placeholder="예: 2026 가족 모임" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>파일당 최대 용량(MB)</label>
|
||||
<label>링크 총 업로드 용량(MB)</label>
|
||||
<input name="maxMb" type="number" min="1" max="20480" value="500" required>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +281,7 @@ app.get("/admin", requireAdmin, async (req, res) => {
|
||||
</form>
|
||||
<h2>활성 링크</h2>
|
||||
<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>
|
||||
</table>
|
||||
</section>
|
||||
@@ -261,12 +326,12 @@ async function findRequest(slug) {
|
||||
}
|
||||
|
||||
function uploadMiddleware(request) {
|
||||
const tempDir = path.join(dataDir, "tmp", request.slug);
|
||||
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);
|
||||
await fsp.mkdir(tempDir, { recursive: true });
|
||||
cb(null, tempDir);
|
||||
} catch (error) {
|
||||
cb(error);
|
||||
}
|
||||
@@ -279,7 +344,7 @@ function uploadMiddleware(request) {
|
||||
|
||||
return multer({
|
||||
storage,
|
||||
limits: { fileSize: request.maxBytes, files: 100 },
|
||||
limits: { files: 100 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).replace(".", "").toLowerCase();
|
||||
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>`));
|
||||
return;
|
||||
}
|
||||
const accept = request.allowedExts.map((ext) => `.${ext}`).join(",");
|
||||
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>
|
||||
`));
|
||||
res.send(await uploadPage(request));
|
||||
});
|
||||
|
||||
app.post("/r/:slug", async (req, res) => {
|
||||
@@ -317,15 +368,30 @@ app.post("/r/:slug", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
const upload = uploadMiddleware(request);
|
||||
upload(req, res, (error) => {
|
||||
upload(req, res, async (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>`));
|
||||
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>`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user