Compare commits
2
Commits
3ed25c1352
...
75ab4396d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75ab4396d4 | ||
|
|
d74c422de6 |
@@ -5,3 +5,8 @@ npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
*.db
|
||||
dev.db
|
||||
prisma/*.db
|
||||
build_log.txt
|
||||
|
||||
@@ -33,6 +33,11 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# database
|
||||
*.db
|
||||
dev.db
|
||||
prisma/*.db
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
+16
-3
@@ -1,4 +1,5 @@
|
||||
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
|
||||
WORKDIR /app
|
||||
@@ -10,6 +11,9 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG GOOGLE_MAPS_API_KEY
|
||||
ENV GOOGLE_MAPS_API_KEY=$GOOGLE_MAPS_API_KEY
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
@@ -20,13 +24,22 @@ ENV NODE_ENV production
|
||||
ENV PORT 3000
|
||||
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/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
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
|
||||
|
||||
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"]
|
||||
Binary file not shown.
+2
-3
@@ -12,8 +12,7 @@ services:
|
||||
- GOOGLE_MAPS_API_KEY=maps_api_key
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=testpass
|
||||
- GOOGLE_CLIENT_ID=google_id
|
||||
- GOOGLE_CLIENT_SECRET=google_secret
|
||||
- SESSION_SECRET=your-64-char-random-secret-here
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
|
||||
+24
-2
@@ -1,7 +1,29 @@
|
||||
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 = {
|
||||
output: "standalone",
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+64
-375
@@ -14,19 +14,21 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bootstrap": "^5.3.8",
|
||||
"googleapis": "^171.4.0",
|
||||
"jose": "^6.2.3",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prisma/client": "^6.4.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"prisma": "^6.4.0",
|
||||
"prisma": "^5.22.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
@@ -1280,89 +1282,72 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||
"integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
"node": ">=16.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prisma": "*",
|
||||
"typescript": ">=5.1.0"
|
||||
"prisma": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"prisma": {
|
||||
"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": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3",
|
||||
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"@prisma/fetch-engine": "6.19.3",
|
||||
"@prisma/get-platform": "6.19.3"
|
||||
"@prisma/debug": "5.22.0",
|
||||
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||
"@prisma/fetch-engine": "5.22.0",
|
||||
"@prisma/get-platform": "5.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3",
|
||||
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"@prisma/get-platform": "6.19.3"
|
||||
"@prisma/debug": "5.22.0",
|
||||
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||
"@prisma/get-platform": "5.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3"
|
||||
"@prisma/debug": "5.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
@@ -1372,13 +1357,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -2472,35 +2450,6 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -2653,23 +2576,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2792,16 +2698,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2875,19 +2757,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2917,17 +2786,6 @@
|
||||
"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": {
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
@@ -2941,16 +2799,6 @@
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"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": {
|
||||
"version": "1.24.1",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||
@@ -3580,42 +3428,12 @@
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3817,6 +3635,21 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
@@ -4817,15 +4632,13 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
@@ -5259,13 +5072,6 @@
|
||||
"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": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -5273,31 +5079,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -5420,13 +5201,6 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -5562,20 +5336,6 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -5595,18 +5355,6 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -5656,30 +5404,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.3",
|
||||
"@prisma/engines": "6.19.3"
|
||||
"@prisma/engines": "5.22.0"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
"node": ">=16.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
@@ -5704,23 +5446,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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
@@ -5757,17 +5482,6 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
@@ -5798,20 +5512,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6570,16 +6270,6 @@
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -7157,10 +6847,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
|
||||
+5
-3
@@ -15,19 +15,21 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bootstrap": "^5.3.8",
|
||||
"googleapis": "^171.4.0",
|
||||
"jose": "^6.2.3",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prisma/client": "^6.4.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"prisma": "^6.4.0",
|
||||
"prisma": "^5.22.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { parseLocationBody } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -7,34 +9,51 @@ export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
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({
|
||||
where: { id },
|
||||
// @ts-ignore
|
||||
data: {
|
||||
title: body.title,
|
||||
city: body.city,
|
||||
gpxData: body.gpxData,
|
||||
tags: body.tags,
|
||||
captureDate: new Date(body.captureDate),
|
||||
visibility: body.visibility,
|
||||
title: data.title,
|
||||
city: data.city,
|
||||
gpxData: data.gpxData ?? null,
|
||||
tags: data.tags,
|
||||
captureDate: new Date(data.captureDate),
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(location);
|
||||
} catch (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(
|
||||
request: Request,
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -45,6 +64,9 @@ export async function DELETE(
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { parseLocationBody } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
try {
|
||||
const locations = await prisma.location.findMany({
|
||||
orderBy: {
|
||||
@@ -14,29 +19,46 @@ export async function GET() {
|
||||
return NextResponse.json(locations);
|
||||
} catch (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) {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
try {
|
||||
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({
|
||||
data: {
|
||||
title: body.title,
|
||||
city: body.city,
|
||||
gpxData: body.gpxData,
|
||||
tags: body.tags,
|
||||
captureDate: new Date(body.captureDate),
|
||||
visibility: body.visibility || "public",
|
||||
title: data.title,
|
||||
city: data.city,
|
||||
gpxData: data.gpxData ?? null,
|
||||
tags: data.tags,
|
||||
captureDate: new Date(data.captureDate),
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(location);
|
||||
} catch (error: any) {
|
||||
} catch (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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
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) {
|
||||
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 correctPassword = process.env.ADMIN_PASSWORD;
|
||||
const correctUsername = process.env.ADMIN_USERNAME || "admin";
|
||||
const correctUsername = process.env.ADMIN_USERNAME;
|
||||
|
||||
if (!correctPassword) {
|
||||
console.error("ADMIN_PASSWORD environment variable is not set.");
|
||||
return NextResponse.json({ error: "Server Configuration Error" }, { status: 500 });
|
||||
if (!correctPassword || !correctUsername) {
|
||||
console.error(
|
||||
"ADMIN_PASSWORD and ADMIN_USERNAME environment variables must be set."
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Server Configuration Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// if (password === correctPassword) {
|
||||
if (username === correctUsername && password === correctPassword) {
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
// Set an HTTP-only cookie that expires in 30 days
|
||||
response.cookies.set({
|
||||
name: 'admin_session',
|
||||
value: 'authenticated',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
if (
|
||||
typeof username !== "string" ||
|
||||
typeof password !== "string" ||
|
||||
!safeCompare(username, correctUsername) ||
|
||||
!safeCompare(password, correctPassword)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// return NextResponse.json({ error: "Invalid password." }, { status: 401 });
|
||||
return NextResponse.json({ error: "Invalid credentials." }, { status: 401 });
|
||||
const token = await createSessionToken();
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set({
|
||||
...getSessionCookieOptions(),
|
||||
value: token,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCookieOptions, SESSION_COOKIE } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
// Hard delete the cookie
|
||||
response.cookies.delete('admin_session');
|
||||
response.cookies.set({
|
||||
...getSessionCookieOptions(0),
|
||||
name: SESSION_COOKIE,
|
||||
value: "",
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
apiKey: process.env.GOOGLE_MAPS_API_KEY || ""
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
apiKey: process.env.GOOGLE_MAPS_API_KEY || "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Location } from "@prisma/client";
|
||||
import { setOptions, importLibrary } from "@googlemaps/js-api-loader";
|
||||
import { MAX_GPX_BYTES } from "@/lib/validation";
|
||||
|
||||
export default function AdminLocationForm({
|
||||
locationId,
|
||||
@@ -62,6 +62,12 @@ export default function AdminLocationForm({
|
||||
const file = e.target.files?.[0];
|
||||
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();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
|
||||
+52
-14
@@ -17,7 +17,6 @@ export default function Gallery() {
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [totalMiles, setTotalMiles] = useState<number>(0);
|
||||
const [geometryLoaded, setGeometryLoaded] = useState(false);
|
||||
|
||||
const [runtimeApiKey, setRuntimeApiKey] = useState<string>("");
|
||||
|
||||
const locationsRef = useRef<Location[]>([]);
|
||||
@@ -30,6 +29,7 @@ export default function Gallery() {
|
||||
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 }[]>([]);
|
||||
|
||||
// Fetch runtime API key from server (GOOGLE_MAPS_API_KEY env var)
|
||||
useEffect(() => {
|
||||
fetch("/api/config")
|
||||
.then((res) => res.json())
|
||||
@@ -39,6 +39,7 @@ export default function Gallery() {
|
||||
.catch((err) => console.error("Error fetching config:", err));
|
||||
}, []);
|
||||
|
||||
// Fetch locations
|
||||
useEffect(() => {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const initialLocId = queryParams.get("locationId");
|
||||
@@ -61,6 +62,7 @@ export default function Gallery() {
|
||||
.catch((err) => console.error("Error fetching locations:", err));
|
||||
}, []);
|
||||
|
||||
// Filter locations
|
||||
useEffect(() => {
|
||||
const term = search.toLowerCase();
|
||||
setFilteredLocations(
|
||||
@@ -70,8 +72,10 @@ export default function Gallery() {
|
||||
);
|
||||
}, [search, locations]);
|
||||
|
||||
// Load Google Maps API & Initialize
|
||||
useEffect(() => {
|
||||
if (!runtimeApiKey) return;
|
||||
|
||||
setOptions({
|
||||
key: runtimeApiKey,
|
||||
v: "weekly",
|
||||
@@ -81,23 +85,26 @@ export default function Gallery() {
|
||||
const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary;
|
||||
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary;
|
||||
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);
|
||||
|
||||
// Initialize Street View Service
|
||||
svServiceRef.current = new google.maps.StreetViewService();
|
||||
|
||||
// Initialize Map
|
||||
googleMapObj.current = new Map(mapRef.current as HTMLElement, {
|
||||
center: { lat: 20, lng: 0 },
|
||||
zoom: 2,
|
||||
mapId: "STREET_VIEW_GALLERY_MAP",
|
||||
});
|
||||
|
||||
// Initialize Mini Map
|
||||
miniMapObj.current = new Map(miniMapRef.current as HTMLElement, {
|
||||
center: { lat: 20, lng: 0 },
|
||||
zoom: 14,
|
||||
mapId: "STREET_VIEW_MINI_MAP",
|
||||
disableDefaultUI: true,
|
||||
disableDefaultUI: true, // This hides everything (pegman, map types, etc.)
|
||||
zoomControl: false,
|
||||
mapTypeControl: false,
|
||||
streetViewControl: false,
|
||||
@@ -111,15 +118,19 @@ export default function Gallery() {
|
||||
position: { lat: 0, lng: 0 },
|
||||
});
|
||||
|
||||
// Intercept Pegman Drops
|
||||
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.addListener("visible_changed", () => {
|
||||
if (defaultStreetView.getVisible()) {
|
||||
const pos = defaultStreetView.getPosition();
|
||||
// Intercept the drop: immediately hide the buggy WebGL canvas
|
||||
defaultStreetView.setVisible(false);
|
||||
|
||||
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`);
|
||||
setViewingStreetView(true);
|
||||
|
||||
@@ -133,8 +144,9 @@ export default function Gallery() {
|
||||
});
|
||||
}, [runtimeApiKey]);
|
||||
|
||||
// Draw all GPX routes for filtered locations
|
||||
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
|
||||
activePolylinesRef.current.forEach((p) => p.polyline.setMap(null));
|
||||
@@ -168,6 +180,7 @@ export default function Gallery() {
|
||||
});
|
||||
|
||||
if (coords.length > 0) {
|
||||
// Calculate distance
|
||||
calculatedMeters += google.maps.geometry.spherical.computeLength(coords);
|
||||
|
||||
const polyline = new google.maps.Polyline({
|
||||
@@ -177,9 +190,10 @@ export default function Gallery() {
|
||||
strokeWeight: 4,
|
||||
clickable: true,
|
||||
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({
|
||||
path: coords,
|
||||
strokeColor: "#0000FF",
|
||||
@@ -202,10 +216,11 @@ export default function Gallery() {
|
||||
bounds: locBounds,
|
||||
firstCoord: coords[0]
|
||||
});
|
||||
|
||||
// Add Google Street View click detection (Main Map)
|
||||
polyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => {
|
||||
if (!clickEvent.latLng) return;
|
||||
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`);
|
||||
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) => {
|
||||
if (!clickEvent.latLng) return;
|
||||
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`);
|
||||
setViewingStreetView(true);
|
||||
|
||||
@@ -227,11 +244,14 @@ export default function Gallery() {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate Static Map Thumbnail URL
|
||||
try {
|
||||
const maxThumbnailPoints = 150;
|
||||
const step = Math.max(1, Math.floor(coords.length / maxThumbnailPoints));
|
||||
const simplifiedCoords = coords.filter((_, i) => i % step === 0);
|
||||
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}`;
|
||||
} catch (err) {
|
||||
console.error("Failed to generate static map thumbnail", err);
|
||||
@@ -240,14 +260,18 @@ export default function Gallery() {
|
||||
});
|
||||
|
||||
setThumbnails(newThumbnails);
|
||||
|
||||
// Update stats
|
||||
setTotalMiles(calculatedMeters * 0.000621371);
|
||||
|
||||
// Fit bounds for all visible filtered locations
|
||||
if (hasPoints) {
|
||||
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(() => {
|
||||
if (!googleMapObj.current || !geometryLoaded) return;
|
||||
|
||||
@@ -256,6 +280,7 @@ export default function Gallery() {
|
||||
activePolylinesRef.current.forEach((item) => {
|
||||
if (selectedLocation?.id === item.id) {
|
||||
item.polyline.setOptions({ strokeColor: "#00FF00", strokeOpacity: 1.0, strokeWeight: 6, zIndex: 10 });
|
||||
// Smoothly fit bounds exactly like the initial filtered view
|
||||
if (!didPan) {
|
||||
googleMapObj.current!.fitBounds(item.bounds);
|
||||
didPan = true;
|
||||
@@ -266,6 +291,7 @@ export default function Gallery() {
|
||||
});
|
||||
}, [selectedLocation, geometryLoaded]);
|
||||
|
||||
// Update URL when selection changes
|
||||
useEffect(() => {
|
||||
if (!selectedLocation) return;
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -275,14 +301,18 @@ export default function Gallery() {
|
||||
}
|
||||
}, [selectedLocation]);
|
||||
|
||||
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleLocationSelect = (loc: Location) => {
|
||||
// Switch to map view to show route, not street view directly
|
||||
setViewingStreetView(false);
|
||||
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) {
|
||||
const item = activePolylinesRef.current.find(p => p.id === loc.id);
|
||||
if (item && googleMapObj.current) {
|
||||
@@ -291,6 +321,8 @@ export default function Gallery() {
|
||||
} else {
|
||||
setSelectedLocation(loc);
|
||||
}
|
||||
|
||||
// Auto-close standard Bootstrap offcanvas on mobile natively
|
||||
closeMobileSidebar();
|
||||
};
|
||||
|
||||
@@ -348,8 +380,9 @@ export default function Gallery() {
|
||||
onClick={() => handleLocationSelect(loc)}
|
||||
style={{ cursor: "pointer", overflow: "hidden" }}
|
||||
>
|
||||
{/* mobile layout */}
|
||||
{/* --- MOBILE LAYOUT (Horizontal) --- */}
|
||||
<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" }}>
|
||||
{thumbnails[loc.id] ? (
|
||||
<img
|
||||
@@ -363,7 +396,7 @@ export default function Gallery() {
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
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"
|
||||
}}
|
||||
/>
|
||||
@@ -373,6 +406,7 @@ export default function Gallery() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Content (Right) */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* desktop layout */}
|
||||
{/* --- DESKTOP LAYOUT (Vertical/Original) --- */}
|
||||
<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" }}>
|
||||
{thumbnails[loc.id] ? (
|
||||
<img
|
||||
@@ -409,6 +444,7 @@ export default function Gallery() {
|
||||
<span className="text-secondary small">Loading Map...</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Content (Bottom) */}
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Oldest date indicator at the bottom */}
|
||||
{locations.length > 0 && (
|
||||
<div className="text-secondary mt-3 mb-2" style={{ fontSize: "0.75rem", textAlign: "center", textTransform: "uppercase", letterSpacing: "1px" }}>
|
||||
Images dating back to {
|
||||
@@ -440,11 +477,11 @@ export default function Gallery() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Panel */}
|
||||
{/* Main Panel (Map or Street View) */}
|
||||
<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 }} />
|
||||
|
||||
{/* Overlay controls and mini-map, only shown when in Street View mode */}
|
||||
{viewingStreetView && iframeUrl && (
|
||||
<>
|
||||
<div
|
||||
@@ -475,7 +512,7 @@ export default function Gallery() {
|
||||
setIframeUrl(null);
|
||||
}}
|
||||
>
|
||||
<span>←</span> Back to Map
|
||||
<span>←</span> Back to Map
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -496,6 +533,7 @@ export default function Gallery() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual React Backdrop for Mobile Sidebar */}
|
||||
{isMobileSidebarOpen && (
|
||||
<div
|
||||
className="offcanvas-backdrop fade show d-md-none"
|
||||
@@ -504,4 +542,4 @@ export default function Gallery() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,33 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { verifySessionToken } from "@/lib/session";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const isAuthRoute = request.nextUrl.pathname.startsWith('/login') || request.nextUrl.pathname.startsWith('/api/auth');
|
||||
const isAdminRoute = request.nextUrl.pathname.startsWith('/admin') || request.nextUrl.pathname.startsWith('/api/admin');
|
||||
export async function middleware(request: NextRequest) {
|
||||
const isAdminRoute =
|
||||
request.nextUrl.pathname.startsWith("/admin") ||
|
||||
request.nextUrl.pathname.startsWith("/api/admin");
|
||||
|
||||
// If trying to access admin routes, verify the session cookie
|
||||
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 (request.nextUrl.pathname === '/api/admin/locations' && request.method === 'GET') {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Redirect to login if no session cookie exists
|
||||
if (!adminSession?.value) {
|
||||
if (request.nextUrl.pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (!isValid) {
|
||||
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') {
|
||||
const adminSession = request.cookies.get('admin_session');
|
||||
if (adminSession?.value) {
|
||||
return NextResponse.redirect(new URL('/admin', request.url));
|
||||
if (request.nextUrl.pathname === "/login") {
|
||||
const adminSession = request.cookies.get("admin_session");
|
||||
if (
|
||||
adminSession?.value &&
|
||||
(await verifySessionToken(adminSession.value))
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/admin", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,5 +38,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*', '/api/admin/:path*', '/login'],
|
||||
matcher: ["/admin/:path*", "/api/admin/:path*", "/login"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user