diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d6a853 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.next +.env +.env.* +!.env.example +.DS_Store +node_modules +content.db +public/media +tsconfig.tsbuildinfo +playwright-report +test-results +coverage +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07e1bce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS base + +ENV NEXT_TELEMETRY_DISABLED=1 \ + PNPM_HOME=/pnpm \ + PATH=/pnpm:$PATH + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@10 --activate + +FROM base AS deps + +COPY package.json pnpm-lock.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile + +FROM deps AS build + +ARG NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +ARG __NEXT_PRIVATE_ORIGIN=http://localhost:3000 + +ENV NODE_ENV=production \ + NEXT_PUBLIC_SERVER_URL=$NEXT_PUBLIC_SERVER_URL \ + __NEXT_PRIVATE_ORIGIN=$__NEXT_PRIVATE_ORIGIN \ + DATABASE_URL=file:/app/shared/content.db \ + PAYLOAD_SECRET=build-time-placeholder \ + CRON_SECRET=build-time-placeholder \ + PREVIEW_SECRET=build-time-placeholder + +COPY . . + +RUN pnpm build +RUN pnpm prune --prod + +FROM base AS runner + +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 \ + PORT=3000 + +COPY --from=build /app /app + +RUN mkdir -p /app/shared /app/public/media + +EXPOSE 3000 + +CMD ["pnpm", "start"] diff --git a/deploy/vps/docker-compose.yml b/deploy/vps/docker-compose.yml new file mode 100644 index 0000000..c4e9188 --- /dev/null +++ b/deploy/vps/docker-compose.yml @@ -0,0 +1,60 @@ +name: bmp-website + +services: + app: + build: + context: ${APP_ROOT:-/var/www/bmp-website}/current + args: + NEXT_PUBLIC_SERVER_URL: https://${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de} + __NEXT_PRIVATE_ORIGIN: https://${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de} + image: bmp-website:latest + container_name: bmp_website + restart: unless-stopped + env_file: + - ${APP_ROOT:-/var/www/bmp-website}/shared/.env + environment: + NODE_ENV: production + HOSTNAME: 0.0.0.0 + PORT: 3000 + DATABASE_URL: file:/app/shared/content.db + NEXT_PUBLIC_SERVER_URL: https://${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de} + volumes: + - ${APP_ROOT:-/var/www/bmp-website}/shared/content.db:/app/shared/content.db + - ${APP_ROOT:-/var/www/bmp-website}/shared/media:/app/public/media + expose: + - "3000" + networks: + - web + + caddy: + image: caddy:latest + container_name: bmp_caddy + restart: unless-stopped + command: + - caddy + - reverse-proxy + - --from + - ${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de} + - --to + - app:3000 + ports: + - "80:80" + - "443:443" + volumes: + - caddy_data:/data + - caddy_config:/config + depends_on: + - app + networks: + - web + +networks: + web: + +volumes: + caddy_data: + external: true + name: ${CADDY_DATA_VOLUME:-n8n-docker_caddy_data} + caddy_config: + external: true + name: ${CADDY_CONFIG_VOLUME:-n8n-docker_caddy_config} diff --git a/docs/vps-deployment.md b/docs/vps-deployment.md index 74dc9f5..13d7924 100644 --- a/docs/vps-deployment.md +++ b/docs/vps-deployment.md @@ -1,218 +1,165 @@ # VPS deployment -This project is a Node/Next/Payload application that currently uses: +The live VPS deployment runs this Payload/Next app with Docker Compose and Caddy. -- SQLite via `DATABASE_URL` -- local filesystem media in `public/media` -- no Docker +Current default target: -Treat code and persistent state separately. Git or rsync can move the code, but -the database and media directory must be copied and backed up explicitly. - -## Persistent paths - -Use one stable directory on the VPS: - -```bash -APP_ROOT=/var/www/bmp-website -APP_USER=deploy +```text +https://srv1.bayerischer-mittelstandspreis.de ``` -Recommended layout: +The app is intentionally split into committed code and uncommitted runtime state. + +## Server Layout ```text /var/www/bmp-website/ current/ # synced application code shared/ - .env # production secrets - content.db # SQLite database - media/ # Payload uploads - backups/ + .env # production secrets, never committed + content.db # live SQLite database, never committed + media/ # live Payload uploads, never committed + backups/ # server-side DB backups ``` -Create it on the VPS: +The Docker Compose file lives in the repo at: + +```text +deploy/vps/docker-compose.yml +``` + +It starts: + +- `bmp_website`: the Next/Payload app +- `bmp_caddy`: Caddy reverse proxy on ports `80` and `443` + +Caddy reuses the certificate volumes from the former n8n deployment: + +```text +n8n-docker_caddy_data +n8n-docker_caddy_config +``` + +The old n8n containers and volumes may still exist on the server, but are +stopped after the BMP deployment takes over public traffic. + +## What Belongs In Git + +Commit: + +- application code +- Payload schema/config changes +- `Dockerfile` +- `.dockerignore` +- `deploy/vps/docker-compose.yml` +- deployment scripts + +Do not commit: + +- `.env` +- `content.db` +- `public/media/` +- production backups +- any secrets + +## Deploying Code + +Normal deploys should be code-only. The live production database and uploads on +the VPS are authoritative and must not be overwritten by routine deploys. + +From a clean, committed worktree: ```bash -sudo mkdir -p "$APP_ROOT"/{current,shared/media,backups} -sudo chown -R "$APP_USER":"$APP_USER" "$APP_ROOT" +pnpm deploy:vps ``` -## Production environment +The deploy script: -Create `/var/www/bmp-website/shared/.env` on the VPS: +1. refuses to deploy a dirty worktree by default +2. rsyncs code to `/var/www/bmp-website/current` +3. excludes `.env`, `content.db`, `public/media`, `.next`, and `node_modules` +4. writes the deployed Git revision to `.deploy-revision` +5. builds the Docker image on the VPS +6. recreates only the `bmp_website` app container +7. verifies `/` and `/admin` -```dotenv -DATABASE_URL=file:/var/www/bmp-website/shared/content.db -PAYLOAD_SECRET=replace-with-existing-secret -NEXT_PUBLIC_SERVER_URL=https://example.com -CRON_SECRET=replace-with-existing-secret -PREVIEW_SECRET=replace-with-existing-secret -PORT=3000 -NODE_ENV=production -``` - -Keep `PAYLOAD_SECRET`, `CRON_SECRET`, and `PREVIEW_SECRET` stable once the site -is live. Do not commit the production `.env`. - -`NEXT_PUBLIC_SERVER_URL` is read during the Next build, so set it correctly -before running `npm run build`. - -If you already have the correct local `.env`, transfer it separately from the -application code and lock down its permissions: +If you intentionally need an ad-hoc deploy before committing: ```bash -scp .env "$SSH_TARGET:$APP_ROOT/shared/.env" -ssh "$SSH_TARGET" "chmod 600 $APP_ROOT/shared/.env" +ALLOW_DIRTY=1 pnpm deploy:vps ``` -If you need to package secrets in an archive, make that archive secret-only and -encrypt it before transfer: +Use that sparingly. The preferred workflow is: -```bash -tar -czf bmp-website-secrets.tar.gz .env -gpg -c bmp-website-secrets.tar.gz -scp bmp-website-secrets.tar.gz.gpg "$SSH_TARGET:$APP_ROOT/shared/" +```text +commit locally -> deploy that commit -> verify production ``` -Then decrypt it on the VPS, move `.env` into place, and remove the temporary -archive files. +## Configuration -## Initial transfer - -Run these locally from the repository root. Replace the SSH target and domain -values first. +The scripts default to the current VPS: ```bash -SSH_TARGET=deploy@example.com +SSH_USER=syntaxbullet +VPS_HOST=srv1.bayerischer-mittelstandspreis.de +VPS_PORT=22 +SSH_KEY=~/.ssh/bmp-vps-deploy APP_ROOT=/var/www/bmp-website +APP_DOMAIN=srv1.bayerischer-mittelstandspreis.de ``` -Stop any local dev server before copying `content.db`. - -Copy persistent state once: +Override any value inline when needed: ```bash -rsync -az content.db "$SSH_TARGET:$APP_ROOT/shared/content.db" -rsync -az public/media/ "$SSH_TARGET:$APP_ROOT/shared/media/" +APP_DOMAIN=www.example.com pnpm deploy:vps ``` -Copy application code without local state or build output: +If the Caddy container also needs to be recreated: ```bash -rsync -az --delete \ - --exclude '.env' \ - --exclude '.next/' \ - --exclude 'node_modules/' \ - --exclude 'content.db' \ - --exclude 'public/media/' \ - ./ "$SSH_TARGET:$APP_ROOT/current/" +DEPLOY_CADDY=1 pnpm deploy:vps ``` -Link the shared media directory into the app: +## Status And Logs ```bash -ssh "$SSH_TARGET" "mkdir -p $APP_ROOT/current/public && rm -rf $APP_ROOT/current/public/media && ln -s $APP_ROOT/shared/media $APP_ROOT/current/public/media" -ssh "$SSH_TARGET" "ln -sfn $APP_ROOT/shared/.env $APP_ROOT/current/.env" +pnpm vps:status ``` -Do not use `--delete` when syncing media unless the source is intentionally the -source of truth and you have a current backup. +This prints: -## Build on the VPS +- deployed revision +- running containers +- old n8n container status +- shared DB/media sizes +- disk usage +- recent app and Caddy logs -Install Node.js 22 or another version accepted by `package.json`, then run: +## Backing Up SQLite + +Create a server-side SQLite backup before schema-sensitive deploys: ```bash -cd /var/www/bmp-website/current -npm ci -npm run build +pnpm vps:backup-db ``` -## systemd service +This briefly stops the app container, copies +`/var/www/bmp-website/shared/content.db` into `/var/www/bmp-website/backups/`, +then starts the app container again. Caddy remains running. -Create `/etc/systemd/system/bmp-website.service`: +## Rollback To Old n8n -```ini -[Unit] -Description=BMP website -After=network.target - -[Service] -Type=simple -User=deploy -Group=deploy -WorkingDirectory=/var/www/bmp-website/current -EnvironmentFile=/var/www/bmp-website/shared/.env -ExecStart=/usr/bin/env npm run start -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - -Enable and start it: +The old n8n compose project was intentionally not deleted. If you need to replace +the BMP site with the previous n8n service: ```bash -sudo systemctl daemon-reload -sudo systemctl enable --now bmp-website -sudo systemctl status bmp-website --no-pager +CONFIRM=replace-bmp-with-n8n pnpm vps:rollback:n8n ``` -## nginx reverse proxy +This stops the BMP Caddy/app containers and starts `/root/n8n-docker`. -Example server block: +## Production State Caution -```nginx -server { - listen 80; - server_name example.com www.example.com; - - client_max_body_size 25m; - - location / { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } -} -``` - -After enabling the site, issue TLS certificates with your normal ACME client -such as Certbot. - -## Repeat deploys - -Before every deploy, back up the live SQLite database on the VPS: - -```bash -APP_ROOT=/var/www/bmp-website -sqlite3 "$APP_ROOT/shared/content.db" ".backup '$APP_ROOT/backups/content-$(date +%Y%m%d-%H%M%S).db'" -``` - -Then sync code, rebuild, and restart: - -```bash -rsync -az --delete \ - --exclude '.env' \ - --exclude '.next/' \ - --exclude 'node_modules/' \ - --exclude 'content.db' \ - --exclude 'public/media/' \ - ./ "$SSH_TARGET:$APP_ROOT/current/" - -ssh "$SSH_TARGET" "cd $APP_ROOT/current && npm ci && npm run build && sudo systemctl restart bmp-website" -``` - -Verify: - -```bash -ssh "$SSH_TARGET" "systemctl status bmp-website --no-pager" -curl -I https://example.com -curl -I https://example.com/admin -``` +After launch, CMS edits and uploads happen on the VPS. Do not sync local +`content.db` or `public/media/` over production unless you are intentionally +restoring or replacing production content and have a current backup. diff --git a/package.json b/package.json index b19f65e..9c07121 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "postbuild": "next-sitemap --config next-sitemap.config.cjs", "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", "dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start", + "deploy:vps": "bash scripts/deploy-vps.sh", "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install", @@ -39,7 +40,10 @@ "start": "cross-env NODE_OPTIONS=--no-deprecation next start", "test": "pnpm run test:int && pnpm run test:e2e", "test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts", - "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts" + "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts", + "vps:backup-db": "bash scripts/vps-backup-db.sh", + "vps:rollback:n8n": "bash scripts/vps-rollback-n8n.sh", + "vps:status": "bash scripts/vps-status.sh" }, "dependencies": { "@payloadcms/admin-bar": "3.85.1", diff --git a/scripts/deploy-vps.sh b/scripts/deploy-vps.sh new file mode 100755 index 0000000..c6bba84 --- /dev/null +++ b/scripts/deploy-vps.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +SSH_USER="${SSH_USER:-syntaxbullet}" +VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}" +VPS_PORT="${VPS_PORT:-22}" +SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}" +APP_ROOT="${APP_ROOT:-/var/www/bmp-website}" +APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}" +PUBLIC_URL="${PUBLIC_URL:-https://$APP_DOMAIN}" +ALLOW_DIRTY="${ALLOW_DIRTY:-0}" +DEPLOY_CADDY="${DEPLOY_CADDY:-0}" +SKIP_VERIFY="${SKIP_VERIFY:-0}" + +SSH_TARGET="$SSH_USER@$VPS_HOST" +COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml" + +if [[ ! -f "$SSH_KEY" ]]; then + echo "Missing SSH key: $SSH_KEY" >&2 + exit 1 +fi + +if [[ "$ALLOW_DIRTY" != "1" && -n "$(git status --porcelain)" ]]; then + echo "Refusing to deploy a dirty worktree." >&2 + echo "Commit or stash changes first, or run with ALLOW_DIRTY=1 for an intentional ad-hoc deploy." >&2 + exit 1 +fi + +REVISION="$(git rev-parse --short=12 HEAD)" + +echo "Deploying $REVISION to $SSH_TARGET:$APP_ROOT" + +rsync -az --delete \ + --exclude '.git/' \ + --exclude '.env' \ + --include '.env.example' \ + --exclude '.env.*' \ + --exclude '.next/' \ + --exclude 'node_modules/' \ + --exclude 'content.db' \ + --exclude 'public/media/' \ + --exclude '.DS_Store' \ + --exclude 'tsconfig.tsbuildinfo' \ + -e "ssh -i $SSH_KEY -p $VPS_PORT" \ + ./ "$SSH_TARGET:$APP_ROOT/current/" + +ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "printf '%s\n' '$REVISION' > '$APP_ROOT/current/.deploy-revision'" + +ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" " + set -e + cd '$APP_ROOT' + APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' build app + APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' up -d app + if [ '$DEPLOY_CADDY' = '1' ]; then + APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' up -d caddy + fi + sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' +" + +if [[ "$SKIP_VERIFY" != "1" ]]; then + echo "Verifying $PUBLIC_URL" + curl -fsSIL --max-time 20 "$PUBLIC_URL/" | sed -n '1,12p' + curl -fsSIL --max-time 20 "$PUBLIC_URL/admin" | sed -n '1,12p' +fi + +echo "Deploy complete: $REVISION" diff --git a/scripts/vps-backup-db.sh b/scripts/vps-backup-db.sh new file mode 100755 index 0000000..c65bc33 --- /dev/null +++ b/scripts/vps-backup-db.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SSH_USER="${SSH_USER:-syntaxbullet}" +VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}" +VPS_PORT="${VPS_PORT:-22}" +SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}" +APP_ROOT="${APP_ROOT:-/var/www/bmp-website}" +APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}" + +SSH_TARGET="$SSH_USER@$VPS_HOST" +COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml" + +ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" " + set -e + backup_dir='$APP_ROOT/backups' + backup_file=\"\$backup_dir/content-\$(date +%Y%m%d-%H%M%S).db\" + + sudo install -d -o '$SSH_USER' -g '$SSH_USER' \"\$backup_dir\" + + restart_app() { + APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' up -d app >/dev/null + } + + trap restart_app EXIT + APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' stop app >/dev/null + cp -p '$APP_ROOT/shared/content.db' \"\$backup_file\" + restart_app + trap - EXIT + + ls -lh \"\$backup_file\" +" diff --git a/scripts/vps-rollback-n8n.sh b/scripts/vps-rollback-n8n.sh new file mode 100755 index 0000000..77e33d9 --- /dev/null +++ b/scripts/vps-rollback-n8n.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SSH_USER="${SSH_USER:-syntaxbullet}" +VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}" +VPS_PORT="${VPS_PORT:-22}" +SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}" +APP_ROOT="${APP_ROOT:-/var/www/bmp-website}" +CONFIRM="${CONFIRM:-}" + +if [[ "$CONFIRM" != "replace-bmp-with-n8n" ]]; then + echo "This stops the BMP website and starts the old n8n compose project." >&2 + echo "Run with CONFIRM=replace-bmp-with-n8n when you intentionally want that rollback." >&2 + exit 1 +fi + +SSH_TARGET="$SSH_USER@$VPS_HOST" +BMP_COMPOSE="$APP_ROOT/current/deploy/vps/docker-compose.yml" +N8N_COMPOSE="/root/n8n-docker/docker-compose.yml" + +ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" " + set -e + sudo docker compose -f '$BMP_COMPOSE' stop caddy app + sudo docker compose -f '$N8N_COMPOSE' start + sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' +" diff --git a/scripts/vps-status.sh b/scripts/vps-status.sh new file mode 100755 index 0000000..209a4d0 --- /dev/null +++ b/scripts/vps-status.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SSH_USER="${SSH_USER:-syntaxbullet}" +VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}" +VPS_PORT="${VPS_PORT:-22}" +SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}" +APP_ROOT="${APP_ROOT:-/var/www/bmp-website}" +LOG_LINES="${LOG_LINES:-80}" + +SSH_TARGET="$SSH_USER@$VPS_HOST" + +ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" " + set -e + printf '== revision ==\n' + cat '$APP_ROOT/current/.deploy-revision' 2>/dev/null || true + printf '\n== containers ==\n' + sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' + printf '\n== old n8n containers ==\n' + sudo docker ps -a --filter name=n8n_automation --filter name=caddy_proxy --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' + printf '\n== shared state ==\n' + ls -lh '$APP_ROOT/shared/.env' '$APP_ROOT/shared/content.db' + find '$APP_ROOT/shared/media' -type f | wc -l + du -sh '$APP_ROOT/shared/media' + printf '\n== disk ==\n' + df -h / /var/lib/docker '$APP_ROOT' 2>/dev/null || df -h + printf '\n== app logs ==\n' + sudo docker logs --tail='$LOG_LINES' bmp_website + printf '\n== caddy logs ==\n' + sudo docker logs --tail='$LOG_LINES' bmp_caddy +" diff --git a/src/collections/Media.ts b/src/collections/Media.ts index 9525751..03f0edf 100644 --- a/src/collections/Media.ts +++ b/src/collections/Media.ts @@ -6,13 +6,11 @@ import { lexicalEditor, } from '@payloadcms/richtext-lexical' import path from 'path' -import { fileURLToPath } from 'url' import { anyone } from '../access/anyone' import { authenticated } from '../access/authenticated' -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) +const mediaStaticDir = path.resolve(process.cwd(), 'public/media') export const Media: CollectionConfig = { slug: 'media', @@ -48,7 +46,7 @@ export const Media: CollectionConfig = { }, upload: { // Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload - staticDir: path.resolve(dirname, '../../public/media'), + staticDir: mediaStaticDir, adminThumbnail: 'thumbnail', focalPoint: true, imageSizes: [ diff --git a/src/spa/cmsMediaField.ts b/src/spa/cmsMediaField.ts index 61fabe7..f7ab375 100644 --- a/src/spa/cmsMediaField.ts +++ b/src/spa/cmsMediaField.ts @@ -6,10 +6,28 @@ type MediaLike = { alt?: string | null } +const payloadFilePrefix = '/api/media/file/' + +export function normalizeMediaUrl(url: string | null | undefined) { + if (!url) return '' + if (url.startsWith(payloadFilePrefix)) return `/media/${url.slice(payloadFilePrefix.length)}` + + try { + const parsed = new URL(url) + if (parsed.pathname.startsWith(payloadFilePrefix)) { + return `/media/${parsed.pathname.slice(payloadFilePrefix.length)}` + } + } catch { + // Relative URLs are handled above. + } + + return url +} + export function mediaUrl(media: unknown, fallback: string) { if (!media || typeof media !== 'object') return fallback const doc = media as MediaLike - return doc.url || (doc.filename ? `/media/${doc.filename}` : fallback) + return normalizeMediaUrl(doc.url) || (doc.filename ? `/media/${doc.filename}` : fallback) } export function mediaAlt(media: unknown, fallback: string) { diff --git a/src/spa/cmsNavigation.ts b/src/spa/cmsNavigation.ts index 762be2b..82bdb4b 100644 --- a/src/spa/cmsNavigation.ts +++ b/src/spa/cmsNavigation.ts @@ -1,4 +1,5 @@ import type { Footer, Header, Media } from '@/payload-types' +import { normalizeMediaUrl } from '@/spa/cmsMediaField' import { isLoginIntentHref } from '@/spa/loginIntent' export type SpaIconName = 'instagram' | 'linkedin' | 'facebook' @@ -164,7 +165,7 @@ export const defaultFooterData: SpaFooterData = { const isMedia = (value: unknown): value is Media => Boolean(value && typeof value === 'object' && 'url' in value) const mediaToSpa = (value: unknown, fallback?: SpaMedia): SpaMedia | undefined => { - if (isMedia(value)) return { url: value.url || fallback?.url, alt: value.alt || fallback?.alt } + if (isMedia(value)) return { url: normalizeMediaUrl(value.url) || fallback?.url, alt: value.alt || fallback?.alt } return fallback }