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..2c0052a 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,53 @@ 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) {
+ const ip = clientIp(req);
+ if (adminAllowedIps.includes(ip)) {
+ next();
+ return;
+ }
+ console.warn(`blocked admin path from ${ip || "unknown"}: ${req.method} ${req.originalUrl}`);
+ res.status(404).send(page("페이지 없음", ``));
+}
+
function uploadResultPage(request, { heading, message, isError = false }) {
const slug = escapeHtml(request.slug);
return `
@@ -320,6 +368,8 @@ app.get("/", (req, res) => {
res.status(404).send(page("페이지 없음", ``));
});
+app.use("/admin", requireAdminIp);
+
app.get("/admin/login", (req, res) => {
res.send(page("관리자 로그인", `