Implement tasks 60, 61, 62: backups, rate limiting, structured logging
Task 60 — Postgres backups: - deploy/backups/: backup.sh (pg_dump -Fc nightly), retain.sh (14/8/6 tiers), restore.sh, entrypoint.sh, crontab - famapp-backup Alpine service + backups volume added to deploy/compose.yaml - Restore procedure in deploy/backups/README.md Task 61 — Rate limiting on share links: - src/lib/rate-limit.ts: Edge-compatible sliding-window counter (50/min, LRU eviction) with consume(), isRateLimited(), recordFailure() exports - middleware.ts: enforces 429 with Retry-After: 60 for /s/[token] (IP + token prefix) - /s/[token]/page.tsx: tracks only failed resolveShareToken calls via recordFailure() Task 62 — Structured logging: - pino + pino-pretty installed; serverExternalPackages added to next.config.ts - src/lib/logger.ts: JSON in production, pretty in dev, level from LOG_LEVEL env - middleware.ts: structured JSON request log (method, path, status, ms, authenticated) - _core/push.ts, notify.ts, reminders.ts: console.error/log → logger.error/info Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b6abe052c9
commit
285a460eb8
@@ -36,9 +36,13 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
||||
|
||||
- **41 — Reminders engine**. Added `fired_at` and `created_by` columns to `reminders` table (migration `0013_push_notify_reminders.sql`); default channel changed to `'auto'`. Created `_core/reminders.ts` with `scheduleReminder` (upsert by entity), `cancelReminder`, `listReminders`, and `tickReminders` (30 s tick, `pg_try_advisory_xact_lock` guard). `startReminderWorker()` started via `src/instrumentation.ts` on the Node.js runtime. Notes actions updated to use `scheduleReminder`/`cancelReminder` instead of raw SQL. Calendar `createEvent` accepts optional `remindMinutesBefore` and schedules a reminder; `deleteEvent` calls `cancelReminder`. Calendar-shell event dialog shows "Remind me 30 min before" checkbox (new events only, checked by default). Reminder worker confirmed starting on server boot (logged in dev server output). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
- **60 — Postgres backups**. `famapp-backup` Alpine service added to `deploy/compose.yaml`; scripts in `deploy/backups/`: `backup.sh` (`pg_dump -Fc` for famapp-db + authentik-db nightly at 02:00), `retain.sh` (14 daily / 8 weekly / 6 monthly), `restore.sh` (restore from any dump file), `entrypoint.sh` (installs postgresql-client, sets up crontab, starts crond). Backup files stored in `backups` named Docker volume. Restore procedure in `deploy/backups/README.md`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
- **61 — Rate limiting on share links**. `src/lib/rate-limit.ts`: pure-JS Edge-compatible sliding-window counter (50 req/min, 1-min window, 10k-key LRU eviction) with `consume()`, `isRateLimited()`, and `recordFailure()` exports. `src/middleware.ts` calls `consume(ip:prefix)` for every `/s/[token]` request and returns 429 with `Retry-After: 60` when the bucket is exceeded. `src/app/s/[token]/page.tsx` additionally tracks only failed `resolveShareToken` lookups via `recordFailure()` in the Node.js runtime (separate module instance from middleware; Redis would unify them for multi-replica deployments). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
- **62 — Structured logging**. Installed `pino` + `pino-pretty` (dev). `src/lib/logger.ts`: pino instance — JSON in production (`stdout`), pretty-printed in dev; level from `LOG_LEVEL` env (default `info`); `pid`/`hostname` stripped, ISO timestamps. `src/middleware.ts` logs every request as structured JSON via `console.log` (Edge-compatible; pino not available in Edge runtime) with `method`, `path`, `status`, `ms`, `authenticated`. All `console.error`/`console.log` calls in `_core/push.ts`, `_core/notify.ts`, `_core/reminders.ts` replaced with `logger.error`/`logger.info`; sensitive fields (endpoint URLs, keys) are never logged as named fields. `next.config.ts` adds `serverExternalPackages: ["pino","pino-pretty"]` so webpack does not bundle them. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
## Next up
|
||||
|
||||
- Next task in `docs/tasks/`.
|
||||
- Phase 7 hardening complete. All acceptance criteria met for tasks 60, 61, 62.
|
||||
|
||||
## Development login/testing notes
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Backups
|
||||
|
||||
The `famapp-backup` service performs nightly compressed `pg_dump` of both
|
||||
`famapp-db` and `authentik-db` at **02:00 server time**.
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
/backups/ (named Docker volume: famapp_backups)
|
||||
famapp/
|
||||
daily/ ← last 14 days
|
||||
weekly/ ← last 8 Sundays
|
||||
monthly/ ← last 6 first-of-month dumps
|
||||
authentik/
|
||||
daily/
|
||||
weekly/
|
||||
monthly/
|
||||
```
|
||||
|
||||
Dump files are named `YYYY-MM-DD.dump` in custom (`-Fc`) format (internal
|
||||
compression, ~3–5× smaller than plain SQL).
|
||||
|
||||
## Retention
|
||||
|
||||
| Tier | Kept | Trigger |
|
||||
| ------- | ---- | --------------------- |
|
||||
| daily | 14 | every night |
|
||||
| weekly | 8 | Sunday night |
|
||||
| monthly | 6 | 1st of the month |
|
||||
|
||||
Retention is enforced by `retain.sh` at the end of each `backup.sh` run.
|
||||
|
||||
## Restore procedure
|
||||
|
||||
### 1. Identify the dump
|
||||
|
||||
```sh
|
||||
# List available dumps
|
||||
docker exec famapp-backup-1 ls /backups/famapp/daily/
|
||||
```
|
||||
|
||||
### 2a. Restore inside the backup container (recommended)
|
||||
|
||||
```sh
|
||||
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
/backups/famapp/daily/2024-06-01.dump \
|
||||
postgres://famapp:SECRET@famapp-db:5432/famapp
|
||||
```
|
||||
|
||||
Replace `SECRET` with the value of `FAMAPP_DB_PASSWORD` in your `.env` file.
|
||||
For authentik:
|
||||
|
||||
```sh
|
||||
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
/backups/authentik/daily/2024-06-01.dump \
|
||||
postgres://authentik:SECRET@authentik-db:5432/authentik
|
||||
```
|
||||
|
||||
### 2b. Restore to a separate database (safe — non-destructive)
|
||||
|
||||
Create a fresh target database first, then restore into it:
|
||||
|
||||
```sh
|
||||
# Create the target DB
|
||||
docker exec famapp-db-1 createdb \
|
||||
-U "$FAMAPP_DB_USER" famapp_restore
|
||||
|
||||
# Restore
|
||||
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
/backups/famapp/daily/2024-06-01.dump \
|
||||
postgres://famapp:SECRET@famapp-db:5432/famapp_restore
|
||||
```
|
||||
|
||||
### 2c. Restore on a fresh host (disaster recovery)
|
||||
|
||||
```sh
|
||||
# Copy the dump file out of the volume
|
||||
docker cp famapp-backup-1:/backups/famapp/daily/2024-06-01.dump ./
|
||||
|
||||
# Spin up a temporary Postgres container and restore
|
||||
docker run --rm \
|
||||
-e PGPASSWORD=SECRET \
|
||||
-v "$(pwd)/2024-06-01.dump:/dump.dump:ro" \
|
||||
postgres:16-alpine \
|
||||
pg_restore -h <new-db-host> -U famapp -d famapp \
|
||||
--no-owner --no-acl /dump.dump
|
||||
```
|
||||
|
||||
## Off-site replication
|
||||
|
||||
The backups live in the `famapp_backups` Docker named volume. To copy them
|
||||
to another host, rsync the volume's data directory periodically (e.g. from a
|
||||
host cron job):
|
||||
|
||||
```sh
|
||||
# On the Docker host, add to /etc/cron.d/famapp-rsync:
|
||||
30 3 * * * root rsync -a --delete \
|
||||
/var/lib/docker/volumes/famapp_backups/_data/ \
|
||||
user@offsite-server:/opt/famapp-backups/
|
||||
```
|
||||
|
||||
Encryption at rest is handled at the disk/filesystem layer (e.g. LUKS).
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# Nightly pg_dump for famapp-db and authentik-db.
|
||||
# Called by crond. Outputs to /backups/<db>/daily/<YYYY-MM-DD>.dump
|
||||
# and copies into weekly/ (Sundays) and monthly/ (1st of month).
|
||||
set -eu
|
||||
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
DOW=$(date +%u) # 1=Mon … 7=Sun
|
||||
DOM=$(date +%d | sed 's/^0*//') # day-of-month without leading zero
|
||||
|
||||
dump_db() {
|
||||
local name="$1"
|
||||
local host="$2"
|
||||
local port="$3"
|
||||
local user="$4"
|
||||
local pass="$5"
|
||||
local dbname="$6"
|
||||
|
||||
local daily_dir="/backups/$name/daily"
|
||||
local dest="$daily_dir/$TODAY.dump"
|
||||
|
||||
mkdir -p "$daily_dir" \
|
||||
"/backups/$name/weekly" \
|
||||
"/backups/$name/monthly"
|
||||
|
||||
echo "[backup] dumping $name ..."
|
||||
PGPASSWORD="$pass" pg_dump \
|
||||
-h "$host" -p "$port" -U "$user" -d "$dbname" \
|
||||
-Fc -f "$dest"
|
||||
echo "[backup] $name → $dest"
|
||||
|
||||
if [ "$DOW" = "7" ]; then
|
||||
cp "$dest" "/backups/$name/weekly/$TODAY.dump"
|
||||
echo "[backup] weekly copy saved for $name"
|
||||
fi
|
||||
|
||||
if [ "$DOM" = "1" ]; then
|
||||
cp "$dest" "/backups/$name/monthly/$TODAY.dump"
|
||||
echo "[backup] monthly copy saved for $name"
|
||||
fi
|
||||
}
|
||||
|
||||
dump_db famapp \
|
||||
"${FAMAPP_DB_HOST:-famapp-db}" \
|
||||
"${FAMAPP_DB_PORT:-5432}" \
|
||||
"$FAMAPP_DB_USER" \
|
||||
"$FAMAPP_DB_PASSWORD" \
|
||||
"$FAMAPP_DB_NAME"
|
||||
|
||||
dump_db authentik \
|
||||
"${AUTHENTIK_DB_HOST:-authentik-db}" \
|
||||
"${AUTHENTIK_DB_PORT:-5432}" \
|
||||
"$AUTHENTIK_DB_USER" \
|
||||
"$AUTHENTIK_DB_PASSWORD" \
|
||||
"$AUTHENTIK_DB_NAME"
|
||||
|
||||
/scripts/retain.sh
|
||||
echo "[backup] complete"
|
||||
@@ -0,0 +1 @@
|
||||
0 2 * * * /scripts/backup.sh >> /var/log/backup.log 2>&1
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# Container entrypoint: install postgresql-client, create backup directories,
|
||||
# install crontab, and start crond.
|
||||
set -eu
|
||||
|
||||
apk add --no-cache postgresql-client >/dev/null
|
||||
|
||||
mkdir -p \
|
||||
/backups/famapp/daily /backups/famapp/weekly /backups/famapp/monthly \
|
||||
/backups/authentik/daily /backups/authentik/weekly /backups/authentik/monthly
|
||||
|
||||
# Make scripts executable (volume mount may strip +x).
|
||||
chmod +x /scripts/backup.sh /scripts/retain.sh /scripts/restore.sh
|
||||
|
||||
# Install root crontab from the mounted file.
|
||||
mkdir -p /var/spool/cron/crontabs
|
||||
cp /scripts/crontab /var/spool/cron/crontabs/root
|
||||
chmod 600 /var/spool/cron/crontabs/root
|
||||
|
||||
echo "[entrypoint] backup service started — first run at 02:00"
|
||||
exec crond -f -l 2
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Restore a pg_dump (-Fc format) to a target Postgres database.
|
||||
# The target DB must already exist and be empty (or you accept overwriting data).
|
||||
#
|
||||
# Usage:
|
||||
# restore.sh <dump-file> <target-db-url>
|
||||
#
|
||||
# Example (inside the backup container):
|
||||
# /scripts/restore.sh \
|
||||
# /backups/famapp/daily/2024-06-01.dump \
|
||||
# postgres://famapp:secret@famapp-db:5432/famapp
|
||||
#
|
||||
# Example (from the host via docker exec):
|
||||
# docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
# /backups/famapp/daily/2024-06-01.dump \
|
||||
# postgres://famapp:secret@famapp-db:5432/famapp_restore
|
||||
set -eu
|
||||
|
||||
DUMP_FILE="${1:?Usage: restore.sh <dump-file> <target-db-url>}"
|
||||
TARGET_URL="${2:?Usage: restore.sh <dump-file> <target-db-url>}"
|
||||
|
||||
[ -f "$DUMP_FILE" ] || { echo "[restore] ERROR: $DUMP_FILE not found"; exit 1; }
|
||||
|
||||
echo "[restore] $DUMP_FILE → $TARGET_URL"
|
||||
pg_restore -d "$TARGET_URL" --no-owner --no-acl --exit-on-error "$DUMP_FILE"
|
||||
echo "[restore] done"
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Prune old backup files according to retention policy.
|
||||
# daily: keep 14 weekly: keep 8 monthly: keep 6
|
||||
set -eu
|
||||
|
||||
prune() {
|
||||
local dir="$1"
|
||||
local keep="$2"
|
||||
[ -d "$dir" ] || return 0
|
||||
# Files are YYYY-MM-DD.dump; lexicographic sort = chronological.
|
||||
# tail skips the N newest; xargs deletes the rest.
|
||||
ls -1 "$dir"/*.dump 2>/dev/null \
|
||||
| sort \
|
||||
| head -n "-$keep" \
|
||||
| xargs -r rm -f --
|
||||
}
|
||||
|
||||
for db in famapp authentik; do
|
||||
prune "/backups/$db/daily" 14
|
||||
prune "/backups/$db/weekly" 8
|
||||
prune "/backups/$db/monthly" 6
|
||||
done
|
||||
|
||||
echo "[retain] done"
|
||||
@@ -177,6 +177,9 @@ async function navigateWithFallback(request) {
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
// Keep pino and pino-pretty as native Node.js requires so their worker-thread
|
||||
// transport and stream internals work correctly inside the standalone bundle.
|
||||
serverExternalPackages: ["pino", "pino-pretty"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"globals": "^15.12.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tsx": "^4.19.4",
|
||||
@@ -59,6 +60,7 @@
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^15.5.15",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"pino": "^10.3.1",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
Generated
+173
@@ -50,6 +50,9 @@ importers:
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.31
|
||||
version: 5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
||||
pino:
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1
|
||||
postgres:
|
||||
specifier: ^3.4.9
|
||||
version: 3.4.9
|
||||
@@ -120,6 +123,9 @@ importers:
|
||||
globals:
|
||||
specifier: ^15.12.0
|
||||
version: 15.15.0
|
||||
pino-pretty:
|
||||
specifier: ^13.1.3
|
||||
version: 13.1.3
|
||||
prettier:
|
||||
specifier: ^3.3.3
|
||||
version: 3.8.3
|
||||
@@ -1228,6 +1234,9 @@ packages:
|
||||
'@panva/hkdf@1.2.1':
|
||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1836,6 +1845,10 @@ packages:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1966,6 +1979,9 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
commander@11.1.0:
|
||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -2044,6 +2060,9 @@ packages:
|
||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
dateformat@4.6.3:
|
||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||
|
||||
debug@3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
@@ -2245,6 +2264,9 @@ packages:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
enhanced-resolve@5.21.0:
|
||||
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -2469,6 +2491,9 @@ packages:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
fast-copy@4.0.3:
|
||||
resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -2489,6 +2514,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
||||
|
||||
@@ -2699,6 +2727,9 @@ packages:
|
||||
headers-polyfill@5.0.1:
|
||||
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
|
||||
|
||||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
@@ -2951,6 +2982,10 @@ packages:
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -3314,6 +3349,10 @@ packages:
|
||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3407,6 +3446,20 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pino-abstract-transport@3.0.0:
|
||||
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||
|
||||
pino-pretty@13.1.3:
|
||||
resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
|
||||
hasBin: true
|
||||
|
||||
pino-std-serializers@7.1.0:
|
||||
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||
|
||||
pino@10.3.1:
|
||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||
hasBin: true
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -3469,6 +3522,9 @@ packages:
|
||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3480,6 +3536,9 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3491,6 +3550,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3559,6 +3621,10 @@ packages:
|
||||
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
recast@0.23.11:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -3634,12 +3700,19 @@ packages:
|
||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -3717,6 +3790,9 @@ packages:
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3728,6 +3804,10 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -3805,6 +3885,10 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@5.0.3:
|
||||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -3840,6 +3924,10 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
thread-stream@4.0.0:
|
||||
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@@ -4951,6 +5039,8 @@ snapshots:
|
||||
|
||||
'@panva/hkdf@1.2.1': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
dependencies:
|
||||
playwright: 1.59.1
|
||||
@@ -5520,6 +5610,8 @@ snapshots:
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
@@ -5651,6 +5743,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
@@ -5715,6 +5809,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
dateformat@4.6.3: {}
|
||||
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -5804,6 +5900,10 @@ snapshots:
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
enhanced-resolve@5.21.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -6285,6 +6385,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-copy@4.0.3: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@4.0.3: {}
|
||||
@@ -6309,6 +6411,8 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-string-truncated-width@3.0.3: {}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
@@ -6507,6 +6611,8 @@ snapshots:
|
||||
'@types/set-cookie-parser': 2.4.10
|
||||
set-cookie-parser: 3.1.0
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
@@ -6730,6 +6836,8 @@ snapshots:
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
@@ -7053,6 +7161,8 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -7150,6 +7260,42 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pino-abstract-transport@3.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-pretty@13.1.3:
|
||||
dependencies:
|
||||
colorette: 2.0.20
|
||||
dateformat: 4.6.3
|
||||
fast-copy: 4.0.3
|
||||
fast-safe-stringify: 2.1.1
|
||||
help-me: 5.0.0
|
||||
joycon: 3.1.1
|
||||
minimist: 1.2.8
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pump: 3.0.4
|
||||
secure-json-parse: 4.1.0
|
||||
sonic-boom: 4.2.1
|
||||
strip-json-comments: 5.0.3
|
||||
|
||||
pino-std-serializers@7.1.0: {}
|
||||
|
||||
pino@10.3.1:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 4.0.0
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
playwright-core@1.59.1: {}
|
||||
@@ -7199,6 +7345,8 @@ snapshots:
|
||||
dependencies:
|
||||
parse-ms: 4.0.0
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
@@ -7215,6 +7363,11 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.1:
|
||||
@@ -7223,6 +7376,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
@@ -7293,6 +7448,8 @@ snapshots:
|
||||
|
||||
react@19.2.5: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
recast@0.23.11:
|
||||
dependencies:
|
||||
ast-types: 0.16.1
|
||||
@@ -7388,10 +7545,14 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-regex: 1.2.1
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
@@ -7562,6 +7723,10 @@ snapshots:
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -7571,6 +7736,8 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
@@ -7668,6 +7835,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -7689,6 +7858,10 @@ snapshots:
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
thread-stream@4.0.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { resolveShareToken } from "@/modules/_core/share";
|
||||
import { getEntityType } from "@/modules/_core/registry";
|
||||
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
// Rate-limit prefix length — must match the value used in middleware.
|
||||
const RL_PREFIX_LEN = 8;
|
||||
|
||||
export default async function SharePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const headersList = await headers();
|
||||
const ip =
|
||||
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
headersList.get("x-real-ip") ??
|
||||
"0.0.0.0";
|
||||
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
||||
|
||||
// Secondary rate-limit check in the Node.js runtime (failure-only bucket).
|
||||
// The primary 429 enforcement lives in src/middleware.ts which counts all
|
||||
// requests in the Edge runtime. This page tracks only failed token lookups,
|
||||
// providing accurate per-failure accounting. The two buckets are independent
|
||||
// (separate module instances across runtimes); a shared Redis store would
|
||||
// unify them for multi-replica deployments.
|
||||
if (isRateLimited(rlKey)) {
|
||||
return <ShareRateLimitError />;
|
||||
}
|
||||
|
||||
const resolved = await resolveShareToken(token);
|
||||
if (!resolved) return <ShareError />;
|
||||
if (!resolved) {
|
||||
// Only failed lookups increment the failure bucket.
|
||||
recordFailure(rlKey);
|
||||
return <ShareError />;
|
||||
}
|
||||
|
||||
const entityReg = getEntityType(resolved.entityType);
|
||||
if (!entityReg?.loadForShare || !entityReg.renderSharedView) {
|
||||
@@ -22,7 +47,10 @@ export default async function SharePage({
|
||||
}
|
||||
|
||||
const data = await entityReg.loadForShare(resolved.entityId);
|
||||
if (!data) return <ShareError />;
|
||||
if (!data) {
|
||||
recordFailure(rlKey);
|
||||
return <ShareError />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -37,6 +65,17 @@ export default async function SharePage({
|
||||
);
|
||||
}
|
||||
|
||||
function ShareRateLimitError() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<h1 className="text-xl font-semibold">Too many requests</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
You have made too many requests in a short period. Please wait a minute and try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareError({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import pino from "pino";
|
||||
|
||||
// Structured JSON logger for Node.js server code (server components, server actions,
|
||||
// background workers). NOT available in the Edge runtime (middleware) — use
|
||||
// console.log with JSON.stringify there instead.
|
||||
//
|
||||
// Secrets are never passed as log fields. Any field named `password`, `secret`,
|
||||
// `token`, or `key` is explicitly excluded by callers. Sensitive env vars
|
||||
// (AUTH_SECRET, VAPID_PRIVATE_KEY, etc.) must not appear in log payloads.
|
||||
//
|
||||
// Log level is controlled by the LOG_LEVEL env var (default: info).
|
||||
// In development, pino-pretty formats output with colour for readability.
|
||||
// In production, single-line JSON goes to stdout and is collected by Docker.
|
||||
|
||||
const level = process.env.LOG_LEVEL ?? "info";
|
||||
|
||||
const logger = pino(
|
||||
{
|
||||
level,
|
||||
base: { pid: undefined, hostname: undefined },
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
process.env.NODE_ENV === "production"
|
||||
? undefined
|
||||
: pino.transport({ target: "pino-pretty", options: { colorize: true } }),
|
||||
);
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,65 @@
|
||||
// In-memory sliding-window rate limiter.
|
||||
//
|
||||
// State is module-scoped: each Next.js runtime (Edge middleware vs Node.js server
|
||||
// components) gets its own module instance, so buckets are not shared between them.
|
||||
//
|
||||
// Redis migration path: replace the `buckets` Map with an upstash/ratelimit or
|
||||
// ioredis ZADD + ZRANGEBYSCORE approach. The exported function signatures stay the
|
||||
// same, only the storage layer changes, giving cross-process and cross-runtime
|
||||
// consistency for multi-replica deployments.
|
||||
|
||||
const WINDOW_MS = 60_000; // 1 minute
|
||||
const LIMIT = 50; // max attempts per window per key
|
||||
const MAX_KEYS = 10_000; // evict oldest when Map grows beyond this
|
||||
|
||||
const buckets = new Map<string, number[]>();
|
||||
|
||||
function sweep(key: string, now: number): number[] {
|
||||
const fresh = (buckets.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
|
||||
buckets.set(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function evictOldest(exclude: string) {
|
||||
if (buckets.size < MAX_KEYS) return;
|
||||
for (const k of buckets.keys()) {
|
||||
if (k !== exclude) {
|
||||
buckets.delete(k);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `key` has exceeded the rate limit WITHOUT recording a new attempt.
|
||||
* Use this to gate a request before you know whether it will succeed or fail.
|
||||
*/
|
||||
export function isRateLimited(key: string): boolean {
|
||||
return sweep(key, Date.now()).length >= LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one failed attempt for `key`.
|
||||
* Successful requests should NOT call this — only failures increment the bucket.
|
||||
*/
|
||||
export function recordFailure(key: string): void {
|
||||
const now = Date.now();
|
||||
const ts = sweep(key, now);
|
||||
if (ts.length >= LIMIT) return; // already over limit; don't bother growing the array
|
||||
evictOldest(key);
|
||||
buckets.set(key, [...ts, now]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and atomically consume one token for `key`.
|
||||
* Returns `true` if the request is allowed; `false` if rate-limited.
|
||||
* Used by middleware where every inbound request (success or failure) is counted.
|
||||
*/
|
||||
export function consume(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const ts = sweep(key, now);
|
||||
if (ts.length >= LIMIT) return false;
|
||||
evictOldest(key);
|
||||
buckets.set(key, [...ts, now]);
|
||||
return true;
|
||||
}
|
||||
+57
-1
@@ -1,12 +1,43 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { consume } from "@/lib/rate-limit";
|
||||
|
||||
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
|
||||
const PUBLIC_PATHS = new Set(["/login"]);
|
||||
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
||||
|
||||
// Token-prefix length used as part of the rate-limit bucket key.
|
||||
const RL_PREFIX_LEN = 8;
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const start = performance.now();
|
||||
const response = route(request);
|
||||
logRequest(request, response.status, Math.round(performance.now() - start));
|
||||
return response;
|
||||
}
|
||||
|
||||
function route(request: NextRequest): NextResponse {
|
||||
const { pathname } = request.nextUrl;
|
||||
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
|
||||
|
||||
// Rate-limit the public share-link viewer (/s/<token>).
|
||||
// The rate limiter runs in the Edge runtime and has its own module-level bucket
|
||||
// Map (separate from the Node.js runtime used by page components). All requests
|
||||
// to /s/<token> are counted here regardless of whether the token resolves; page
|
||||
// components additionally track per-failure counts via recordFailure() for
|
||||
// accurate auditing. Switch to Redis for cross-runtime / multi-replica accuracy.
|
||||
if (pathname.startsWith("/s/") && pathname.length > 3) {
|
||||
const token = pathname.slice(3); // strip "/s/"
|
||||
const ip = clientIp(request);
|
||||
const key = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
||||
|
||||
if (!consume(key)) {
|
||||
return new NextResponse("Too Many Requests", {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "60", "Content-Type": "text/plain" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@@ -17,6 +48,14 @@ export function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
function clientIp(request: NextRequest): string {
|
||||
return (
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"0.0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
function hasSessionCookie(request: NextRequest) {
|
||||
return request.cookies
|
||||
.getAll()
|
||||
@@ -27,6 +66,23 @@ function hasSessionCookie(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Structured request log — output as JSON so it's machine-readable in production.
|
||||
// pino is not available in the Edge runtime; console.log goes to server stdout.
|
||||
// userId is omitted: decoding the session token requires a DB lookup unavailable here.
|
||||
function logRequest(request: NextRequest, status: number, ms: number) {
|
||||
const authenticated = hasSessionCookie(request);
|
||||
const entry = {
|
||||
level: "info",
|
||||
time: Date.now(),
|
||||
method: request.method,
|
||||
path: request.nextUrl.pathname,
|
||||
status,
|
||||
ms,
|
||||
authenticated,
|
||||
};
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip Next.js internals and static files
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import logger from "@/lib/logger";
|
||||
import { notifications, users } from "./schema";
|
||||
import { sendPush } from "./push";
|
||||
|
||||
@@ -25,7 +26,7 @@ export async function notify(userId: string, payload: NotifyPayload) {
|
||||
|
||||
if (channels.includes("push") && user.notifPush && pushEnabled) {
|
||||
await sendPush(userId, payload).catch((err) =>
|
||||
console.error("[famapp] push channel failed:", err),
|
||||
logger.error({ err }, "push channel failed"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ export async function notify(userId: string, payload: NotifyPayload) {
|
||||
method: "POST",
|
||||
headers: { Title: payload.title, "Content-Type": "text/plain" },
|
||||
body: payload.body,
|
||||
}).catch((err) => console.error("[famapp] ntfy delivery failed:", err));
|
||||
}).catch((err) => logger.error({ err }, "ntfy delivery failed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import webPush from "web-push";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import logger from "@/lib/logger";
|
||||
import { pushSubscriptions } from "./schema";
|
||||
|
||||
function ensureVapidConfigured() {
|
||||
@@ -40,7 +41,7 @@ export async function sendPush(
|
||||
if (status === 404 || status === 410) {
|
||||
staleIds.push(sub.id);
|
||||
} else {
|
||||
console.error("[famapp] push delivery failed:", err);
|
||||
logger.error({ err }, "push delivery failed");
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import logger from "@/lib/logger";
|
||||
import { reminders } from "./schema";
|
||||
import { notify } from "./notify";
|
||||
|
||||
@@ -67,7 +68,7 @@ export async function tickReminders() {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[famapp] reminder tick error:", err);
|
||||
logger.error({ err }, "reminder tick error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@ export async function tickReminders() {
|
||||
channels: ["push", "inapp"],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[famapp] reminder delivery failed:", reminder.id, err);
|
||||
logger.error({ reminderId: reminder.id, err }, "reminder delivery failed");
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -93,7 +94,7 @@ let workerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
export function startReminderWorker() {
|
||||
if (workerTimer) return;
|
||||
workerTimer = setInterval(() => {
|
||||
tickReminders().catch((err) => console.error("[famapp] reminder worker uncaught:", err));
|
||||
tickReminders().catch((err) => logger.error({ err }, "reminder worker uncaught error"));
|
||||
}, 30_000);
|
||||
console.log("[famapp] reminder worker started (30s tick)");
|
||||
logger.info("reminder worker started (30s tick)");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user