diff --git a/README.md b/README.md index af4d193..4da9d69 100644 --- a/README.md +++ b/README.md @@ -32,5 +32,8 @@ The server cron runs this once per minute. - DB: `data/db.json` - Deploy log: `data/deploy.log` - 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. diff --git a/src/public/hero.svg b/src/public/hero.svg new file mode 100644 index 0000000..784b7cf --- /dev/null +++ b/src/public/hero.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/public/upload.css b/src/public/upload.css new file mode 100644 index 0000000..0770f4a --- /dev/null +++ b/src/public/upload.css @@ -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; + } +} diff --git a/src/public/upload.html b/src/public/upload.html new file mode 100644 index 0000000..6a6e33b --- /dev/null +++ b/src/public/upload.html @@ -0,0 +1,32 @@ + + + + + + {{title}} + + + +
+
+ +
+

Photo Request

+

{{title}}

+

이 링크 전체에서 총 {{maxMb}} MB까지 업로드할 수 있어.

+
+
+ +
+
+ + +
+
+
+ + diff --git a/src/server.js b/src/server.js index 416bb3c..efc3633 100644 --- a/src/server.js +++ b/src/server.js @@ -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) { `; } +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, ` +
+

${values.title}

+

이 링크 전체에서 총 ${values.maxMb} MB까지 업로드할 수 있어.

+
+
+ + +

허용 형식: ${values.allowedExts}

+
+
+
+
+ `); +} + +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) => {
- +
@@ -216,7 +281,7 @@ app.get("/admin", requireAdmin, async (req, res) => {

활성 링크

- + ${rows || ``}
요청링크용량확장자
요청링크총 용량 제한확장자
아직 만든 링크가 없어.
@@ -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("링크 없음", `

링크가 없거나 삭제됐어

`)); return; } - const accept = request.allowedExts.map((ext) => `.${ext}`).join(","); - res.send(page(request.title, ` -
-

${escapeHtml(request.title)}

-

파일당 최대 ${Math.round(request.maxBytes / 1024 / 1024)} MB까지 업로드할 수 있어.

-
-
- - -

허용 형식: ${escapeHtml(request.allowedExts.join(", "))}

-
-
-
-
- `)); + 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("업로드 실패", `

업로드 실패

${escapeHtml(message)}
다시 올리기
`)); return; } - res.send(page("업로드 완료", `

업로드 완료

${escapeHtml((req.files || []).length)}개 파일을 받았어.
더 올리기
`)); + 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("업로드 실패", `

업로드 실패

이 링크의 총 업로드 용량 제한을 넘었어. 남은 용량은 약 ${escapeHtml(remainMb)} MB야.
다시 올리기
`)); + return; + } + await moveUploadedFiles(files, targetDir); + res.send(page("업로드 완료", `

업로드 완료

${escapeHtml(files.length)}개 파일을 받았어.
더 올리기
`)); + } catch { + await removeFiles(req.files); + res.status(500).send(page("업로드 실패", `

업로드 실패

파일을 저장하는 중 오류가 났어.
다시 올리기
`)); + } }); });