From 84dad51bce34d385bf4d57c80d5e45618166b85b Mon Sep 17 00:00:00 2001 From: "DESKTOP-KSVGT20\\shkim" Date: Sun, 17 May 2026 10:54:19 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20IP=20=ED=97=88=EC=9A=A9=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + run.sh | 1 + src/server.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/README.md b/README.md index 961084e..815b55a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The server cron runs this once per minute. - DB: `data/db.json` - Deploy log: `data/deploy.log` - Uploads: `/volume2/AKA Drive/사진 받기` +- Admin allowlist: `ADMIN_ALLOWED_IPS` comma-separated IPs, default `112.159.64.27,192.168.219.100` - Upload page template: `src/public/upload.html` - Upload page style: `src/public/upload.css` - Upload page illustration: `src/public/wedding-illustration.png` diff --git a/run.sh b/run.sh index 174a19c..37dca40 100644 --- a/run.sh +++ b/run.sh @@ -11,6 +11,7 @@ UPLOAD_DIR="/volume2/AKA Drive/사진 받기" -p 18082:8080 \ --env-file "$APP_DIR/.env" \ -e PUBLIC_BASE_URL="https://photoreq.mokaya.org" \ + -e ADMIN_ALLOWED_IPS="${ADMIN_ALLOWED_IPS:-112.159.64.27,192.168.219.100}" \ -e MAX_FIELDS_SIZE_MB="2" \ -v "$APP_DIR/data:/data" \ -v "$UPLOAD_DIR:/uploads" \ diff --git a/src/server.js b/src/server.js index b0b90aa..ceb134e 100644 --- a/src/server.js +++ b/src/server.js @@ -15,6 +15,7 @@ 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(/\/$/, ""); +const adminAllowedIps = parseIpList(process.env.ADMIN_ALLOWED_IPS || "112.159.64.27,192.168.219.100"); if (!adminPassword || !sessionSecret) { throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required."); @@ -175,6 +176,51 @@ function wantsJson(req) { return String(req.get("accept") || "").includes("application/json"); } +function parseIpList(value) { + return String(value || "") + .split(/[\s,;]+/) + .map(normalizeIp) + .filter(Boolean); +} + +function normalizeIp(value) { + let ip = String(value || "").trim(); + if (!ip) return ""; + if (ip.startsWith("[") && ip.endsWith("]")) ip = ip.slice(1, -1); + if (ip.startsWith("::ffff:")) ip = ip.slice("::ffff:".length); + return ip; +} + +function clientIp(req) { + const forwarded = String(req.get("x-forwarded-for") || "") + .split(",") + .map(normalizeIp) + .filter(Boolean); + const closestPublicIp = forwarded.toReversed().find((ip) => !isPrivateIp(ip)) || ""; + return normalizeIp(closestPublicIp || forwarded[0] || req.get("x-real-ip") || req.ip || req.socket.remoteAddress || ""); +} + +function isPrivateIp(ip) { + if (ip === "localhost" || ip === "::1") return true; + if (ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd")) return true; + const parts = ip.split(".").map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + const [a, b] = parts; + return a === 10 + || a === 127 + || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && b === 168) + || (a === 169 && b === 254); +} + +function requireAdminIp(req, res, next) { + if (adminAllowedIps.includes(clientIp(req))) { + next(); + return; + } + res.status(404).send(page("페이지 없음", `

페이지를 찾을 수 없어

`)); +} + function uploadResultPage(request, { heading, message, isError = false }) { const slug = escapeHtml(request.slug); return ` @@ -320,6 +366,8 @@ app.get("/", (req, res) => { res.status(404).send(page("페이지 없음", `

페이지를 찾을 수 없어

`)); }); +app.use("/admin", requireAdminIp); + app.get("/admin/login", (req, res) => { res.send(page("관리자 로그인", `