Compare commits

...
2 Commits
Author SHA1 Message Date
Atakan Doğan Özban 75ab4396d4 Merge Gitea main with local security hardening changes. 2026-07-12 02:06:02 +02:00
Atakan Doğan Özban d74c422de6 Add admin panel, gallery, and security hardening.
Replace forgeable cookie auth with signed JWT sessions, protect admin APIs, add input validation, and improve Docker deployment config.
2026-07-12 00:34:46 +02:00
22 changed files with 452 additions and 473 deletions
+5
View File
@@ -5,3 +5,8 @@ npm-debug.log
README.md README.md
.next .next
.git .git
.env*
*.db
dev.db
prisma/*.db
build_log.txt
+5
View File
@@ -33,6 +33,11 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# database
*.db
dev.db
prisma/*.db
# vercel # vercel
.vercel .vercel
+16 -3
View File
@@ -1,4 +1,5 @@
FROM node:20-bullseye-slim AS base FROM node:20-bullseye-slim AS base
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
@@ -10,6 +11,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG GOOGLE_MAPS_API_KEY
ENV GOOGLE_MAPS_API_KEY=$GOOGLE_MAPS_API_KEY
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN npm run build
@@ -20,13 +24,22 @@ ENV NODE_ENV production
ENV PORT 3000 ENV PORT 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME "0.0.0.0"
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package.json /app/dev.d[b] ./ COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "-c", "mkdir -p /app/data && DATABASE_URL=file:/app/data/prod.db npx prisma@6 db push && node server.js"] CMD ["sh", "-c", "npx prisma@5.22.0 db push && node server.js"]
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -3
View File
@@ -12,8 +12,7 @@ services:
- GOOGLE_MAPS_API_KEY=maps_api_key - GOOGLE_MAPS_API_KEY=maps_api_key
- ADMIN_USERNAME=admin - ADMIN_USERNAME=admin
- ADMIN_PASSWORD=testpass - ADMIN_PASSWORD=testpass
- GOOGLE_CLIENT_ID=google_id - SESSION_SECRET=your-64-char-random-secret-here
- GOOGLE_CLIENT_SECRET=google_secret
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
+24 -2
View File
@@ -1,7 +1,29 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
}; };
export default nextConfig; export default nextConfig;
+64 -375
View File
@@ -14,19 +14,21 @@
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"googleapis": "^171.4.0", "googleapis": "^171.4.0",
"jose": "^6.2.3",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@prisma/client": "^6.4.0", "@prisma/client": "^5.22.0",
"@types/google.maps": "^3.58.1", "@types/google.maps": "^3.58.1",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"prisma": "^6.4.0", "prisma": "^5.22.0",
"typescript": "^5" "typescript": "^5"
} }
}, },
@@ -1280,89 +1282,72 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.19.3", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=18.18" "node": ">=16.13"
}, },
"peerDependencies": { "peerDependencies": {
"prisma": "*", "prisma": "*"
"typescript": ">=5.1.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"prisma": { "prisma": {
"optional": true "optional": true
},
"typescript": {
"optional": true
} }
} }
}, },
"node_modules/@prisma/config": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.21.0",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "6.19.3", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.19.3", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.19.3", "@prisma/debug": "5.22.0",
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "6.19.3", "@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "6.19.3" "@prisma/get-platform": "5.22.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.19.3", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.19.3", "@prisma/debug": "5.22.0",
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "6.19.3" "@prisma/get-platform": "5.22.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "6.19.3", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.19.3" "@prisma/debug": "5.22.0"
} }
}, },
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
@@ -1372,13 +1357,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2472,35 +2450,6 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"confbox": "^0.2.2",
"defu": "^6.1.4",
"dotenv": "^16.6.1",
"exsolve": "^1.0.7",
"giget": "^2.0.0",
"jiti": "^2.4.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^1.0.0",
"pkg-types": "^2.2.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "^0.3.5"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -2596,32 +2545,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2653,23 +2576,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2792,16 +2698,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/define-data-property": { "node_modules/define-data-property": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -2838,20 +2734,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2875,19 +2757,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2917,17 +2786,6 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/effect": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.302", "version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
@@ -2941,16 +2799,6 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.1", "version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -3580,42 +3428,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/exsolve": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"dev": true,
"license": "MIT"
},
"node_modules/extend": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^6.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3817,6 +3635,21 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3974,24 +3807,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
} }
}, },
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.6.0",
"pathe": "^2.0.3"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -4817,15 +4632,13 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/jiti": { "node_modules/jose": {
"version": "2.7.0", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "funding": {
"bin": { "url": "https://github.com/sponsors/panva"
"jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-tokens": { "node_modules/js-tokens": {
@@ -5259,13 +5072,6 @@
"url": "https://opencollective.com/node-fetch" "url": "https://opencollective.com/node-fetch"
} }
}, },
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -5273,31 +5079,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nypm": {
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
"integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.2",
"pathe": "^2.0.3",
"tinyexec": "^1.2.4"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": ">=18"
}
},
"node_modules/nypm/node_modules/citty": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5420,13 +5201,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"dev": true,
"license": "MIT"
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5562,20 +5336,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5595,18 +5355,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5656,30 +5404,24 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.19.3", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "6.19.3", "@prisma/engines": "5.22.0"
"@prisma/engines": "6.19.3"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"
}, },
"engines": { "engines": {
"node": ">=18.18" "node": ">=16.13"
}, },
"peerDependencies": { "optionalDependencies": {
"typescript": ">=5.1.0" "fsevents": "2.3.3"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
} }
}, },
"node_modules/prop-types": { "node_modules/prop-types": {
@@ -5704,23 +5446,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -5757,17 +5482,6 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
@@ -5798,20 +5512,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6570,16 +6270,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -7157,10 +6847,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {
+5 -3
View File
@@ -15,19 +15,21 @@
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"googleapis": "^171.4.0", "googleapis": "^171.4.0",
"jose": "^6.2.3",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@prisma/client": "^6.4.0", "@prisma/client": "^5.22.0",
"@types/google.maps": "^3.58.1", "@types/google.maps": "^3.58.1",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"prisma": "^6.4.0", "prisma": "^5.22.0",
"typescript": "^5" "typescript": "^5"
} }
} }
BIN
View File
Binary file not shown.
+32 -10
View File
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/auth";
import { parseLocationBody } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -7,34 +9,51 @@ export async function PUT(
request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try { try {
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const parsed = parseLocationBody(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const data = parsed.data;
const location = await prisma.location.update({ const location = await prisma.location.update({
where: { id }, where: { id },
// @ts-ignore
data: { data: {
title: body.title, title: data.title,
city: body.city, city: data.city,
gpxData: body.gpxData, gpxData: data.gpxData ?? null,
tags: body.tags, tags: data.tags,
captureDate: new Date(body.captureDate), captureDate: new Date(data.captureDate),
visibility: body.visibility, visibility: data.visibility,
}, },
}); });
return NextResponse.json(location); return NextResponse.json(location);
} catch (error) { } catch (error) {
console.error("Error updating location:", error); console.error("Error updating location:", error);
return NextResponse.json({ error: "Failed to update location" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to update location" },
{ status: 500 }
);
} }
} }
export async function DELETE( export async function DELETE(
request: Request, _request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try { try {
const { id } = await params; const { id } = await params;
@@ -45,6 +64,9 @@ export async function DELETE(
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error deleting location:", error); console.error("Error deleting location:", error);
return NextResponse.json({ error: "Failed to delete location" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to delete location" },
{ status: 500 }
);
} }
} }
+32 -10
View File
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/auth";
import { parseLocationBody } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try { try {
const locations = await prisma.location.findMany({ const locations = await prisma.location.findMany({
orderBy: { orderBy: {
@@ -14,29 +19,46 @@ export async function GET() {
return NextResponse.json(locations); return NextResponse.json(locations);
} catch (error) { } catch (error) {
console.error("Error fetching all locations:", error); console.error("Error fetching all locations:", error);
return NextResponse.json({ error: "Failed to fetch locations" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to fetch locations" },
{ status: 500 }
);
} }
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try { try {
const body = await request.json(); const body = await request.json();
const parsed = parseLocationBody(body);
// Convert strings to float for lat/lng, or they might already be numbers if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const data = parsed.data;
const location = await prisma.location.create({ const location = await prisma.location.create({
data: { data: {
title: body.title, title: data.title,
city: body.city, city: data.city,
gpxData: body.gpxData, gpxData: data.gpxData ?? null,
tags: body.tags, tags: data.tags,
captureDate: new Date(body.captureDate), captureDate: new Date(data.captureDate),
visibility: body.visibility || "public", visibility: data.visibility,
}, },
}); });
return NextResponse.json(location); return NextResponse.json(location);
} catch (error: any) { } catch (error) {
console.error("Error creating location:", error); console.error("Error creating location:", error);
return NextResponse.json({ error: error instanceof Error ? error.message : "Failed to create location" }, { status: 500 }); return NextResponse.json(
{ error: "Failed to create location" },
{ status: 500 }
);
} }
} }
+48 -23
View File
@@ -1,39 +1,64 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import {
createSessionToken,
getSessionCookieOptions,
} from "@/lib/session";
import { safeCompare } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rate-limit";
function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return request.headers.get("x-real-ip") ?? "unknown";
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
// const { password } = await request.json(); const ip = getClientIp(request);
if (!checkRateLimit(`login:${ip}`)) {
return NextResponse.json(
{ error: "Too many login attempts. Try again later." },
{ status: 429 }
);
}
const { username, password } = await request.json(); const { username, password } = await request.json();
const correctPassword = process.env.ADMIN_PASSWORD; const correctPassword = process.env.ADMIN_PASSWORD;
const correctUsername = process.env.ADMIN_USERNAME || "admin"; const correctUsername = process.env.ADMIN_USERNAME;
if (!correctPassword) { if (!correctPassword || !correctUsername) {
console.error("ADMIN_PASSWORD environment variable is not set."); console.error(
return NextResponse.json({ error: "Server Configuration Error" }, { status: 500 }); "ADMIN_PASSWORD and ADMIN_USERNAME environment variables must be set."
);
return NextResponse.json(
{ error: "Server Configuration Error" },
{ status: 500 }
);
} }
// if (password === correctPassword) { if (
if (username === correctUsername && password === correctPassword) { typeof username !== "string" ||
const response = NextResponse.json({ success: true }); typeof password !== "string" ||
!safeCompare(username, correctUsername) ||
// Set an HTTP-only cookie that expires in 30 days !safeCompare(password, correctPassword)
response.cookies.set({ ) {
name: 'admin_session', return NextResponse.json(
value: 'authenticated', { error: "Invalid credentials." },
httpOnly: true, { status: 401 }
secure: process.env.NODE_ENV === 'production', );
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return response;
} }
// return NextResponse.json({ error: "Invalid password." }, { status: 401 }); const token = await createSessionToken();
return NextResponse.json({ error: "Invalid credentials." }, { status: 401 }); const response = NextResponse.json({ success: true });
response.cookies.set({
...getSessionCookieOptions(),
value: token,
});
return response;
} catch (error) { } catch (error) {
console.error("Login error:", error);
return NextResponse.json({ error: "Invalid request." }, { status: 400 }); return NextResponse.json({ error: "Invalid request." }, { status: 400 });
} }
} }
+6 -2
View File
@@ -1,10 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSessionCookieOptions, SESSION_COOKIE } from "@/lib/session";
export async function POST() { export async function POST() {
const response = NextResponse.json({ success: true }); const response = NextResponse.json({ success: true });
// Hard delete the cookie response.cookies.set({
response.cookies.delete('admin_session'); ...getSessionCookieOptions(0),
name: SESSION_COOKIE,
value: "",
});
return response; return response;
} }
+6 -4
View File
@@ -1,7 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({
apiKey: process.env.GOOGLE_MAPS_API_KEY || "" apiKey: process.env.GOOGLE_MAPS_API_KEY || "",
}); });
} }
+7 -1
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Location } from "@prisma/client"; import { Location } from "@prisma/client";
import { setOptions, importLibrary } from "@googlemaps/js-api-loader"; import { MAX_GPX_BYTES } from "@/lib/validation";
export default function AdminLocationForm({ export default function AdminLocationForm({
locationId, locationId,
@@ -62,6 +62,12 @@ export default function AdminLocationForm({
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (file.size > MAX_GPX_BYTES) {
alert(`GPX file must be under ${MAX_GPX_BYTES / (1024 * 1024)} MB`);
e.target.value = "";
return;
}
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const text = event.target?.result as string; const text = event.target?.result as string;
+52 -14
View File
@@ -17,7 +17,6 @@ export default function Gallery() {
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const [totalMiles, setTotalMiles] = useState<number>(0); const [totalMiles, setTotalMiles] = useState<number>(0);
const [geometryLoaded, setGeometryLoaded] = useState(false); const [geometryLoaded, setGeometryLoaded] = useState(false);
const [runtimeApiKey, setRuntimeApiKey] = useState<string>(""); const [runtimeApiKey, setRuntimeApiKey] = useState<string>("");
const locationsRef = useRef<Location[]>([]); const locationsRef = useRef<Location[]>([]);
@@ -30,6 +29,7 @@ export default function Gallery() {
const svServiceRef = useRef<google.maps.StreetViewService | null>(null); const svServiceRef = useRef<google.maps.StreetViewService | null>(null);
const activePolylinesRef = useRef<{ id: string; polyline: google.maps.Polyline; bounds: google.maps.LatLngBounds; firstCoord: google.maps.LatLngLiteral }[]>([]); const activePolylinesRef = useRef<{ id: string; polyline: google.maps.Polyline; bounds: google.maps.LatLngBounds; firstCoord: google.maps.LatLngLiteral }[]>([]);
// Fetch runtime API key from server (GOOGLE_MAPS_API_KEY env var)
useEffect(() => { useEffect(() => {
fetch("/api/config") fetch("/api/config")
.then((res) => res.json()) .then((res) => res.json())
@@ -39,6 +39,7 @@ export default function Gallery() {
.catch((err) => console.error("Error fetching config:", err)); .catch((err) => console.error("Error fetching config:", err));
}, []); }, []);
// Fetch locations
useEffect(() => { useEffect(() => {
const queryParams = new URLSearchParams(window.location.search); const queryParams = new URLSearchParams(window.location.search);
const initialLocId = queryParams.get("locationId"); const initialLocId = queryParams.get("locationId");
@@ -61,6 +62,7 @@ export default function Gallery() {
.catch((err) => console.error("Error fetching locations:", err)); .catch((err) => console.error("Error fetching locations:", err));
}, []); }, []);
// Filter locations
useEffect(() => { useEffect(() => {
const term = search.toLowerCase(); const term = search.toLowerCase();
setFilteredLocations( setFilteredLocations(
@@ -70,8 +72,10 @@ export default function Gallery() {
); );
}, [search, locations]); }, [search, locations]);
// Load Google Maps API & Initialize
useEffect(() => { useEffect(() => {
if (!runtimeApiKey) return; if (!runtimeApiKey) return;
setOptions({ setOptions({
key: runtimeApiKey, key: runtimeApiKey,
v: "weekly", v: "weekly",
@@ -81,23 +85,26 @@ export default function Gallery() {
const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary; const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary;
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary; const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary;
const { StreetViewPanorama } = await google.maps.importLibrary("streetView") as google.maps.StreetViewLibrary; const { StreetViewPanorama } = await google.maps.importLibrary("streetView") as google.maps.StreetViewLibrary;
await google.maps.importLibrary("geometry"); await google.maps.importLibrary("geometry"); // For distance calculations
setGeometryLoaded(true); setGeometryLoaded(true);
// Initialize Street View Service
svServiceRef.current = new google.maps.StreetViewService(); svServiceRef.current = new google.maps.StreetViewService();
// Initialize Map
googleMapObj.current = new Map(mapRef.current as HTMLElement, { googleMapObj.current = new Map(mapRef.current as HTMLElement, {
center: { lat: 20, lng: 0 }, center: { lat: 20, lng: 0 },
zoom: 2, zoom: 2,
mapId: "STREET_VIEW_GALLERY_MAP", mapId: "STREET_VIEW_GALLERY_MAP",
}); });
// Initialize Mini Map
miniMapObj.current = new Map(miniMapRef.current as HTMLElement, { miniMapObj.current = new Map(miniMapRef.current as HTMLElement, {
center: { lat: 20, lng: 0 }, center: { lat: 20, lng: 0 },
zoom: 14, zoom: 14,
mapId: "STREET_VIEW_MINI_MAP", mapId: "STREET_VIEW_MINI_MAP",
disableDefaultUI: true, disableDefaultUI: true, // This hides everything (pegman, map types, etc.)
zoomControl: false, zoomControl: false,
mapTypeControl: false, mapTypeControl: false,
streetViewControl: false, streetViewControl: false,
@@ -111,15 +118,19 @@ export default function Gallery() {
position: { lat: 0, lng: 0 }, position: { lat: 0, lng: 0 },
}); });
// Intercept Pegman Drops
const defaultStreetView = googleMapObj.current.getStreetView(); const defaultStreetView = googleMapObj.current.getStreetView();
// We must hide the close button since we are intercepting anyway, but just in case
defaultStreetView.setOptions({ enableCloseButton: false }); defaultStreetView.setOptions({ enableCloseButton: false });
defaultStreetView.addListener("visible_changed", () => { defaultStreetView.addListener("visible_changed", () => {
if (defaultStreetView.getVisible()) { if (defaultStreetView.getVisible()) {
const pos = defaultStreetView.getPosition(); const pos = defaultStreetView.getPosition();
// Intercept the drop: immediately hide the buggy WebGL canvas
defaultStreetView.setVisible(false); defaultStreetView.setVisible(false);
if (pos) { if (pos) {
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${pos.lat()},${pos.lng()}&heading=0&pitch=0&fov=90`); setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${pos.lat()},${pos.lng()}&heading=0&pitch=0&fov=90`);
setViewingStreetView(true); setViewingStreetView(true);
@@ -133,8 +144,9 @@ export default function Gallery() {
}); });
}, [runtimeApiKey]); }, [runtimeApiKey]);
// Draw all GPX routes for filtered locations
useEffect(() => { useEffect(() => {
if (!googleMapObj.current || typeof window === 'undefined' || !window.google || !geometryLoaded || !runtimeApiKey) return; if (!googleMapObj.current || typeof window === 'undefined' || !window.google || !geometryLoaded) return;
// Clear existing routes // Clear existing routes
activePolylinesRef.current.forEach((p) => p.polyline.setMap(null)); activePolylinesRef.current.forEach((p) => p.polyline.setMap(null));
@@ -168,6 +180,7 @@ export default function Gallery() {
}); });
if (coords.length > 0) { if (coords.length > 0) {
// Calculate distance
calculatedMeters += google.maps.geometry.spherical.computeLength(coords); calculatedMeters += google.maps.geometry.spherical.computeLength(coords);
const polyline = new google.maps.Polyline({ const polyline = new google.maps.Polyline({
@@ -177,9 +190,10 @@ export default function Gallery() {
strokeWeight: 4, strokeWeight: 4,
clickable: true, clickable: true,
map: googleMapObj.current, map: googleMapObj.current,
zIndex: selectedLocation?.id === loc.id ? 10 : 1, zIndex: selectedLocation?.id === loc.id ? 10 : 1, // Elevate selected outline
}); });
// Also draw on mini map
const miniPolyline = new google.maps.Polyline({ const miniPolyline = new google.maps.Polyline({
path: coords, path: coords,
strokeColor: "#0000FF", strokeColor: "#0000FF",
@@ -202,10 +216,11 @@ export default function Gallery() {
bounds: locBounds, bounds: locBounds,
firstCoord: coords[0] firstCoord: coords[0]
}); });
// Add Google Street View click detection (Main Map)
polyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => { polyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => {
if (!clickEvent.latLng) return; if (!clickEvent.latLng) return;
const clickPos = clickEvent.latLng; const clickPos = clickEvent.latLng;
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`); setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`);
setViewingStreetView(true); setViewingStreetView(true);
@@ -215,9 +230,11 @@ export default function Gallery() {
} }
}); });
// Add Google Street View click detection (Mini Map)
miniPolyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => { miniPolyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => {
if (!clickEvent.latLng) return; if (!clickEvent.latLng) return;
const clickPos = clickEvent.latLng; const clickPos = clickEvent.latLng;
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`); setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`);
setViewingStreetView(true); setViewingStreetView(true);
@@ -227,11 +244,14 @@ export default function Gallery() {
} }
}); });
// Generate Static Map Thumbnail URL
try { try {
const maxThumbnailPoints = 150; const maxThumbnailPoints = 150;
const step = Math.max(1, Math.floor(coords.length / maxThumbnailPoints)); const step = Math.max(1, Math.floor(coords.length / maxThumbnailPoints));
const simplifiedCoords = coords.filter((_, i) => i % step === 0); const simplifiedCoords = coords.filter((_, i) => i % step === 0);
const encodedPath = google.maps.geometry.encoding.encodePath(simplifiedCoords); const encodedPath = google.maps.geometry.encoding.encodePath(simplifiedCoords);
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
// Requesting a high-res (scale=2) map, and a slightly taller size so the Google logo sits proportionally smaller at the bottom right.
newThumbnails[loc.id] = `https://maps.googleapis.com/maps/api/staticmap?size=600x250&scale=2&path=weight:3%7Ccolor:blue%7Cenc:${encodedPath}&key=${runtimeApiKey}`; newThumbnails[loc.id] = `https://maps.googleapis.com/maps/api/staticmap?size=600x250&scale=2&path=weight:3%7Ccolor:blue%7Cenc:${encodedPath}&key=${runtimeApiKey}`;
} catch (err) { } catch (err) {
console.error("Failed to generate static map thumbnail", err); console.error("Failed to generate static map thumbnail", err);
@@ -240,14 +260,18 @@ export default function Gallery() {
}); });
setThumbnails(newThumbnails); setThumbnails(newThumbnails);
// Update stats
setTotalMiles(calculatedMeters * 0.000621371); setTotalMiles(calculatedMeters * 0.000621371);
// Fit bounds for all visible filtered locations
if (hasPoints) { if (hasPoints) {
googleMapObj.current.fitBounds(bounds); googleMapObj.current.fitBounds(bounds);
} }
}, [filteredLocations, geometryLoaded, selectedLocation, runtimeApiKey]); }, [filteredLocations, geometryLoaded, selectedLocation]); // Note: dependency on selectedLocation is REMOVED to prevent redrawing all routes!
// Handle Selection Highlights and panning smoothly
useEffect(() => { useEffect(() => {
if (!googleMapObj.current || !geometryLoaded) return; if (!googleMapObj.current || !geometryLoaded) return;
@@ -256,6 +280,7 @@ export default function Gallery() {
activePolylinesRef.current.forEach((item) => { activePolylinesRef.current.forEach((item) => {
if (selectedLocation?.id === item.id) { if (selectedLocation?.id === item.id) {
item.polyline.setOptions({ strokeColor: "#00FF00", strokeOpacity: 1.0, strokeWeight: 6, zIndex: 10 }); item.polyline.setOptions({ strokeColor: "#00FF00", strokeOpacity: 1.0, strokeWeight: 6, zIndex: 10 });
// Smoothly fit bounds exactly like the initial filtered view
if (!didPan) { if (!didPan) {
googleMapObj.current!.fitBounds(item.bounds); googleMapObj.current!.fitBounds(item.bounds);
didPan = true; didPan = true;
@@ -266,6 +291,7 @@ export default function Gallery() {
}); });
}, [selectedLocation, geometryLoaded]); }, [selectedLocation, geometryLoaded]);
// Update URL when selection changes
useEffect(() => { useEffect(() => {
if (!selectedLocation) return; if (!selectedLocation) return;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -275,14 +301,18 @@ export default function Gallery() {
} }
}, [selectedLocation]); }, [selectedLocation]);
const closeMobileSidebar = () => { const closeMobileSidebar = () => {
setIsMobileSidebarOpen(false); setIsMobileSidebarOpen(false);
}; };
const handleLocationSelect = (loc: Location) => { const handleLocationSelect = (loc: Location) => {
// Switch to map view to show route, not street view directly
setViewingStreetView(false); setViewingStreetView(false);
setIframeUrl(null); setIframeUrl(null);
// If clicking the currently selected location, force a map pan/zoom because React state won't trigger the effect
if (selectedLocation?.id === loc.id) { if (selectedLocation?.id === loc.id) {
const item = activePolylinesRef.current.find(p => p.id === loc.id); const item = activePolylinesRef.current.find(p => p.id === loc.id);
if (item && googleMapObj.current) { if (item && googleMapObj.current) {
@@ -291,6 +321,8 @@ export default function Gallery() {
} else { } else {
setSelectedLocation(loc); setSelectedLocation(loc);
} }
// Auto-close standard Bootstrap offcanvas on mobile natively
closeMobileSidebar(); closeMobileSidebar();
}; };
@@ -348,8 +380,9 @@ export default function Gallery() {
onClick={() => handleLocationSelect(loc)} onClick={() => handleLocationSelect(loc)}
style={{ cursor: "pointer", overflow: "hidden" }} style={{ cursor: "pointer", overflow: "hidden" }}
> >
{/* mobile layout */} {/* --- MOBILE LAYOUT (Horizontal) --- */}
<div className="row g-0 h-100 d-md-none"> <div className="row g-0 h-100 d-md-none">
{/* Thumbnail Map (Left) */}
<div className="col-5 bg-dark border-end border-secondary position-relative" style={{ minHeight: "105px", overflow: "hidden" }}> <div className="col-5 bg-dark border-end border-secondary position-relative" style={{ minHeight: "105px", overflow: "hidden" }}>
{thumbnails[loc.id] ? ( {thumbnails[loc.id] ? (
<img <img
@@ -363,7 +396,7 @@ export default function Gallery() {
height: "100%", height: "100%",
objectFit: "cover", objectFit: "cover",
objectPosition: "center", objectPosition: "center",
transform: "scale(1.2) translateY(5%)", transform: "scale(1.2) translateY(5%)", // Zoom in slightly and push down to crop out the bottom Google/Map Data logos
transformOrigin: "center bottom" transformOrigin: "center bottom"
}} }}
/> />
@@ -373,6 +406,7 @@ export default function Gallery() {
</div> </div>
)} )}
</div> </div>
{/* Content (Right) */}
<div className="col-7 bg-dark d-flex flex-column justify-content-center p-2 text-white"> <div className="col-7 bg-dark d-flex flex-column justify-content-center p-2 text-white">
<h6 className="card-title mb-1 text-truncate gsv-title" style={{ fontSize: "0.9rem" }} title={loc.title}>{loc.title}</h6> <h6 className="card-title mb-1 text-truncate gsv-title" style={{ fontSize: "0.9rem" }} title={loc.title}>{loc.title}</h6>
<p className="card-text mb-1 text-truncate gsv-subtitle" style={{ fontSize: "0.75rem", lineHeight: "1.2" }}> <p className="card-text mb-1 text-truncate gsv-subtitle" style={{ fontSize: "0.75rem", lineHeight: "1.2" }}>
@@ -389,8 +423,9 @@ export default function Gallery() {
</div> </div>
</div> </div>
{/* desktop layout */} {/* --- DESKTOP LAYOUT (Vertical/Original) --- */}
<div className="d-none d-md-block"> <div className="d-none d-md-block">
{/* Thumbnail Map (Top) */}
<div className="card-img-top bg-dark border-bottom border-secondary d-flex align-items-center justify-content-center" style={{ width: "100%", height: "140px", flexShrink: 0, overflow: "hidden", position: "relative" }}> <div className="card-img-top bg-dark border-bottom border-secondary d-flex align-items-center justify-content-center" style={{ width: "100%", height: "140px", flexShrink: 0, overflow: "hidden", position: "relative" }}>
{thumbnails[loc.id] ? ( {thumbnails[loc.id] ? (
<img <img
@@ -409,6 +444,7 @@ export default function Gallery() {
<span className="text-secondary small">Loading Map...</span> <span className="text-secondary small">Loading Map...</span>
)} )}
</div> </div>
{/* Content (Bottom) */}
<div className="card-body p-2 bg-dark text-white"> <div className="card-body p-2 bg-dark text-white">
<h6 className="card-title mb-1 text-truncate gsv-title" title={loc.title}>{loc.title}</h6> <h6 className="card-title mb-1 text-truncate gsv-title" title={loc.title}>{loc.title}</h6>
<p className="card-text small mb-1 text-truncate gsv-subtitle"> <p className="card-text small mb-1 text-truncate gsv-subtitle">
@@ -430,6 +466,7 @@ export default function Gallery() {
<div className="text-center text-muted mt-4 p-3 bg-dark rounded">No locations found.</div> <div className="text-center text-muted mt-4 p-3 bg-dark rounded">No locations found.</div>
)} )}
{/* Oldest date indicator at the bottom */}
{locations.length > 0 && ( {locations.length > 0 && (
<div className="text-secondary mt-3 mb-2" style={{ fontSize: "0.75rem", textAlign: "center", textTransform: "uppercase", letterSpacing: "1px" }}> <div className="text-secondary mt-3 mb-2" style={{ fontSize: "0.75rem", textAlign: "center", textTransform: "uppercase", letterSpacing: "1px" }}>
Images dating back to { Images dating back to {
@@ -440,11 +477,11 @@ export default function Gallery() {
</div> </div>
</div> </div>
</div> </div>
{/* Main Panel (Map or Street View) */}
{/* Main Panel */}
<div className="col-12 col-md-9 position-relative bg-black d-flex flex-column p-0"> <div className="col-12 col-md-9 position-relative bg-black d-flex flex-column p-0">
<div ref={mapRef} style={{ width: "100%", height: "100%", position: "absolute", zIndex: 0 }} /> <div ref={mapRef} style={{ width: "100%", height: "100%", position: "absolute", zIndex: 0 }} />
{/* Overlay controls and mini-map, only shown when in Street View mode */}
{viewingStreetView && iframeUrl && ( {viewingStreetView && iframeUrl && (
<> <>
<div <div
@@ -475,7 +512,7 @@ export default function Gallery() {
setIframeUrl(null); setIframeUrl(null);
}} }}
> >
<span></span> Back to Map <span>&larr;</span> Back to Map
</button> </button>
</> </>
)} )}
@@ -496,6 +533,7 @@ export default function Gallery() {
</div> </div>
</div> </div>
{/* Manual React Backdrop for Mobile Sidebar */}
{isMobileSidebarOpen && ( {isMobileSidebarOpen && (
<div <div
className="offcanvas-backdrop fade show d-md-none" className="offcanvas-backdrop fade show d-md-none"
@@ -504,4 +542,4 @@ export default function Gallery() {
)} )}
</div> </div>
); );
} }
+27
View File
@@ -0,0 +1,27 @@
import { timingSafeEqual } from "crypto";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { SESSION_COOKIE, verifySessionToken } from "@/lib/session";
export function safeCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
export async function requireAdmin(): Promise<
{ authorized: true } | { authorized: false; response: NextResponse }
> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token || !(await verifySessionToken(token))) {
return {
authorized: false,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
return { authorized: true };
}
+31
View File
@@ -0,0 +1,31 @@
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ENTRIES = 10_000;
function pruneExpired(now: number) {
if (attempts.size <= MAX_ENTRIES) return;
for (const [key, entry] of attempts) {
if (now > entry.resetAt) attempts.delete(key);
}
}
export function checkRateLimit(
key: string,
maxAttempts = 5,
windowMs = 15 * 60 * 1000
): boolean {
const now = Date.now();
pruneExpired(now);
const entry = attempts.get(key);
if (!entry || now > entry.resetAt) {
attempts.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (entry.count >= maxAttempts) return false;
entry.count++;
return true;
}
+40
View File
@@ -0,0 +1,40 @@
import { SignJWT, jwtVerify } from "jose";
export const SESSION_COOKIE = "admin_session";
export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
function getSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 32) {
throw new Error("SESSION_SECRET must be set and at least 32 characters");
}
return new TextEncoder().encode(secret);
}
export function getSessionCookieOptions(maxAge = SESSION_MAX_AGE) {
return {
name: SESSION_COOKIE,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge,
path: "/",
};
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({ role: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${SESSION_MAX_AGE}s`)
.sign(getSecret());
}
export async function verifySessionToken(token: string): Promise<boolean> {
try {
const { payload } = await jwtVerify(token, getSecret());
return payload.role === "admin";
} catch {
return false;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
export const MAX_GPX_BYTES = 5 * 1024 * 1024; // 5 MB
export const locationSchema = z.object({
title: z.string().trim().min(1, "Title is required").max(200),
city: z.string().trim().min(1, "City is required").max(200),
tags: z.string().max(500).default(""),
captureDate: z
.string()
.refine((d) => !isNaN(Date.parse(d)), "Invalid capture date"),
visibility: z.enum(["public", "unlisted"]).default("public"),
gpxData: z
.string()
.max(MAX_GPX_BYTES, `GPX data must be under ${MAX_GPX_BYTES / (1024 * 1024)} MB`)
.optional()
.nullable(),
});
export type LocationInput = z.infer<typeof locationSchema>;
export function parseLocationBody(body: unknown) {
return locationSchema.safeParse(body);
}
+26 -23
View File
@@ -1,33 +1,36 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import type { NextRequest } from 'next/server'; import type { NextRequest } from "next/server";
import { verifySessionToken } from "@/lib/session";
export function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const isAuthRoute = request.nextUrl.pathname.startsWith('/login') || request.nextUrl.pathname.startsWith('/api/auth'); const isAdminRoute =
const isAdminRoute = request.nextUrl.pathname.startsWith('/admin') || request.nextUrl.pathname.startsWith('/api/admin'); request.nextUrl.pathname.startsWith("/admin") ||
request.nextUrl.pathname.startsWith("/api/admin");
// If trying to access admin routes, verify the session cookie
if (isAdminRoute) { if (isAdminRoute) {
const adminSession = request.cookies.get('admin_session'); const adminSession = request.cookies.get("admin_session");
const isValid =
adminSession?.value &&
(await verifySessionToken(adminSession.value));
// Allow GET /api/admin/locations (used by the public gallery) if (!isValid) {
if (request.nextUrl.pathname === '/api/admin/locations' && request.method === 'GET') { if (request.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.next(); return NextResponse.json(
} { error: "Unauthorized" },
{ status: 401 }
// Redirect to login if no session cookie exists );
if (!adminSession?.value) {
if (request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
return NextResponse.redirect(new URL('/login', request.url)); return NextResponse.redirect(new URL("/login", request.url));
} }
} }
// If trying to access login page but already authenticated, redirect to admin if (request.nextUrl.pathname === "/login") {
if (request.nextUrl.pathname === '/login') { const adminSession = request.cookies.get("admin_session");
const adminSession = request.cookies.get('admin_session'); if (
if (adminSession?.value) { adminSession?.value &&
return NextResponse.redirect(new URL('/admin', request.url)); (await verifySessionToken(adminSession.value))
) {
return NextResponse.redirect(new URL("/admin", request.url));
} }
} }
@@ -35,5 +38,5 @@ export function middleware(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ['/admin/:path*', '/api/admin/:path*', '/login'], matcher: ["/admin/:path*", "/api/admin/:path*", "/login"],
}; };