From 94ff09dc9829f8f898dac85b78d148e123dfbcfd Mon Sep 17 00:00:00 2001 From: shkim Date: Sun, 10 May 2026 20:08:22 +0900 Subject: [PATCH] first commit --- .gitignore | 3 + Dockerfile | 14 +++ README.md | 25 ++++ build.sh | 4 + package.json | 14 +++ run.sh | 17 +++ src/server.js | 334 ++++++++++++++++++++++++++++++++++++++++++++++++++ stop.sh | 4 + 8 files changed, 415 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 build.sh create mode 100644 package.json create mode 100644 run.sh create mode 100644 src/server.js create mode 100644 stop.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f77d70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..827153e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +COPY src ./src + +ENV NODE_ENV=production +ENV PORT=8080 + +EXPOSE 8080 +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..94d02c5 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Photo Request Service + +Single-container upload request service for `photoreq.mokaya.org`. + +## Run + +```sh +sudo ./build.sh +sudo ./run.sh +``` + +## Stop + +```sh +sudo ./stop.sh +``` + +## Paths + +- App: `/volume2/homes/whitekomani/photo-request-service` +- Config: `.env` +- DB: `data/db.json` +- Uploads: `/volume2/AKA Drive/사진 받기` + +Each request creates its own folder under the upload directory. diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..351db74 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +/usr/local/bin/docker build -t photoreq:latest . diff --git a/package.json b/package.json new file mode 100644 index 0000000..365f5a4 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "photo-request-service", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "cookie-parser": "^1.4.7", + "express": "^4.19.2", + "multer": "^2.0.2" + } +} diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..174a19c --- /dev/null +++ b/run.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +APP_DIR="$(cd "$(dirname "$0")" && pwd)" +UPLOAD_DIR="/volume2/AKA Drive/사진 받기" + +/usr/local/bin/docker rm -f photoreq >/dev/null 2>&1 || true +/usr/local/bin/docker run -d \ + --name photoreq \ + --restart unless-stopped \ + -p 18082:8080 \ + --env-file "$APP_DIR/.env" \ + -e PUBLIC_BASE_URL="https://photoreq.mokaya.org" \ + -e MAX_FIELDS_SIZE_MB="2" \ + -v "$APP_DIR/data:/data" \ + -v "$UPLOAD_DIR:/uploads" \ + photoreq:latest diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..416bb3c --- /dev/null +++ b/src/server.js @@ -0,0 +1,334 @@ +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까지 업로드할 수 있어.

+
+
+ + +

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

+
+
+
+
+ `)); +}); + +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}`); +}); diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..21004ea --- /dev/null +++ b/stop.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +/usr/local/bin/docker rm -f photoreq >/dev/null 2>&1 || true