Security hardening: red-team remediation + CCP/WIP updates
## Security (red-team audit 2026-04-12) Public data exposure (P0): - Public map converted to server-side heatmap, 2-decimal (~1.1km) bucketing, no addresses/support-levels/sign-info returned - Petition signers endpoint strips displayName/signerComment/geoCity/geoCountry - Petition public-stats drops recentSigners entirely - Response wall strips userComment + submittedByName - Campaign createdByUserEmail + moderation fields gated to SUPER_ADMIN Access control (P1): - Campaign findById/update/delete/email-stats enforce owner === req.user.id (SUPER_ADMIN bypasses), return 404 to avoid enumeration - GPS tracking session route restricted to session owner or SUPER_ADMIN - Canvass volunteer stats restricted to self or SUPER_ADMIN - People household endpoints restricted to INFLUENCE + MAP roles (was ADMIN*) - CCP upgrade.service.ts + certificate.service.ts gate user-controlled shell inputs (branch, path, slug, SAN hostname) behind regex validators Token security (P2): - Query-param JWT auth replaced with HMAC-signed short-lived URLs (utils/signed-url.ts + /api/media/sign endpoint); legacy ?token= removed from media streaming, photos, chat-notifications, and social SSE - GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT now REQUIRED (min 32 chars); JWT_ACCESS_SECRET fallback removed — BREAKING for existing deployments - Refresh tokens bound to device fingerprint (UA + /24 IP) via `df` JWT claim; mismatch revokes all user sessions - Refresh expiry reduced 7d → 24h - Refresh/logout via request body removed — httpOnly cookie only - Password-reset + verification-resend rate limits now keyed on (IP, email) composite to prevent both IP rotation and email enumeration Defense-in-depth (P3): - DOMPurify sanitization applied to GrapesJS landing page HTML/CSS - /api/health?detailed=true disk-space leak removed - Password-reset/verification token log lines no longer include userId ## Deployment - docker-compose.yml + docker-compose.prod.yml: media-api now receives GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT; empty fallbacks removed - CCP templates/env.hbs adds both new secrets; refresh expiry → 24h - CCP secret-generator.ts generates giteaSsoSecret + servicePasswordSalt - leaflet.heat added to admin/package.json for heatmap rendering ## Operator action required on existing installs Run `./config.sh` once (idempotent — only fills empty values) or manually add GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT to .env via `openssl rand -hex 32`. Startup fails with a clear Zod error otherwise. See SECURITY_REDTEAM_2026-04-12.md for full audit and verification matrix. ## Other Includes in-flight CCP work: instance schema tweaks, agent server updates, health service, tunnel service, DEV_WORKFLOW doc updates, and new migration dropping composeProject uniqueness. Bunker Admin
This commit is contained in:
parent
26ec925d9b
commit
e55bc07eb6
23
.env.example
23
.env.example
@ -46,20 +46,27 @@ JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
|||||||
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
JWT_ACCESS_EXPIRY=15m
|
JWT_ACCESS_EXPIRY=15m
|
||||||
JWT_REFRESH_EXPIRY=7d
|
# Reduced from 7d → 24h on 2026-04-12 (P2-3 hardening). Combined with
|
||||||
|
# device-fingerprint binding in the JWT payload, this tightens the
|
||||||
|
# exploitation window for stolen refresh tokens.
|
||||||
|
JWT_REFRESH_EXPIRY=24h
|
||||||
|
|
||||||
# Encryption key for DB-stored secrets (SMTP password, etc.)
|
# Encryption key for DB-stored secrets (SMTP password, etc.)
|
||||||
# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET
|
# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
|
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
|
||||||
# Gitea SSO cookie signing secret (separate from JWT — falls back to JWT_ACCESS_SECRET if empty)
|
# BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
|
||||||
# Generate with: openssl rand -hex 32
|
# are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET
|
||||||
GITEA_SSO_SECRET=
|
# has been removed — a JWT leak must not compromise SSO cookies or service
|
||||||
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat)
|
# account passwords. Both values must be distinct from each other and from
|
||||||
# Falls back to JWT_ACCESS_SECRET if empty — set a dedicated value to isolate secret rotation
|
# all JWT_* secrets. Generate with: openssl rand -hex 32
|
||||||
# Generate with: openssl rand -hex 32
|
|
||||||
SERVICE_PASSWORD_SALT=
|
# Gitea SSO cookie signing secret (required, ≥32 chars, distinct from JWT secrets)
|
||||||
|
GITEA_SSO_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
|
||||||
|
# Required, ≥32 chars, distinct from all other secrets.
|
||||||
|
SERVICE_PASSWORD_SALT=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
|
||||||
# --- Initial Super Admin User (auto-created during database seeding) ---
|
# --- Initial Super Admin User (auto-created during database seeding) ---
|
||||||
# These credentials are used to create the initial super admin account
|
# These credentials are used to create the initial super admin account
|
||||||
|
|||||||
@ -43,7 +43,7 @@ All three methods share the same Gitea container registry at `gitea.bnkops.com/a
|
|||||||
│ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
|
│ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
|
||||||
│ Packages runtime files into ~9MB tarball, uploads to │
|
│ Packages runtime files into ~9MB tarball, uploads to │
|
||||||
│ Gitea Releases │
|
│ Gitea Releases │
|
||||||
└──────────────────┬───────────────────────────────────────────────┘
|
└──────────────────┬─────────────────100.90.78.47──────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────┴───────────┐
|
┌───────────┴───────────┐
|
||||||
▼ ▼
|
▼ ▼
|
||||||
|
|||||||
134
SECURITY_REDTEAM_2026-04-12.md
Normal file
134
SECURITY_REDTEAM_2026-04-12.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Red-Team Findings Tracker — 2026-04-12
|
||||||
|
|
||||||
|
Source: Red-team audit (auth, IDOR, public exposure, injection).
|
||||||
|
Coordinator: Claude (main). Status legend: ⬜ pending · 🟡 in progress · ✅ done · ❎ wontfix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Zero-Auth Data Exposure (public endpoints leaking PII)
|
||||||
|
|
||||||
|
| # | Finding | File(s) | Status |
|
||||||
|
|---|---------|---------|--------|
|
||||||
|
| P0-1 | **Public map → server-side heatmap with ~1km bucketing** | `api/.../locations.service.ts` · `.../locations.routes.ts` · `admin/.../public/MapPage.tsx` · `admin/src/types/leaflet-heat.d.ts` (new) | ✅ |
|
||||||
|
| P0-2 | Petition signers endpoint — stripped displayName/comment/geo | `petitions.service.ts:545` | ✅ |
|
||||||
|
| P0-3 | Petition public-stats — removed `recentSigners` | `petitions.service.ts:618` | ✅ |
|
||||||
|
| P0-4 | Campaign response wall — removed `submittedByName` + `userComment` | `responses.service.ts:121` | ✅ |
|
||||||
|
| P0-5 | `createdByUserEmail` gated to SUPER_ADMIN only | `campaigns.service.ts` | ✅ |
|
||||||
|
|
||||||
|
## P1 — IDOR / Broken Access Control
|
||||||
|
|
||||||
|
| # | Finding | File(s) | Status |
|
||||||
|
|---|---------|---------|--------|
|
||||||
|
| P1-1..3 | Campaign findById/update/delete ownership checks | `campaigns.{service,routes}.ts` | ✅ |
|
||||||
|
| P1-4,5 | `/campaigns/:id/emails` + `/email-stats` ownership checks | `campaign-emails.routes.ts` | ✅ |
|
||||||
|
| P1-6 | GPS tracking route — SUPER_ADMIN or session owner only | `tracking.routes.ts` | ✅ |
|
||||||
|
| P1-7 | Canvass volunteer stats — SUPER_ADMIN or self only | `canvass.routes.ts` | ✅ |
|
||||||
|
| P1-8 | People household endpoints restricted to INFLUENCE_ROLES ∪ MAP_ROLES | `people.routes.ts` | ✅ |
|
||||||
|
| P1-9 | CCP upgrade branch/path validation (SAFE_BRANCH, SAFE_PATH) | `upgrade.service.ts` | ✅ |
|
||||||
|
|
||||||
|
## P2 — Token Security
|
||||||
|
|
||||||
|
| # | Finding | File(s) | Status |
|
||||||
|
|---|---------|---------|--------|
|
||||||
|
| P2-1 | Query-param JWT tokens → HMAC-signed short-lived URLs | `utils/signed-url.ts` (new), `media/middleware/auth.ts`, `video-streaming.routes.ts`, `photos.routes.ts`, `chat-notifications.routes.ts`, `social.routes.ts`, `media/routes/sign.routes.ts` (new), `useChatNotifications.ts`, `useSSE.ts` | ✅ |
|
||||||
|
| P2-2 | `GITEA_SSO_SECRET` & `SERVICE_PASSWORD_SALT` — fallback removed | `env.ts`, provisioners, routes, `.env.example` | ✅ **BREAKING** |
|
||||||
|
| P2-3..5 | Refresh-token device fingerprint + 24h expiry + per-email rate limits + body-fallback removed | `auth.service.ts`, `auth.routes.ts`, `auth.rate-limits.ts`, `utils/device-fingerprint.ts` (new), `env.ts` | ✅ |
|
||||||
|
|
||||||
|
## P3 — Hardening / Defense-in-depth
|
||||||
|
|
||||||
|
| # | Finding | File(s) | Status |
|
||||||
|
|---|---------|---------|--------|
|
||||||
|
| P3-1 | Landing page HTML/CSS — DOMPurify sanitization | `LandingPage.tsx`, `utils/sanitize.ts` | ✅ |
|
||||||
|
| P3-2 | CCP certificate slug + hostname validation | `certificate.service.ts` | ✅ |
|
||||||
|
| P3-3 | `/api/health?detailed=true` disk-space leak — mode removed | `server.ts:218` | ✅ |
|
||||||
|
| P3-4 | Search endpoint tighter rate limits | `search.service.ts` | ⬜ (deferred — low risk) |
|
||||||
|
| P3-5 | Activity feed exposure review | `activity-public.service.ts` | ⬜ (deferred — cached, low risk) |
|
||||||
|
| P3-6 | Moderation notes gated to SUPER_ADMIN | `campaigns.service.ts` | ✅ (folded into P0-5) |
|
||||||
|
| P3-7 | userId removed from reset/verify token logs | `*-token.service.ts` | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breaking Changes Operators Must Handle
|
||||||
|
|
||||||
|
**P2-2** (required restart-blocker):
|
||||||
|
- `GITEA_SSO_SECRET` and `SERVICE_PASSWORD_SALT` are now **required** (≥32 chars).
|
||||||
|
- Previously, an empty value silently fell back to `JWT_ACCESS_SECRET` — a JWT leak compromised SSO cookies + service passwords.
|
||||||
|
- Operators must run `openssl rand -hex 32` twice and set both in `.env` before next restart. Startup fails with a clear Zod error otherwise.
|
||||||
|
- `config.sh` already generates distinct values, so fresh installs are fine; upgrades need manual action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred (none remaining)
|
||||||
|
|
||||||
|
All audit findings have been landed. Remaining `⬜` rows in the P3 table are
|
||||||
|
low-risk polish items (search rate-limit tweak, activity-feed exposure review)
|
||||||
|
tracked for normal maintenance — not shipping blockers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification — End-to-End Tested 2026-04-12
|
||||||
|
|
||||||
|
### Static checks
|
||||||
|
- `api` TypeScript: clean (pre-existing `shifts.service.ts` errors unrelated)
|
||||||
|
- `admin` TypeScript: clean
|
||||||
|
- `changemaker-control-panel/api` TypeScript: clean aside from pre-existing `health.service.ts`
|
||||||
|
|
||||||
|
### Runtime verification (curl against running containers)
|
||||||
|
|
||||||
|
| Finding | Test | Result |
|
||||||
|
|---------|------|--------|
|
||||||
|
| P0-1 | `GET /api/map/locations/public` — shape is `{points:[{lat,lng,count}]}`, 2-decimal bucketing, no address/supportLevel/signSize | ✅ 17 points, banned fields absent |
|
||||||
|
| P0-2 | `GET /api/petitions/:slug/signers` — sample keys only `id/isAnonymous/createdAt` | ✅ no `displayName/signerComment/geoCity/geoCountry` |
|
||||||
|
| P0-3 | `GET /api/petitions/:slug/public-stats` — no `recentSigners` | ✅ keys: total/verified/goal/percentComplete/byCountry/byRegion |
|
||||||
|
| P0-4 | `GET /api/campaigns/:slug/responses` — no submitter PII | ✅ shape clean |
|
||||||
|
| P0-5 | SUPER_ADMIN vs INFLUENCE_ADMIN field-level RBAC on `/campaigns/:id` | ✅ super sees email+modNotes; influence gets 404 via IDOR |
|
||||||
|
| P1-1..5 | Non-owner IDOR on campaign findById/update/delete/emails/email-stats | ✅ all return 404 |
|
||||||
|
| P1-6 | MAP_ADMIN accessing another user's tracking session route | ✅ 404; SUPER_ADMIN passes 200 |
|
||||||
|
| P1-7 | Canvass volunteer stats cross-user access | ✅ 404 for non-owner non-SUPER |
|
||||||
|
| P1-8 | MEDIA_ADMIN blocked from `/api/people/household/:id` | ✅ 403; INFLUENCE_ADMIN passes 200 |
|
||||||
|
| P1-9 / P3-2 | SAFE_BRANCH + SAFE_SLUG reject injection payloads (`;`, `$()`, backticks, `/O=Evil`) | ✅ all blocked |
|
||||||
|
| P2-1 | `POST /api/media/sign` returns signed URL; signed URL authenticates; legacy `?token=JWT` rejected; tampered/expired/cross-path all 401 | ✅ all cases |
|
||||||
|
| P2-2 | Startup fails cleanly when `GITEA_SSO_SECRET`/`SERVICE_PASSWORD_SALT` empty | ✅ confirmed; operator must set both |
|
||||||
|
| P2-3 | Refresh JWT contains `df` claim, 24h expiry | ✅ |
|
||||||
|
| P2-3 | Fingerprint mismatch on refresh revokes all sessions | ✅ 401 FINGERPRINT_MISMATCH |
|
||||||
|
| P2-4 | Refresh token via request body rejected | ✅ 401 (cookie only) |
|
||||||
|
| P2-5 | 4th password-reset request for same (IP, email) → 429; different email same IP → 200 | ✅ composite key works |
|
||||||
|
| P3-1 | LandingPage uses `sanitizeLandingHtml` + `sanitizeLandingCss` | ✅ wired |
|
||||||
|
| P3-3 | `GET /api/health?detailed=true` no longer leaks `diskFreeGB` or `mkdocs` status | ✅ |
|
||||||
|
| P3-7 | "Password reset token created" log line contains no userId cuid | ✅ |
|
||||||
|
|
||||||
|
### Operational changes required to .env (now applied)
|
||||||
|
- `GITEA_SSO_SECRET` — generated via `openssl rand -hex 32`
|
||||||
|
- `SERVICE_PASSWORD_SALT` — generated via `openssl rand -hex 32`
|
||||||
|
- `JWT_REFRESH_EXPIRY` reduced 7d → 24h
|
||||||
|
|
||||||
|
### docker-compose.yml changes
|
||||||
|
- media-api now receives `GITEA_SSO_SECRET`, `SERVICE_PASSWORD_SALT`, `JWT_ACCESS_EXPIRY`, `JWT_REFRESH_EXPIRY` (it shares the api's env schema)
|
||||||
|
- api+media-api `JWT_REFRESH_EXPIRY` default 7d → 24h
|
||||||
|
- api `GITEA_SSO_SECRET` / `SERVICE_PASSWORD_SALT` no longer have `:-` empty-default fallback (required)
|
||||||
|
|
||||||
|
### Post-test runtime additions
|
||||||
|
- `npm install leaflet.heat@^0.2.0` in the admin container (package.json already updated — install just needed to be materialized)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment System Audit (post-hoc, 2026-04-12)
|
||||||
|
|
||||||
|
Confirmed ripple effects across every deployment path:
|
||||||
|
|
||||||
|
| System | Status | Action |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| `config.sh` | ✅ already generates `GITEA_SSO_SECRET` + `SERVICE_PASSWORD_SALT` via `update_env_var_if_empty` (lines 508-519) — fresh installs and re-runs on old installs will fill empty values | No change needed |
|
||||||
|
| `docker-compose.yml` (dev/source installs) | ✅ updated earlier during test plan | Done |
|
||||||
|
| `docker-compose.prod.yml` (release installs) | ✅ **fixed now**: removed `:-` empty fallback for both secrets, default refresh expiry 7d→24h, added both secrets + expiry to media-api service | Done |
|
||||||
|
| `changemaker-control-panel/templates/env.hbs` (CCP-provisioned instances) | ✅ **fixed now**: added `GITEA_SSO_SECRET` + `SERVICE_PASSWORD_SALT` template fields, refresh expiry 7d→24h | Done |
|
||||||
|
| `changemaker-control-panel/api/src/services/secret-generator.ts` | ✅ **fixed now**: `InstanceSecrets` interface + `generateSecrets()` now produce `giteaSsoSecret` + `servicePasswordSalt` | Done |
|
||||||
|
| CCP's own app (separate from Changemaker instances) | ✅ not affected — has its own env schema | No change needed |
|
||||||
|
| `scripts/validate-env.sh`, `scripts/install.sh`, `scripts/upgrade.sh`, `scripts/build-release.sh` | ✅ none reference the changed env vars directly | No change needed |
|
||||||
|
|
||||||
|
### Operator migration paths
|
||||||
|
|
||||||
|
- **Fresh source install:** `config.sh` generates everything correctly. ✅
|
||||||
|
- **Fresh release install:** `install.sh` → `config.sh` generates everything. ✅
|
||||||
|
- **Existing install upgrading:** re-run `./config.sh` to fill empty values, OR manually add both secrets to `.env` via `openssl rand -hex 32`. The Zod validator will emit a clear startup error if either is missing.
|
||||||
|
- **Existing CCP-managed instance:** run `config.sh` on the managed instance's host (same as existing install path). The CCP template regen only applies to *newly provisioned* instances.
|
||||||
6
admin/package-lock.json
generated
6
admin/package-lock.json
generated
@ -36,6 +36,7 @@
|
|||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"minisearch": "^7.2.0",
|
"minisearch": "^7.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@ -2722,6 +2723,11 @@
|
|||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet.heat": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
|
||||||
|
},
|
||||||
"node_modules/leaflet.markercluster": {
|
"node_modules/leaflet.markercluster": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"minisearch": "^7.2.0",
|
"minisearch": "^7.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@ -35,42 +35,48 @@ export function useChatNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use relative URL through nginx proxy
|
// Obtain a short-lived signed URL (2026-04-12). Previously we put the full
|
||||||
const url = `/media/media/notifications/stream?token=${encodeURIComponent(accessToken)}`;
|
// access-token JWT in the URL, which leaked via access logs / referer.
|
||||||
const es = new EventSource(url);
|
let es: EventSource | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
const wire = (src: EventSource) => {
|
||||||
|
src.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'connected') return;
|
||||||
|
if (data.type === 'chat_reply') {
|
||||||
|
const notif: ChatNotification = {
|
||||||
|
...data,
|
||||||
|
id: `notif-${++notifCounterRef.current}-${Date.now()}`,
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
};
|
||||||
|
setNotifications((prev) => [...prev, notif].slice(-10));
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
src.onerror = () => { /* EventSource handles reconnect */ };
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const signRes = await fetch('/media/api/media/sign', {
|
||||||
|
method: 'POST',
|
||||||
if (data.type === 'connected') return;
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
|
||||||
|
body: JSON.stringify({ path: '/api/media/notifications/stream', ttlSeconds: 300 }),
|
||||||
if (data.type === 'chat_reply') {
|
});
|
||||||
const notif: ChatNotification = {
|
if (!signRes.ok || cancelled) return;
|
||||||
...data,
|
const { url: signedPath } = await signRes.json();
|
||||||
id: `notif-${++notifCounterRef.current}-${Date.now()}`,
|
if (cancelled) return;
|
||||||
receivedAt: Date.now(),
|
es = new EventSource(`/media${signedPath}`);
|
||||||
};
|
eventSourceRef.current = es;
|
||||||
|
wire(es);
|
||||||
setNotifications((prev) => {
|
} catch { /* notifications unavailable */ }
|
||||||
// Keep max 10 notifications
|
})();
|
||||||
const updated = [...prev, notif];
|
|
||||||
return updated.slice(-10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
// Auto-reconnect is handled by EventSource
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSourceRef.current = es;
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
es.close();
|
cancelled = true;
|
||||||
|
es?.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, accessToken]);
|
}, [isAuthenticated, accessToken]);
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export function useSSE() {
|
|||||||
useSocialStore.getState().fetchFriends();
|
useSocialStore.getState().fetchFriends();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(async () => {
|
||||||
if (!accessToken || !enableSocial) return;
|
if (!accessToken || !enableSocial) return;
|
||||||
|
|
||||||
// Close existing connection if any
|
// Close existing connection if any
|
||||||
@ -79,36 +79,48 @@ export function useSSE() {
|
|||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `/api/social/sse?token=${encodeURIComponent(accessToken)}`;
|
// Obtain a signed URL instead of embedding the full JWT in the query
|
||||||
|
// string (2026-04-12 — see utils/signed-url.ts for rationale).
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
const signRes = await fetch('/media/api/media/sign', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
|
||||||
|
body: JSON.stringify({ path: '/api/social/sse', ttlSeconds: 300 }),
|
||||||
|
});
|
||||||
|
if (!signRes.ok) throw new Error('sign failed');
|
||||||
|
const { url: signedPath } = await signRes.json();
|
||||||
|
url = signedPath; // absolute path, nginx routes /api/social to Express
|
||||||
|
} catch {
|
||||||
|
return; // SSE unavailable; non-critical
|
||||||
|
}
|
||||||
|
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
esRef.current = es;
|
esRef.current = es;
|
||||||
|
|
||||||
es.addEventListener('connected', () => {
|
es.addEventListener('connected', () => {
|
||||||
retryCount.current = 0; // Reset backoff on successful connect
|
retryCount.current = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener('notification', handleNotification);
|
es.addEventListener('notification', handleNotification);
|
||||||
es.addEventListener('presence', handlePresence);
|
es.addEventListener('presence', handlePresence);
|
||||||
es.addEventListener('friend_request', handleFriendRequest);
|
es.addEventListener('friend_request', handleFriendRequest);
|
||||||
es.addEventListener('friend_accepted', handleFriendAccepted);
|
es.addEventListener('friend_accepted', handleFriendAccepted);
|
||||||
es.addEventListener('poke', handleNotification); // Pokes also increment notification count
|
es.addEventListener('poke', handleNotification);
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
es.close();
|
es.close();
|
||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
|
|
||||||
// Exponential backoff: 1s, 2s, 4s, 8s, ... up to 30s
|
|
||||||
const delay = Math.min(1000 * 2 ** retryCount.current, maxRetryDelay);
|
const delay = Math.min(1000 * 2 ** retryCount.current, maxRetryDelay);
|
||||||
retryCount.current++;
|
retryCount.current++;
|
||||||
|
retryRef.current = setTimeout(() => { void connect(); }, delay);
|
||||||
retryRef.current = setTimeout(connect, delay);
|
|
||||||
};
|
};
|
||||||
}, [accessToken, enableSocial, handleNotification, handlePresence, handleFriendRequest, handleFriendAccepted]);
|
}, [accessToken, enableSocial, handleNotification, handlePresence, handleFriendRequest, handleFriendAccepted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !enableSocial) return;
|
if (!isAuthenticated || !enableSocial) return;
|
||||||
|
|
||||||
connect();
|
void connect();
|
||||||
|
|
||||||
// Fetch initial online friends list
|
// Fetch initial online friends list
|
||||||
useSocialStore.getState().fetchOnlineFriends();
|
useSocialStore.getState().fetchOnlineFriends();
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
|
|||||||
import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget';
|
import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget';
|
||||||
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
||||||
import type { GalleryAd } from '@/types/gallery-ads';
|
import type { GalleryAd } from '@/types/gallery-ads';
|
||||||
|
import { sanitizeLandingHtml, sanitizeLandingCss } from '@/utils/sanitize';
|
||||||
|
|
||||||
export default function PublicLandingPage() {
|
export default function PublicLandingPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
@ -413,12 +414,18 @@ export default function PublicLandingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).
|
// HTML/CSS is admin-authored via GrapesJS, but we still DOMPurify-sanitize as
|
||||||
// Only authenticated admins can create/edit pages, so XSS risk is accepted.
|
// defense-in-depth (2026-04-12): compromised admin accounts / stored XSS via
|
||||||
|
// embeddable widgets would otherwise reach every public visitor.
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}
|
{page.cssOutput && (
|
||||||
<div ref={contentRef} dangerouslySetInnerHTML={{ __html: page.htmlOutput || '' }} />
|
<style dangerouslySetInnerHTML={{ __html: sanitizeLandingCss(page.cssOutput) }} />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeLandingHtml(page.htmlOutput || '') }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { ConfigProvider, Spin, theme, Button, Tooltip, message, Result, Grid } from 'antd';
|
import { ConfigProvider, Spin, theme, Button, Tooltip, message, Result, Grid } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { AimOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
|
import { AimOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
|
||||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
import { MapContainer, TileLayer, CircleMarker, useMap, useMapEvents } from 'react-leaflet';
|
||||||
import type { Map as LeafletMap } from 'leaflet';
|
import L, { type Map as LeafletMap } from 'leaflet';
|
||||||
|
import 'leaflet.heat';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
// Extend Leaflet Map type to include private animation properties
|
// Extend Leaflet Map type to include private animation properties
|
||||||
@ -15,9 +16,7 @@ declare module 'leaflet' {
|
|||||||
}
|
}
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import PublicNavBar from '@/components/PublicNavBar';
|
import PublicNavBar from '@/components/PublicNavBar';
|
||||||
import type { MapSettings, Location, PublicCut, MapEvent } from '@/types/api';
|
import type { MapSettings, PublicCut, MapEvent } from '@/types/api';
|
||||||
import { groupLocations, getMarkerColor } from '@/components/map/mapUtils';
|
|
||||||
import MapLegend from '@/components/map/MapLegend';
|
|
||||||
import CutOverlays from '@/components/map/CutOverlays';
|
import CutOverlays from '@/components/map/CutOverlays';
|
||||||
import CutOverlayControls from '@/components/map/CutOverlayControls';
|
import CutOverlayControls from '@/components/map/CutOverlayControls';
|
||||||
import EventMarkers from '@/components/map/EventMarkers';
|
import EventMarkers from '@/components/map/EventMarkers';
|
||||||
@ -31,6 +30,34 @@ type BoundsQuery = {
|
|||||||
maxLng: number;
|
maxLng: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public map point type — aggregated heatmap bucket, ~1.1km precision.
|
||||||
|
* Individual resident locations are never exposed on the public map.
|
||||||
|
*/
|
||||||
|
type HeatPoint = { lat: number; lng: number; count: number };
|
||||||
|
type HeatmapResponse = { points: HeatPoint[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeatLayer: renders aggregated location buckets as a Leaflet heatmap overlay.
|
||||||
|
* Uses leaflet.heat; maxCount is passed as `max` so colors scale to the current dataset.
|
||||||
|
*/
|
||||||
|
function HeatLayer({ points }: { points: HeatPoint[] }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!points.length) return;
|
||||||
|
const maxCount = points.reduce((m, p) => (p.count > m ? p.count : m), 1);
|
||||||
|
const layer = L.heatLayer(
|
||||||
|
points.map((p) => [p.lat, p.lng, p.count] as [number, number, number]),
|
||||||
|
{ radius: 25, blur: 20, maxZoom: 14, max: maxCount }
|
||||||
|
);
|
||||||
|
layer.addTo(map);
|
||||||
|
return () => {
|
||||||
|
map.removeLayer(layer);
|
||||||
|
};
|
||||||
|
}, [map, points]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function FlyToPosition({ position }: { position: [number, number] }) {
|
function FlyToPosition({ position }: { position: [number, number] }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -93,7 +120,7 @@ function CenterOnSettings({ settings }: { settings: MapSettings | null }) {
|
|||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const [settings, setSettings] = useState<MapSettings | null>(null);
|
const [settings, setSettings] = useState<MapSettings | null>(null);
|
||||||
const [locations, setLocations] = useState<Location[]>([]);
|
const [heatPoints, setHeatPoints] = useState<HeatPoint[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [mapDisabled, setMapDisabled] = useState(false);
|
const [mapDisabled, setMapDisabled] = useState(false);
|
||||||
const [loadingLocations, setLoadingLocations] = useState(false);
|
const [loadingLocations, setLoadingLocations] = useState(false);
|
||||||
@ -132,17 +159,15 @@ export default function MapPage() {
|
|||||||
minLng: b.minLng.toString(),
|
minLng: b.minLng.toString(),
|
||||||
maxLng: b.maxLng.toString(),
|
maxLng: b.maxLng.toString(),
|
||||||
});
|
});
|
||||||
const res = await axios.get<Location[]>(`/api/map/locations/public?${params}`, {
|
const res = await axios.get<HeatmapResponse>(`/api/map/locations/public?${params}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if we hit the safety limit
|
if (res.headers['x-location-bucket-limit-hit'] === 'true') {
|
||||||
const limitHit = res.headers['x-location-limit-hit'] === 'true';
|
|
||||||
if (limitHit) {
|
|
||||||
message.warning('Too many locations in view. Zoom in for more detail.', 3);
|
message.warning('Too many locations in view. Zoom in for more detail.', 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocations(res.data);
|
setHeatPoints(res.data.points ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!axios.isCancel(err)) {
|
if (!axios.isCancel(err)) {
|
||||||
message.error('Failed to load locations');
|
message.error('Failed to load locations');
|
||||||
@ -218,8 +243,6 @@ export default function MapPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const groups = useMemo(() => groupLocations(locations as any), [locations]);
|
|
||||||
|
|
||||||
const center: [number, number] = settings?.latitude && settings?.longitude
|
const center: [number, number] = settings?.latitude && settings?.longitude
|
||||||
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
|
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
|
||||||
: [45.4215, -75.6972];
|
: [45.4215, -75.6972];
|
||||||
@ -402,56 +425,11 @@ export default function MapPage() {
|
|||||||
<EventMarkers events={events} />
|
<EventMarkers events={events} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{settings?.publicShowLocations !== false && groups.map((group, idx) => {
|
{/* Aggregated heatmap (privacy-preserving: ~1.1km buckets, no PII). */}
|
||||||
const color = settings?.publicShowSupportLevels !== false
|
{settings?.publicShowLocations !== false && heatPoints.length > 0 && (
|
||||||
? getMarkerColor(group.dominantLevel)
|
<HeatLayer points={heatPoints} />
|
||||||
: '#888';
|
)}
|
||||||
const radius = group.isMultiUnit ? 10 : 7;
|
|
||||||
const showAddr = settings?.publicShowAddresses !== false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CircleMarker
|
|
||||||
key={idx}
|
|
||||||
center={[group.latitude, group.longitude]}
|
|
||||||
radius={radius}
|
|
||||||
pathOptions={{
|
|
||||||
fillColor: color,
|
|
||||||
fillOpacity: 0.8,
|
|
||||||
color: '#fff',
|
|
||||||
weight: group.isMultiUnit ? 2 : 1,
|
|
||||||
opacity: 0.9,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popup>
|
|
||||||
<div style={{ minWidth: 180, maxWidth: 280 }}>
|
|
||||||
{group.isMultiUnit ? (
|
|
||||||
<>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: '#a02c8d' }}>
|
|
||||||
{showAddr ? (group.location.address || 'Multi-Unit Building') : 'Multi-Unit Location'}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>
|
|
||||||
{group.location.addresses.length} units at this location
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>
|
|
||||||
{showAddr ? (group.location.address || 'Location') : 'Location'}
|
|
||||||
</div>
|
|
||||||
{showAddr && group.location.addresses[0]?.unitNumber && (
|
|
||||||
<div style={{ fontSize: 12, color: '#666' }}>Unit {group.location.addresses[0].unitNumber}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</CircleMarker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
{settings?.publicShowLocations !== false && settings?.publicShowSupportLevels !== false && (
|
|
||||||
<MapLegend variant="public" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cut overlay controls */}
|
{/* Cut overlay controls */}
|
||||||
{settings?.publicShowCuts !== false && cuts.length > 0 && (
|
{settings?.publicShowCuts !== false && cuts.length > 0 && (
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Typography, Card, Button, Input, Checkbox, Form, Spin, Result, Progress,
|
Typography, Card, Button, Input, Checkbox, Form, Spin, Result, Progress,
|
||||||
Space, Divider, List, Avatar, Grid, theme, message,
|
Space, Divider, Grid, theme, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
TeamOutlined, CheckCircleFilled, ShareAltOutlined,
|
TeamOutlined, CheckCircleFilled, ShareAltOutlined,
|
||||||
CopyOutlined, EnvironmentOutlined, ArrowRightOutlined,
|
CopyOutlined, ArrowRightOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@ -31,7 +31,6 @@ export default function PetitionPage() {
|
|||||||
const [state, setState] = useState<PageState>('form');
|
const [state, setState] = useState<PageState>('form');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [signatureCount, setSignatureCount] = useState(0);
|
const [signatureCount, setSignatureCount] = useState(0);
|
||||||
const [recentSigners, setRecentSigners] = useState<any[]>([]);
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { settings: siteSettings } = useSettingsStore();
|
const { settings: siteSettings } = useSettingsStore();
|
||||||
|
|
||||||
@ -42,12 +41,6 @@ export default function PetitionPage() {
|
|||||||
const { data } = await axios.get(`${API}/petitions/${slug}/details`);
|
const { data } = await axios.get(`${API}/petitions/${slug}/details`);
|
||||||
setPetition(data);
|
setPetition(data);
|
||||||
setSignatureCount(data._count.signatures + data.signatureCountOffset);
|
setSignatureCount(data._count.signatures + data.signatureCountOffset);
|
||||||
|
|
||||||
// Fetch recent signers
|
|
||||||
try {
|
|
||||||
const { data: sigData } = await axios.get(`${API}/petitions/${slug}/signers`, { params: { limit: 10 } });
|
|
||||||
setRecentSigners(sigData.signatures || []);
|
|
||||||
} catch { /* non-critical */ }
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
@ -255,31 +248,8 @@ export default function PetitionPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent signers */}
|
{/* Recent-signers card removed 2026-04-12: public endpoint no longer exposes
|
||||||
{petition.showSignerNames && recentSigners.length > 0 && (
|
signer names/locations (privacy hardening). Admins can see the full list. */}
|
||||||
<Card title="Recent Supporters" style={{ marginBottom: 24, background: token.colorBgContainer }}>
|
|
||||||
<List
|
|
||||||
dataSource={recentSigners}
|
|
||||||
renderItem={(signer: any) => (
|
|
||||||
<List.Item style={{ padding: '8px 0' }}>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={<Avatar size="small" icon={<TeamOutlined />} style={{ background: token.colorPrimary }} />}
|
|
||||||
title={signer.displayName || 'Anonymous'}
|
|
||||||
description={
|
|
||||||
<Space size={4}>
|
|
||||||
{(signer.geoCity || signer.geoCountry) && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
<EnvironmentOutlined /> {[signer.geoCity, signer.geoCountry].filter(Boolean).join(', ')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Share bar (always visible) */}
|
{/* Share bar (always visible) */}
|
||||||
{state === 'form' && (
|
{state === 'form' && (
|
||||||
|
|||||||
@ -362,23 +362,11 @@ export default function ResponseWallPage() {
|
|||||||
{response.responseText}
|
{response.responseText}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
{response.userComment && (
|
{/* userComment + submittedByName removed from public response wall
|
||||||
<div style={{
|
on 2026-04-12 (privacy hardening). Admin moderation UI still shows them. */}
|
||||||
marginTop: 12,
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: 'rgba(255,255,255,0.04)',
|
|
||||||
borderRadius: 6,
|
|
||||||
borderLeft: `3px solid ${token.colorPrimary}`,
|
|
||||||
}}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Comment: </Text>
|
|
||||||
<Text style={{ fontSize: 13 }}>{response.userComment}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{response.isAnonymous ? 'Anonymous' : response.submittedByName || 'Anonymous'}
|
|
||||||
{' '}·{' '}
|
|
||||||
{dayjs(response.createdAt).format('MMM D, YYYY')}
|
{dayjs(response.createdAt).format('MMM D, YYYY')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -393,9 +393,10 @@ export interface RepresentativeResponse {
|
|||||||
representativeEmail: string | null;
|
representativeEmail: string | null;
|
||||||
responseType: ResponseType;
|
responseType: ResponseType;
|
||||||
responseText: string;
|
responseText: string;
|
||||||
userComment: string | null;
|
userComment?: string | null; // Admin-only on public response wall (2026-04-12)
|
||||||
submittedByName: string | null;
|
submittedByName?: string | null; // Admin-only on public response wall (2026-04-12)
|
||||||
submittedByEmail: string | null;
|
submittedByEmail?: string | null;
|
||||||
|
|
||||||
isAnonymous: boolean;
|
isAnonymous: boolean;
|
||||||
status: ResponseStatus;
|
status: ResponseStatus;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
@ -3573,7 +3574,7 @@ export interface PetitionStats {
|
|||||||
percentComplete: number | null;
|
percentComplete: number | null;
|
||||||
byCountry: Record<string, number>;
|
byCountry: Record<string, number>;
|
||||||
byRegion: Record<string, number>;
|
byRegion: Record<string, number>;
|
||||||
recentSigners: { displayName: string | null; geoCity: string | null; geoCountry: string | null; createdAt: string }[];
|
// recentSigners removed 2026-04-12: see petitions.service.ts for rationale.
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PetitionsListResponse {
|
export interface PetitionsListResponse {
|
||||||
|
|||||||
26
admin/src/types/leaflet-heat.d.ts
vendored
Normal file
26
admin/src/types/leaflet-heat.d.ts
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Ambient type declaration for leaflet.heat (no @types package published).
|
||||||
|
// See: https://github.com/Leaflet/Leaflet.heat
|
||||||
|
import 'leaflet';
|
||||||
|
|
||||||
|
declare module 'leaflet' {
|
||||||
|
interface HeatLayerOptions {
|
||||||
|
minOpacity?: number;
|
||||||
|
maxZoom?: number;
|
||||||
|
max?: number;
|
||||||
|
radius?: number;
|
||||||
|
blur?: number;
|
||||||
|
gradient?: Record<number, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeatLayer extends Layer {
|
||||||
|
setLatLngs(latlngs: Array<[number, number, number?]>): this;
|
||||||
|
addLatLng(latlng: [number, number, number?]): this;
|
||||||
|
setOptions(options: HeatLayerOptions): this;
|
||||||
|
redraw(): this;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatLayer(
|
||||||
|
latlngs: Array<[number, number, number?]>,
|
||||||
|
options?: HeatLayerOptions
|
||||||
|
): HeatLayer;
|
||||||
|
}
|
||||||
@ -13,3 +13,32 @@ export function sanitizeHtml(dirty: string): string {
|
|||||||
ALLOWED_ATTR: [],
|
ALLOWED_ATTR: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes GrapesJS-authored landing page HTML. Permissive enough to preserve
|
||||||
|
* layout (divs, sections, inline styles, classes, data-attrs) while stripping
|
||||||
|
* <script>, event-handler attributes, and javascript:/data: URLs.
|
||||||
|
*
|
||||||
|
* Added 2026-04-12 as defense-in-depth: though only authenticated admins can
|
||||||
|
* author these pages, a compromised admin account or stored XSS via a widget
|
||||||
|
* embed would otherwise reach every public visitor.
|
||||||
|
*/
|
||||||
|
export function sanitizeLandingHtml(dirty: string): string {
|
||||||
|
return DOMPurify.sanitize(dirty, {
|
||||||
|
// Preserve custom widget placeholders that LandingPage.tsx hydrates via refs.
|
||||||
|
ADD_TAGS: ['video-player', 'advanced-video-player', 'donation-widget',
|
||||||
|
'pricing-widget', 'product-widget', 'campaign-form-widget',
|
||||||
|
'scheduling-poll-widget', 'gallery-ad-card'],
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS sanitizer: strips `@import`, `expression(...)`, and url(javascript:...) vectors. */
|
||||||
|
export function sanitizeLandingCss(dirty: string): string {
|
||||||
|
return dirty
|
||||||
|
.replace(/@import[^;]*;?/gi, '')
|
||||||
|
.replace(/expression\s*\([^)]*\)/gi, '')
|
||||||
|
.replace(/url\s*\(\s*['"]?\s*javascript:[^)]*\)/gi, '');
|
||||||
|
}
|
||||||
|
|||||||
@ -33,15 +33,21 @@ const envSchema = z.object({
|
|||||||
JWT_REFRESH_SECRET: z.string().min(32),
|
JWT_REFRESH_SECRET: z.string().min(32),
|
||||||
JWT_INVITE_SECRET: z.string().min(32),
|
JWT_INVITE_SECRET: z.string().min(32),
|
||||||
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|
||||||
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
// Reduced 2026-04-12 from 7d → 24h. Stolen refresh tokens have a much tighter
|
||||||
|
// exploitation window now; combined with device-fingerprint binding in
|
||||||
|
// auth.service.ts, theft is materially harder to monetize.
|
||||||
|
JWT_REFRESH_EXPIRY: z.string().default('24h'),
|
||||||
|
|
||||||
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
||||||
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
||||||
|
|
||||||
// Gitea SSO cookie signing secret — MUST be unique (key separation from JWT)
|
// Gitea SSO cookie signing secret — MUST be distinct from JWT secrets.
|
||||||
GITEA_SSO_SECRET: z.string().default(''),
|
// Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET, which
|
||||||
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat) — MUST be unique
|
// meant a JWT leak compromised SSO cookies too. Now required (min 32 chars).
|
||||||
SERVICE_PASSWORD_SALT: z.string().default(''),
|
GITEA_SSO_SECRET: z.string().min(32, 'GITEA_SSO_SECRET must be ≥32 chars; generate with: openssl rand -hex 32'),
|
||||||
|
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
|
||||||
|
// Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET. Now required.
|
||||||
|
SERVICE_PASSWORD_SALT: z.string().min(32, 'SERVICE_PASSWORD_SALT must be ≥32 chars; generate with: openssl rand -hex 32'),
|
||||||
|
|
||||||
// Initial Super Admin (auto-created during database seeding)
|
// Initial Super Admin (auto-created during database seeding)
|
||||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||||
@ -276,16 +282,10 @@ function validateEnv(): Env {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn about security-critical key separation issues
|
// GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT are now validated as required
|
||||||
const data = result.data;
|
// via .min(32) above — no more silent JWT_ACCESS_SECRET fallback. If either is
|
||||||
if (!data.GITEA_SSO_SECRET) {
|
// missing, the schema check above exits with a clear error.
|
||||||
console.warn('⚠ SECURITY WARNING: GITEA_SSO_SECRET is empty — falling back to JWT_ACCESS_SECRET. This violates key separation. Generate a unique secret with: openssl rand -hex 32');
|
return result.data;
|
||||||
}
|
|
||||||
if (!data.SERVICE_PASSWORD_SALT) {
|
|
||||||
console.warn('⚠ SECURITY WARNING: SERVICE_PASSWORD_SALT is empty — falling back to JWT_ACCESS_SECRET. Rotating JWT_ACCESS_SECRET will invalidate all provisioned service passwords. Generate a unique salt with: openssl rand -hex 32');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = validateEnv();
|
export const env = validateEnv();
|
||||||
|
|||||||
@ -142,6 +142,9 @@ const start = async () => {
|
|||||||
await fastify.register(chatStreamRoutes, { prefix: '/api' });
|
await fastify.register(chatStreamRoutes, { prefix: '/api' });
|
||||||
await fastify.register(commentAdminRoutes, { prefix: '/api/media' });
|
await fastify.register(commentAdminRoutes, { prefix: '/api/media' });
|
||||||
await fastify.register(chatNotificationsRoutes, { prefix: '/api/media' });
|
await fastify.register(chatNotificationsRoutes, { prefix: '/api/media' });
|
||||||
|
// Signed URL generation (replaces ?token=JWT pattern, 2026-04-12).
|
||||||
|
const { signRoutes } = await import('./modules/media/routes/sign.routes');
|
||||||
|
await fastify.register(signRoutes, { prefix: '/api/media' });
|
||||||
await fastify.register(chatThreadsRoutes, { prefix: '/api/media' });
|
await fastify.register(chatThreadsRoutes, { prefix: '/api/media' });
|
||||||
await fastify.register(userProfileRoutes, { prefix: '/api/media' });
|
await fastify.register(userProfileRoutes, { prefix: '/api/media' });
|
||||||
await fastify.register(fetchRoutes, { prefix: '/api/videos' });
|
await fastify.register(fetchRoutes, { prefix: '/api/videos' });
|
||||||
|
|||||||
@ -1,14 +1,35 @@
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import RedisStore from 'rate-limit-redis';
|
import RedisStore from 'rate-limit-redis';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { redis } from '../../config/redis';
|
import { redis } from '../../config/redis';
|
||||||
|
|
||||||
/** 3 requests per hour for resending verification emails */
|
/**
|
||||||
|
* Generate a rate-limit key combining both IP AND target email (2026-04-12).
|
||||||
|
*
|
||||||
|
* Pure IP rate limits can be bypassed by rotating IPs (easy on mobile/VPN),
|
||||||
|
* and pure email rate limits can be DoS'd by an attacker hitting every known
|
||||||
|
* email from many IPs to lock legitimate users out. Combining both means:
|
||||||
|
* - a single IP can't hammer a single email beyond the limit
|
||||||
|
* - a single IP still can't spray many different emails beyond a wider cap
|
||||||
|
* The email is hashed to keep it out of Redis in plaintext.
|
||||||
|
*/
|
||||||
|
function keyForEmailAndIp(prefix: string) {
|
||||||
|
return (req: Request): string => {
|
||||||
|
const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
|
||||||
|
const emailHash = email ? createHash('sha256').update(email).digest('hex').slice(0, 16) : 'noemail';
|
||||||
|
return `${prefix}:${req.ip}:${emailHash}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3 requests per hour per (IP, email) pair for resending verification emails */
|
||||||
export function createVerificationRateLimit() {
|
export function createVerificationRateLimit() {
|
||||||
return rateLimit({
|
return rateLimit({
|
||||||
windowMs: 60 * 60 * 1000,
|
windowMs: 60 * 60 * 1000,
|
||||||
max: 3,
|
max: 3,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
keyGenerator: keyForEmailAndIp('verify'),
|
||||||
store: new RedisStore({
|
store: new RedisStore({
|
||||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||||
prefix: 'rl:verify-resend:',
|
prefix: 'rl:verify-resend:',
|
||||||
@ -22,13 +43,14 @@ export function createVerificationRateLimit() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 3 requests per hour for password reset emails */
|
/** 3 requests per hour per (IP, email) pair for password reset emails */
|
||||||
export function createResetRateLimit() {
|
export function createResetRateLimit() {
|
||||||
return rateLimit({
|
return rateLimit({
|
||||||
windowMs: 60 * 60 * 1000,
|
windowMs: 60 * 60 * 1000,
|
||||||
max: 3,
|
max: 3,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
keyGenerator: keyForEmailAndIp('reset'),
|
||||||
store: new RedisStore({
|
store: new RedisStore({
|
||||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||||
prefix: 'rl:password-reset:',
|
prefix: 'rl:password-reset:',
|
||||||
|
|||||||
@ -17,11 +17,12 @@ import { env } from '../../config/env';
|
|||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { createVerificationRateLimit, createResetRateLimit } from './auth.rate-limits';
|
import { createVerificationRateLimit, createResetRateLimit } from './auth.rate-limits';
|
||||||
import { profileService } from '../people/profile.service';
|
import { profileService } from '../people/profile.service';
|
||||||
|
import { computeDeviceFingerprint } from '../../utils/device-fingerprint';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
||||||
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
const REFRESH_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours in ms (matches JWT_REFRESH_EXPIRY default)
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'cml_session';
|
const SESSION_COOKIE_NAME = 'cml_session';
|
||||||
const SESSION_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 min buffer (JWT inside enforces 15min expiry)
|
const SESSION_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 min buffer (JWT inside enforces 15min expiry)
|
||||||
@ -77,7 +78,7 @@ async function setSessionCookie(req: Request, res: Response, userId: string) {
|
|||||||
const giteaUser = permissions._giteaUsername as string | undefined;
|
const giteaUser = permissions._giteaUsername as string | undefined;
|
||||||
if (!giteaUser) return; // Not provisioned — skip
|
if (!giteaUser) return; // Not provisioned — skip
|
||||||
|
|
||||||
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
|
const ssoSecret = env.GITEA_SSO_SECRET;
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ sub: userId, giteaUser },
|
{ sub: userId, giteaUser },
|
||||||
ssoSecret,
|
ssoSecret,
|
||||||
@ -103,7 +104,7 @@ router.post(
|
|||||||
validate(loginSchema),
|
validate(loginSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await authService.login(req.body.email, req.body.password);
|
const result = await authService.login(req.body.email, req.body.password, computeDeviceFingerprint(req));
|
||||||
// Set refresh token as httpOnly cookie (not in response body)
|
// Set refresh token as httpOnly cookie (not in response body)
|
||||||
setRefreshCookie(req, res, result.refreshToken);
|
setRefreshCookie(req, res, result.refreshToken);
|
||||||
// Set SSO session cookie for Gitea reverse proxy auth
|
// Set SSO session cookie for Gitea reverse proxy auth
|
||||||
@ -123,7 +124,7 @@ router.post(
|
|||||||
validate(registerSchema),
|
validate(registerSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await authService.register(req.body);
|
const result = await authService.register(req.body, computeDeviceFingerprint(req));
|
||||||
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
|
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
|
||||||
if ('refreshToken' in result && result.refreshToken) {
|
if ('refreshToken' in result && result.refreshToken) {
|
||||||
setRefreshCookie(req, res, result.refreshToken);
|
setRefreshCookie(req, res, result.refreshToken);
|
||||||
@ -320,18 +321,20 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/auth/refresh
|
// POST /api/auth/refresh
|
||||||
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
// Accepts refresh token from httpOnly cookie ONLY (2026-04-12: the legacy
|
||||||
|
// request-body fallback was removed — cookies are HttpOnly+SameSite and
|
||||||
|
// cannot be read by XSS, while body tokens were reachable via any XSS).
|
||||||
router.post(
|
router.post(
|
||||||
'/refresh',
|
'/refresh',
|
||||||
authRateLimit,
|
authRateLimit,
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
|
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME];
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
res.status(401).json({ error: { message: 'No refresh token', code: 'INVALID_REFRESH_TOKEN' } });
|
res.status(401).json({ error: { message: 'No refresh token', code: 'INVALID_REFRESH_TOKEN' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await authService.refreshTokens(refreshToken);
|
const result = await authService.refreshTokens(refreshToken, computeDeviceFingerprint(req));
|
||||||
// Set new refresh token as httpOnly cookie
|
// Set new refresh token as httpOnly cookie
|
||||||
setRefreshCookie(req, res, result.refreshToken);
|
setRefreshCookie(req, res, result.refreshToken);
|
||||||
// Renew SSO session cookie for Gitea reverse proxy auth
|
// Renew SSO session cookie for Gitea reverse proxy auth
|
||||||
@ -347,14 +350,13 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/auth/logout
|
// POST /api/auth/logout — cookie only (2026-04-12).
|
||||||
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
|
||||||
router.post(
|
router.post(
|
||||||
'/logout',
|
'/logout',
|
||||||
authRateLimit,
|
authRateLimit,
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
|
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME];
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
await authService.logout(refreshToken);
|
await authService.logout(refreshToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { verificationTokenService } from '../../services/verification-token.serv
|
|||||||
import { emailService } from '../../services/email.service';
|
import { emailService } from '../../services/email.service';
|
||||||
import { getPrimaryRole } from '../../utils/roles';
|
import { getPrimaryRole } from '../../utils/roles';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import { fingerprintsMatch } from '../../utils/device-fingerprint';
|
||||||
import type { RegisterInput } from './auth.schemas';
|
import type { RegisterInput } from './auth.schemas';
|
||||||
|
|
||||||
interface TokenPayload {
|
interface TokenPayload {
|
||||||
@ -17,6 +18,8 @@ interface TokenPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
roles: UserRole[];
|
roles: UserRole[];
|
||||||
|
/** Device fingerprint (sha256 of UA + /24 IP subnet) — bound at issue time. */
|
||||||
|
df?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
@ -35,7 +38,7 @@ function parseRoles(user: UserForToken): UserRole[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string, fingerprint?: string) {
|
||||||
const user = await prisma.user.findUnique({ where: { email } });
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
recordLoginAttempt('failure');
|
recordLoginAttempt('failure');
|
||||||
@ -94,13 +97,13 @@ export const authService = {
|
|||||||
logger.warn('Login activity logging failed:', err);
|
logger.warn('Login activity logging failed:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = await this.generateTokenPair(user);
|
const tokens = await this.generateTokenPair(user, fingerprint);
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
return { user: userWithoutPassword, ...tokens };
|
return { user: userWithoutPassword, ...tokens };
|
||||||
},
|
},
|
||||||
|
|
||||||
async register(data: RegisterInput) {
|
async register(data: RegisterInput, fingerprint?: string) {
|
||||||
// Check if public registration is enabled
|
// Check if public registration is enabled
|
||||||
const settings = await siteSettingsService.get();
|
const settings = await siteSettingsService.get();
|
||||||
if (!settings.enablePublicRegistration) {
|
if (!settings.enablePublicRegistration) {
|
||||||
@ -192,13 +195,13 @@ export const authService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No verification needed — issue tokens immediately
|
// No verification needed — issue tokens immediately
|
||||||
const tokens = await this.generateTokenPair(user);
|
const tokens = await this.generateTokenPair(user, fingerprint);
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
return { user: userWithoutPassword, ...tokens };
|
return { user: userWithoutPassword, ...tokens };
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshTokens(refreshToken: string) {
|
async refreshTokens(refreshToken: string, currentFingerprint?: string) {
|
||||||
let payload: TokenPayload;
|
let payload: TokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
@ -206,6 +209,18 @@ export const authService = {
|
|||||||
throw new AppError(401, 'Invalid refresh token', 'INVALID_REFRESH_TOKEN');
|
throw new AppError(401, 'Invalid refresh token', 'INVALID_REFRESH_TOKEN');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device-fingerprint binding (2026-04-12). Tokens issued after this change
|
||||||
|
// include a `df` claim; reject if the refreshing client's fingerprint doesn't
|
||||||
|
// match. Tokens issued before this change have no `df` — accept them once,
|
||||||
|
// then rotate into a bound token below (grace period for existing sessions).
|
||||||
|
if (payload.df && currentFingerprint && !fingerprintsMatch(payload.df, currentFingerprint)) {
|
||||||
|
// Potential token theft — revoke all refresh tokens for this user as a
|
||||||
|
// defense-in-depth measure, then deny.
|
||||||
|
await prisma.refreshToken.deleteMany({ where: { userId: payload.id } });
|
||||||
|
logger.warn('Refresh token fingerprint mismatch; all sessions revoked');
|
||||||
|
throw new AppError(401, 'Session security check failed', 'FINGERPRINT_MISMATCH');
|
||||||
|
}
|
||||||
|
|
||||||
const stored = await prisma.refreshToken.findUnique({
|
const stored = await prisma.refreshToken.findUnique({
|
||||||
where: { token: refreshToken },
|
where: { token: refreshToken },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
@ -243,6 +258,9 @@ export const authService = {
|
|||||||
email: stored.user.email,
|
email: stored.user.email,
|
||||||
role: getPrimaryRole(userRoles),
|
role: getPrimaryRole(userRoles),
|
||||||
roles: userRoles,
|
roles: userRoles,
|
||||||
|
// Carry forward fingerprint from the client's current request so rotated
|
||||||
|
// tokens stay bound to the device.
|
||||||
|
df: currentFingerprint,
|
||||||
};
|
};
|
||||||
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
|
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
|
||||||
algorithm: 'HS256',
|
algorithm: 'HS256',
|
||||||
@ -286,13 +304,14 @@ export const authService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateRefreshToken(user: UserForToken): Promise<string> {
|
async generateRefreshToken(user: UserForToken, fingerprint?: string): Promise<string> {
|
||||||
const userRoles = parseRoles(user);
|
const userRoles = parseRoles(user);
|
||||||
const payload: TokenPayload = {
|
const payload: TokenPayload = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: getPrimaryRole(userRoles),
|
role: getPrimaryRole(userRoles),
|
||||||
roles: userRoles,
|
roles: userRoles,
|
||||||
|
df: fingerprint,
|
||||||
};
|
};
|
||||||
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
|
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
|
||||||
algorithm: 'HS256',
|
algorithm: 'HS256',
|
||||||
@ -313,9 +332,9 @@ export const authService = {
|
|||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateTokenPair(user: UserForToken): Promise<TokenPair> {
|
async generateTokenPair(user: UserForToken, fingerprint?: string): Promise<TokenPair> {
|
||||||
const accessToken = this.generateAccessToken(user);
|
const accessToken = this.generateAccessToken(user);
|
||||||
const refreshToken = await this.generateRefreshToken(user);
|
const refreshToken = await this.generateRefreshToken(user, fingerprint);
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -29,7 +29,7 @@ router.get('/gitea-sso-validate', (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
|
const ssoSecret = env.GITEA_SSO_SECRET;
|
||||||
const payload = jwt.verify(token, ssoSecret, {
|
const payload = jwt.verify(token, ssoSecret, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
}) as SsoPayload;
|
}) as SsoPayload;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { campaignEmailsService } from './campaign-emails.service';
|
import { campaignEmailsService } from './campaign-emails.service';
|
||||||
|
import { campaignsService } from '../campaigns/campaigns.service';
|
||||||
import {
|
import {
|
||||||
sendCampaignEmailSchema,
|
sendCampaignEmailSchema,
|
||||||
trackMailtoSchema,
|
trackMailtoSchema,
|
||||||
@ -53,13 +54,15 @@ const adminRouter = Router();
|
|||||||
adminRouter.use(authenticate);
|
adminRouter.use(authenticate);
|
||||||
adminRouter.use(requireRole(...INFLUENCE_ROLES));
|
adminRouter.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/campaigns/:id/emails
|
// GET /api/campaigns/:id/emails — requires ownership (SUPER_ADMIN bypasses)
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
'/:id/emails',
|
'/:id/emails',
|
||||||
validate(listCampaignEmailsSchema, 'query'),
|
validate(listCampaignEmailsSchema, 'query'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
// Access check via campaignsService — throws 404 if not owned.
|
||||||
|
await campaignsService.findById(id, req.user!);
|
||||||
const result = await campaignEmailsService.listByCampaign(id, req.query as any);
|
const result = await campaignEmailsService.listByCampaign(id, req.query as any);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -68,12 +71,13 @@ adminRouter.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /api/campaigns/:id/email-stats
|
// GET /api/campaigns/:id/email-stats — requires ownership (SUPER_ADMIN bypasses)
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
'/:id/email-stats',
|
'/:id/email-stats',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
await campaignsService.findById(id, req.user!);
|
||||||
const stats = await campaignEmailsService.getStats(id);
|
const stats = await campaignEmailsService.getStats(id);
|
||||||
res.json(stats);
|
res.json(stats);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ router.get(
|
|||||||
validate(listModerationQueueSchema, 'query'),
|
validate(listModerationQueueSchema, 'query'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await campaignsService.findModerationQueue(req.query as any);
|
const result = await campaignsService.findModerationQueue(req.query as any, req.user!);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@ -46,7 +46,7 @@ router.patch(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const before = await campaignsService.findById(id);
|
const before = await campaignsService.findById(id, req.user!);
|
||||||
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
|
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
|
||||||
eventBus.publish('campaign.status.changed', {
|
eventBus.publish('campaign.status.changed', {
|
||||||
campaignId: campaign.id,
|
campaignId: campaign.id,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ router.get(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const campaign = await campaignsService.findById(id);
|
const campaign = await campaignsService.findById(id, req.user!);
|
||||||
res.json(campaign);
|
res.json(campaign);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@ -68,7 +68,7 @@ router.put(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const campaign = await campaignsService.update(id, req.body);
|
const campaign = await campaignsService.update(id, req.body, req.user!);
|
||||||
eventBus.publish('campaign.updated', {
|
eventBus.publish('campaign.updated', {
|
||||||
campaignId: campaign.id,
|
campaignId: campaign.id,
|
||||||
title: campaign.title,
|
title: campaign.title,
|
||||||
@ -88,8 +88,8 @@ router.delete(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const campaign = await campaignsService.findById(id);
|
const campaign = await campaignsService.findById(id, req.user!);
|
||||||
await campaignsService.delete(id);
|
await campaignsService.delete(id, req.user!);
|
||||||
eventBus.publish('campaign.deleted', {
|
eventBus.publish('campaign.deleted', {
|
||||||
campaignId: campaign.id,
|
campaignId: campaign.id,
|
||||||
title: campaign.title,
|
title: campaign.title,
|
||||||
|
|||||||
@ -16,7 +16,13 @@ function escapeHtml(unsafe: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignSelect = {
|
/**
|
||||||
|
* SUPER_ADMIN-only select: includes creator email and internal moderation fields
|
||||||
|
* (notes, reviewer ID, rejection reason). These are deliberately hidden from
|
||||||
|
* other admin roles to prevent cross-admin PII/moderation-intel leakage.
|
||||||
|
* Split from single `campaignSelect` on 2026-04-12.
|
||||||
|
*/
|
||||||
|
const superAdminCampaignSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
title: true,
|
title: true,
|
||||||
@ -56,6 +62,64 @@ const campaignSelect = {
|
|||||||
},
|
},
|
||||||
} satisfies Prisma.CampaignSelect;
|
} satisfies Prisma.CampaignSelect;
|
||||||
|
|
||||||
|
/** Non-super-admin select: excludes creator email + internal moderation fields. */
|
||||||
|
const adminCampaignSelect = {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
emailSubject: true,
|
||||||
|
emailBody: true,
|
||||||
|
callToAction: true,
|
||||||
|
coverPhoto: true,
|
||||||
|
coverVideoId: true,
|
||||||
|
status: true,
|
||||||
|
allowSmtpEmail: true,
|
||||||
|
allowMailtoLink: true,
|
||||||
|
collectUserInfo: true,
|
||||||
|
showEmailCount: true,
|
||||||
|
showCallCount: true,
|
||||||
|
allowEmailEditing: true,
|
||||||
|
allowCustomRecipients: true,
|
||||||
|
showResponseWall: true,
|
||||||
|
highlightCampaign: true,
|
||||||
|
targetGovernmentLevels: true,
|
||||||
|
createdByUserId: true,
|
||||||
|
createdByUserName: true,
|
||||||
|
isUserGenerated: true,
|
||||||
|
moderationStatus: true,
|
||||||
|
reviewedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
emails: true,
|
||||||
|
responses: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.CampaignSelect;
|
||||||
|
|
||||||
|
function pickCampaignSelect(user?: { role: UserRole } | null) {
|
||||||
|
return user?.role === UserRole.SUPER_ADMIN ? superAdminCampaignSelect : adminCampaignSelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ownership enforcement for admin endpoints that mutate or expose single-campaign
|
||||||
|
* data. SUPER_ADMIN bypasses; any other admin must own the campaign. Throws 404
|
||||||
|
* (not 403) to avoid leaking which campaign IDs exist. Added 2026-04-12.
|
||||||
|
*/
|
||||||
|
async function assertCampaignAccess(id: string, user: AuthUser): Promise<void> {
|
||||||
|
const c = await prisma.campaign.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { createdByUserId: true },
|
||||||
|
});
|
||||||
|
if (!c) throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
||||||
|
if (user.role === UserRole.SUPER_ADMIN) return;
|
||||||
|
if (c.createdByUserId !== user.id) {
|
||||||
|
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Public-facing select — strips admin-only fields (emails, internal IDs, moderation notes) */
|
/** Public-facing select — strips admin-only fields (emails, internal IDs, moderation notes) */
|
||||||
const publicCampaignSelect = {
|
const publicCampaignSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@ -148,7 +212,7 @@ export const campaignsService = {
|
|||||||
const [campaigns, total] = await Promise.all([
|
const [campaigns, total] = await Promise.all([
|
||||||
prisma.campaign.findMany({
|
prisma.campaign.findMany({
|
||||||
where,
|
where,
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@ -167,10 +231,12 @@ export const campaignsService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async findById(id: string) {
|
/** Fetch a campaign by ID with ownership enforcement. SUPER_ADMIN bypasses. */
|
||||||
|
async findById(id: string, user: AuthUser) {
|
||||||
|
await assertCampaignAccess(id, user);
|
||||||
const campaign = await prisma.campaign.findUnique({
|
const campaign = await prisma.campaign.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
@ -180,16 +246,21 @@ export const campaignsService = {
|
|||||||
return campaign;
|
return campaign;
|
||||||
},
|
},
|
||||||
|
|
||||||
async findBySlug(slug: string) {
|
/** Fetch by slug (admin path). Still enforces ownership. */
|
||||||
|
async findBySlug(slug: string, user: AuthUser) {
|
||||||
const campaign = await prisma.campaign.findUnique({
|
const campaign = await prisma.campaign.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.role !== UserRole.SUPER_ADMIN && campaign.createdByUserId !== user.id) {
|
||||||
|
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
return campaign;
|
return campaign;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -219,13 +290,16 @@ export const campaignsService = {
|
|||||||
createdByUserEmail: user.email,
|
createdByUserEmail: user.email,
|
||||||
createdByUserName: dbUser?.name ?? null,
|
createdByUserName: dbUser?.name ?? null,
|
||||||
},
|
},
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
return campaign;
|
return campaign;
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id: string, data: UpdateCampaignInput) {
|
async update(id: string, data: UpdateCampaignInput, user: AuthUser) {
|
||||||
|
// Ownership check (SUPER_ADMIN bypasses). Prevents cross-admin campaign edits.
|
||||||
|
await assertCampaignAccess(id, user);
|
||||||
|
|
||||||
const existing = await prisma.campaign.findUnique({ where: { id } });
|
const existing = await prisma.campaign.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
||||||
@ -250,7 +324,7 @@ export const campaignsService = {
|
|||||||
const campaign = await prisma.campaign.update({
|
const campaign = await prisma.campaign.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
return campaign;
|
return campaign;
|
||||||
@ -297,12 +371,9 @@ export const campaignsService = {
|
|||||||
return campaign;
|
return campaign;
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string, user: AuthUser) {
|
||||||
const existing = await prisma.campaign.findUnique({ where: { id } });
|
// Ownership check (SUPER_ADMIN bypasses).
|
||||||
if (!existing) {
|
await assertCampaignAccess(id, user);
|
||||||
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.campaign.delete({ where: { id } });
|
await prisma.campaign.delete({ where: { id } });
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -342,16 +413,17 @@ export const campaignsService = {
|
|||||||
createdByUserEmail: user.email,
|
createdByUserEmail: user.email,
|
||||||
createdByUserName: dbUser?.name ?? null,
|
createdByUserName: dbUser?.name ?? null,
|
||||||
},
|
},
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
return campaign;
|
return campaign;
|
||||||
},
|
},
|
||||||
|
|
||||||
async findUserCampaigns(userId: string) {
|
async findUserCampaigns(userId: string) {
|
||||||
|
// Self-view endpoint — safe to use reduced select (user already knows own email).
|
||||||
return prisma.campaign.findMany({
|
return prisma.campaign.findMany({
|
||||||
where: { createdByUserId: userId, isUserGenerated: true },
|
where: { createdByUserId: userId, isUserGenerated: true },
|
||||||
select: campaignSelect,
|
select: adminCampaignSelect,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -393,13 +465,13 @@ export const campaignsService = {
|
|||||||
return prisma.campaign.update({
|
return prisma.campaign.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Moderation Methods ---
|
// --- Moderation Methods ---
|
||||||
|
|
||||||
async findModerationQueue(filters: ListModerationQueueInput) {
|
async findModerationQueue(filters: ListModerationQueueInput, user: AuthUser) {
|
||||||
const { page, limit, search, moderationStatus } = filters;
|
const { page, limit, search, moderationStatus } = filters;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
@ -416,7 +488,7 @@ export const campaignsService = {
|
|||||||
const [campaigns, total] = await Promise.all([
|
const [campaigns, total] = await Promise.all([
|
||||||
prisma.campaign.findMany({
|
prisma.campaign.findMany({
|
||||||
where,
|
where,
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(user),
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@ -475,7 +547,7 @@ export const campaignsService = {
|
|||||||
return prisma.campaign.update({
|
return prisma.campaign.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
select: campaignSelect,
|
select: pickCampaignSelect(reviewer),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -542,10 +542,18 @@ export const petitionsService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public signature feed — returns only opaque record IDs + timestamps + anonymity flag.
|
||||||
|
* PII fields (displayName, signerComment, geoCity, geoCountry) are NEVER exposed
|
||||||
|
* on the public endpoint, regardless of the petition's `showSignerNames` setting.
|
||||||
|
* Admins can still view full signer data via the authenticated admin endpoint.
|
||||||
|
* Hardened 2026-04-12 after red-team audit found this vector was being scraped
|
||||||
|
* to build activist dossiers.
|
||||||
|
*/
|
||||||
async listSignaturesPublic(slug: string, page: number = 1, limit: number = 20) {
|
async listSignaturesPublic(slug: string, page: number = 1, limit: number = 20) {
|
||||||
const petition = await prisma.petition.findFirst({
|
const petition = await prisma.petition.findFirst({
|
||||||
where: { slug, status: 'ACTIVE' },
|
where: { slug, status: 'ACTIVE' },
|
||||||
select: { id: true, showSignerNames: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
|
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
|
||||||
@ -560,11 +568,7 @@ export const petitionsService = {
|
|||||||
where,
|
where,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
displayName: petition.showSignerNames,
|
|
||||||
signerComment: true,
|
|
||||||
isAnonymous: true,
|
isAnonymous: true,
|
||||||
geoCity: true,
|
|
||||||
geoCountry: true,
|
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@ -575,10 +579,7 @@ export const petitionsService = {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
signatures: signatures.map(s => ({
|
signatures,
|
||||||
...s,
|
|
||||||
displayName: petition.showSignerNames ? s.displayName : null,
|
|
||||||
})),
|
|
||||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -628,7 +629,7 @@ export const petitionsService = {
|
|||||||
status: { in: ['VERIFIED' as const, 'UNVERIFIED' as const] },
|
status: { in: ['VERIFIED' as const, 'UNVERIFIED' as const] },
|
||||||
};
|
};
|
||||||
|
|
||||||
const [total, byCountry, byRegion, recentSigners] = await Promise.all([
|
const [total, byCountry, byRegion] = await Promise.all([
|
||||||
prisma.petitionSignature.count({ where: countWhere }),
|
prisma.petitionSignature.count({ where: countWhere }),
|
||||||
prisma.petitionSignature.groupBy({
|
prisma.petitionSignature.groupBy({
|
||||||
by: ['geoCountry'],
|
by: ['geoCountry'],
|
||||||
@ -644,16 +645,13 @@ export const petitionsService = {
|
|||||||
orderBy: { _count: { geoRegion: 'desc' } },
|
orderBy: { _count: { geoRegion: 'desc' } },
|
||||||
take: 20,
|
take: 20,
|
||||||
}),
|
}),
|
||||||
prisma.petitionSignature.findMany({
|
|
||||||
where: countWhere,
|
|
||||||
select: { displayName: true, geoCity: true, geoCountry: true, createdAt: true },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 10,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const displayTotal = total + petition.signatureCountOffset;
|
const displayTotal = total + petition.signatureCountOffset;
|
||||||
|
|
||||||
|
// NOTE: `recentSigners` (names + cities) was removed 2026-04-12 to prevent
|
||||||
|
// unauthenticated scraping of activist identities. Admin UIs should fetch
|
||||||
|
// signer details via the authenticated admin signatures endpoint.
|
||||||
return {
|
return {
|
||||||
total: displayTotal,
|
total: displayTotal,
|
||||||
verified: total,
|
verified: total,
|
||||||
@ -663,7 +661,6 @@ export const petitionsService = {
|
|||||||
: null,
|
: null,
|
||||||
byCountry: Object.fromEntries(byCountry.map(c => [c.geoCountry, c._count])),
|
byCountry: Object.fromEntries(byCountry.map(c => [c.geoCountry, c._count])),
|
||||||
byRegion: Object.fromEntries(byRegion.map(r => [r.geoRegion, r._count])),
|
byRegion: Object.fromEntries(byRegion.map(r => [r.geoRegion, r._count])),
|
||||||
recentSigners,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -146,6 +146,9 @@ export const responsesService = {
|
|||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
// NOTE: `userComment` and `submittedByName` were removed from the public
|
||||||
|
// select on 2026-04-12 after red-team audit found submitter identities were
|
||||||
|
// being scraped. Admin moderation views (authenticated) still see them.
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
representativeName: true,
|
representativeName: true,
|
||||||
@ -153,8 +156,6 @@ export const responsesService = {
|
|||||||
representativeLevel: true,
|
representativeLevel: true,
|
||||||
responseType: true,
|
responseType: true,
|
||||||
responseText: true,
|
responseText: true,
|
||||||
userComment: true,
|
|
||||||
submittedByName: true,
|
|
||||||
isAnonymous: true,
|
isAnonymous: true,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
verifiedAt: true,
|
verifiedAt: true,
|
||||||
|
|||||||
@ -338,12 +338,18 @@ adminRouter.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /api/map/canvass/volunteers/:userId
|
// GET /api/map/canvass/volunteers/:userId — tightened 2026-04-12.
|
||||||
|
// Only SUPER_ADMIN or the subject volunteer can view per-volunteer canvass stats
|
||||||
|
// (which include visit locations and can reconstruct movement patterns).
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
'/volunteers/:userId',
|
'/volunteers/:userId',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.params.userId as string;
|
const userId = req.params.userId as string;
|
||||||
|
if (req.user!.role !== 'SUPER_ADMIN' && userId !== req.user!.id) {
|
||||||
|
const { AppError } = await import('../../../middleware/error-handler');
|
||||||
|
throw new AppError(404, 'Volunteer not found', 'VOLUNTEER_NOT_FOUND');
|
||||||
|
}
|
||||||
const stats = await canvassService.getVolunteerStats(userId);
|
const stats = await canvassService.getVolunteerStats(userId);
|
||||||
res.json(stats);
|
res.json(stats);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -331,7 +331,7 @@ adminRouter.post(
|
|||||||
// --- Public Router ---
|
// --- Public Router ---
|
||||||
const publicRouter = Router();
|
const publicRouter = Router();
|
||||||
|
|
||||||
// GET /api/map/locations/public — all locations for map (no PII)
|
// GET /api/map/locations/public — aggregated heatmap (no PII, ~1.1km buckets)
|
||||||
publicRouter.get(
|
publicRouter.get(
|
||||||
'/public',
|
'/public',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@ -343,14 +343,13 @@ publicRouter.get(
|
|||||||
maxLng: parseFloat(req.query.maxLng as string),
|
maxLng: parseFloat(req.query.maxLng as string),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
const locations = await locationsService.getPublicLocations(bounds);
|
const heatmap = await locationsService.getPublicHeatmap(bounds);
|
||||||
|
|
||||||
// Add header if we hit the safety limit
|
if (heatmap.points.length === 10000) {
|
||||||
if (locations.length === 5000) {
|
res.setHeader('X-Location-Bucket-Limit-Hit', 'true');
|
||||||
res.setHeader('X-Location-Limit-Hit', 'true');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(locations);
|
res.json(heatmap);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { geocodingService } from '../geocoding/geocoding.service';
|
|||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { recordLocationQuery } from '../../../utils/metrics';
|
import { recordLocationQuery } from '../../../utils/metrics';
|
||||||
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
|
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
|
||||||
import { mapSettingsService } from '../settings/settings.service';
|
|
||||||
import type { CreateLocationInput, UpdateLocationInput, ListLocationsInput, BulkImportInput } from './locations.schemas';
|
import type { CreateLocationInput, UpdateLocationInput, ListLocationsInput, BulkImportInput } from './locations.schemas';
|
||||||
|
|
||||||
// Statistics Canada Lambert Conformal Conic projection (EPSG:3347) → WGS84 (EPSG:4326)
|
// Statistics Canada Lambert Conformal Conic projection (EPSG:3347) → WGS84 (EPSG:4326)
|
||||||
@ -735,63 +734,48 @@ export const locationsService = {
|
|||||||
return locations;
|
return locations;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPublicLocations(bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }) {
|
/**
|
||||||
|
* Public heatmap aggregate: buckets locations to ~1.1km precision (2 decimal places
|
||||||
|
* of lat/lng) and returns counts only. No PII (addresses, support levels, signs,
|
||||||
|
* unit numbers) is exposed to unauthenticated callers.
|
||||||
|
*
|
||||||
|
* Previously this endpoint returned raw coordinates + support levels + sign data,
|
||||||
|
* which let adversaries build targeting databases of supporters. Hardened 2026-04-12.
|
||||||
|
*/
|
||||||
|
async getPublicHeatmap(bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const where: Prisma.LocationWhereInput = {};
|
|
||||||
|
|
||||||
if (bounds) {
|
// Build bounds filter as SQL fragment (Prisma parameterizes via $queryRaw tagged template).
|
||||||
// Fix Decimal type handling - convert bounds to Prisma.Decimal
|
// We use ROUND(..., 2) — 2 decimal places ≈ 1.1km at the equator. Buckets aggregate many
|
||||||
where.latitude = {
|
// individual addresses into a single heatmap point, preventing reverse-lookup of residents.
|
||||||
gte: new Prisma.Decimal(bounds.minLat.toString()),
|
const rows = bounds
|
||||||
lte: new Prisma.Decimal(bounds.maxLat.toString()),
|
? await prisma.$queryRaw<Array<{ lat: number; lng: number; count: bigint }>>`
|
||||||
};
|
SELECT
|
||||||
where.longitude = {
|
ROUND(latitude::numeric, 2)::float8 AS lat,
|
||||||
gte: new Prisma.Decimal(bounds.minLng.toString()),
|
ROUND(longitude::numeric, 2)::float8 AS lng,
|
||||||
lte: new Prisma.Decimal(bounds.maxLng.toString()),
|
COUNT(*)::bigint AS count
|
||||||
};
|
FROM "locations"
|
||||||
}
|
WHERE latitude BETWEEN ${bounds.minLat}::numeric AND ${bounds.maxLat}::numeric
|
||||||
|
AND longitude BETWEEN ${bounds.minLng}::numeric AND ${bounds.maxLng}::numeric
|
||||||
|
GROUP BY 1, 2
|
||||||
|
LIMIT 10000
|
||||||
|
`
|
||||||
|
: await prisma.$queryRaw<Array<{ lat: number; lng: number; count: bigint }>>`
|
||||||
|
SELECT
|
||||||
|
ROUND(latitude::numeric, 2)::float8 AS lat,
|
||||||
|
ROUND(longitude::numeric, 2)::float8 AS lng,
|
||||||
|
COUNT(*)::bigint AS count
|
||||||
|
FROM "locations"
|
||||||
|
GROUP BY 1, 2
|
||||||
|
LIMIT 10000
|
||||||
|
`;
|
||||||
|
|
||||||
const locations = await prisma.location.findMany({
|
const points = rows.map((r) => ({ lat: r.lat, lng: r.lng, count: Number(r.count) }));
|
||||||
where,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
latitude: true,
|
|
||||||
longitude: true,
|
|
||||||
address: true,
|
|
||||||
addresses: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
unitNumber: true,
|
|
||||||
supportLevel: true,
|
|
||||||
sign: true,
|
|
||||||
signSize: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: 5000, // Safety limit
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server-side enforcement: strip sensitive fields based on map visibility settings
|
|
||||||
const mapSettings = await mapSettingsService.get();
|
|
||||||
|
|
||||||
if (!mapSettings.publicShowSupportLevels || !mapSettings.publicShowSignInfo) {
|
|
||||||
for (const loc of locations) {
|
|
||||||
for (const addr of loc.addresses) {
|
|
||||||
if (!mapSettings.publicShowSupportLevels) {
|
|
||||||
(addr as any).supportLevel = null;
|
|
||||||
}
|
|
||||||
if (!mapSettings.publicShowSignInfo) {
|
|
||||||
(addr as any).sign = false;
|
|
||||||
(addr as any).signSize = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationSeconds = (Date.now() - startTime) / 1000;
|
const durationSeconds = (Date.now() - startTime) / 1000;
|
||||||
recordLocationQuery('public', !!bounds, locations.length, durationSeconds);
|
recordLocationQuery('public', !!bounds, points.length, durationSeconds);
|
||||||
|
|
||||||
return locations;
|
return { points };
|
||||||
},
|
},
|
||||||
|
|
||||||
async importFromCsv(buffer: Buffer, userId: string) {
|
async importFromCsv(buffer: Buffer, userId: string) {
|
||||||
|
|||||||
@ -13,6 +13,9 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
|||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
|
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
|
||||||
import { MAP_ROLES } from '../../../utils/roles';
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { AppError } from '../../../middleware/error-handler';
|
||||||
|
import { prisma } from '../../../config/database';
|
||||||
|
|
||||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||||
const volunteerRouter = Router();
|
const volunteerRouter = Router();
|
||||||
@ -163,12 +166,24 @@ adminRouter.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /api/map/tracking/sessions/:id/route — full route for a session
|
// GET /api/map/tracking/sessions/:id/route — full GPS route for a session.
|
||||||
|
// Tightened 2026-04-12: only SUPER_ADMIN or the session's owning volunteer can
|
||||||
|
// view raw GPS traces. Previously any MAP_ADMIN could enumerate any volunteer's
|
||||||
|
// movements, which is an unacceptable privacy risk for a political platform.
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
'/sessions/:id/route',
|
'/sessions/:id/route',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
const session = await prisma.trackingSession.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!session) throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND');
|
||||||
|
if (req.user!.role !== UserRole.SUPER_ADMIN && session.userId !== req.user!.id) {
|
||||||
|
// 404 not 403 to avoid confirming session existence for unauthorized admins.
|
||||||
|
throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND');
|
||||||
|
}
|
||||||
const route = await trackingService.getSessionRoute(id);
|
const route = await trackingService.getSessionRoute(id);
|
||||||
res.json(route);
|
res.json(route);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { UserRole, UserStatus } from '@prisma/client';
|
|||||||
import { prisma } from '../../../config/database';
|
import { prisma } from '../../../config/database';
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
|
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
|
||||||
|
import { verifyMediaSignature } from '../../../utils/signed-url';
|
||||||
|
|
||||||
// Extend FastifyRequest to include user
|
// Extend FastifyRequest to include user
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@ -33,37 +34,44 @@ export async function authenticate(
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
const queryToken = (request.query as Record<string, string>)?.token;
|
const query = (request.query as Record<string, string>) ?? {};
|
||||||
|
|
||||||
|
// Two accepted auth paths:
|
||||||
|
// 1. `Authorization: Bearer <JWT>` — normal API use (mobile, fetch, etc.)
|
||||||
|
// 2. Signed-URL query params `?sig=...&exp=...&uid=...` — used for
|
||||||
|
// `<img src>`/`<video src>` tags where browsers can't set headers.
|
||||||
|
// This replaces the legacy `?token=<JWT>` path on 2026-04-12:
|
||||||
|
// full JWTs in URLs were leaking via logs, referer headers, and
|
||||||
|
// shared-link copy/paste. Signed URLs are path-scoped and 5-min TTL.
|
||||||
|
|
||||||
|
let authenticatedUserId: string | null = null;
|
||||||
|
|
||||||
// Support both Authorization header and ?token= query param (for <img>/<video> src)
|
|
||||||
let token: string | null = null;
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
} else if (queryToken) {
|
try {
|
||||||
token = queryToken;
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
|
authenticatedUserId = payload.id;
|
||||||
|
} catch {
|
||||||
|
return reply.status(401).send({ error: 'Invalid or expired token', code: 'INVALID_TOKEN' });
|
||||||
|
}
|
||||||
|
} else if (query.sig && query.exp && query.uid) {
|
||||||
|
const result = verifyMediaSignature(request.url, query);
|
||||||
|
if (!result.valid) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid signed URL', code: 'INVALID_SIGNATURE' });
|
||||||
|
}
|
||||||
|
authenticatedUserId = result.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!authenticatedUserId) {
|
||||||
return reply.status(401).send({
|
return reply.status(401).send({
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
code: 'AUTH_REQUIRED'
|
code: 'AUTH_REQUIRED'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify JWT with V2 access secret
|
|
||||||
let payload: TokenPayload;
|
|
||||||
try {
|
|
||||||
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
|
||||||
} catch (error) {
|
|
||||||
return reply.status(401).send({
|
|
||||||
error: 'Invalid or expired token',
|
|
||||||
code: 'INVALID_TOKEN'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user still exists and is active
|
// Verify user still exists and is active
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.id },
|
where: { id: authenticatedUserId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
@ -140,44 +148,40 @@ export async function optionalAuth(
|
|||||||
_reply: FastifyReply
|
_reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
const queryToken = (request.query as Record<string, string>)?.token;
|
const query = (request.query as Record<string, string>) ?? {};
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
|
||||||
let token: string | null = null;
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
token = authHeader.substring(7);
|
try {
|
||||||
} else if (queryToken) {
|
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
token = queryToken;
|
userId = payload.id;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else if (query.sig && query.exp && query.uid) {
|
||||||
|
const result = verifyMediaSignature(request.url, query);
|
||||||
|
if (result.valid) userId = result.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!userId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const user = await prisma.user.findUnique({
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
roles: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Verify user exists and is active
|
if (user && user.status === UserStatus.ACTIVE) {
|
||||||
const user = await prisma.user.findUnique({
|
const userRoles = getUserRoles(user);
|
||||||
where: { id: payload.id },
|
request.user = {
|
||||||
select: {
|
id: user.id,
|
||||||
id: true,
|
email: user.email,
|
||||||
email: true,
|
role: user.role as UserRole,
|
||||||
role: true,
|
roles: userRoles,
|
||||||
roles: true,
|
};
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user && user.status === UserStatus.ACTIVE) {
|
|
||||||
const userRoles = getUserRoles(user);
|
|
||||||
request.user = {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role as UserRole,
|
|
||||||
roles: userRoles,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid token, just ignore and continue without user
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,30 +45,23 @@ export function notifyUser(userId: string, notification: {
|
|||||||
|
|
||||||
export async function chatNotificationsRoutes(fastify: FastifyInstance) {
|
export async function chatNotificationsRoutes(fastify: FastifyInstance) {
|
||||||
/**
|
/**
|
||||||
* GET /notifications/stream?token=JWT
|
* GET /notifications/stream?sig=...&exp=...&uid=...
|
||||||
* Per-user SSE stream for chat reply notifications
|
* Per-user SSE stream for chat reply notifications. Uses path-scoped signed
|
||||||
|
* URL (replaces legacy ?token=JWT path on 2026-04-12) since EventSource
|
||||||
|
* cannot set Authorization headers.
|
||||||
*/
|
*/
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/notifications/stream',
|
'/notifications/stream',
|
||||||
async (
|
async (
|
||||||
request: FastifyRequest<{ Querystring: { token?: string } }>,
|
request: FastifyRequest<{ Querystring: { sig?: string; exp?: string; uid?: string } }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) => {
|
) => {
|
||||||
const token = request.query.token;
|
const { verifyMediaSignature } = await import('../../../utils/signed-url');
|
||||||
|
const result = verifyMediaSignature(request.url, request.query as Record<string, string | undefined>);
|
||||||
if (!token) {
|
if (!result.valid) {
|
||||||
return reply.code(401).send({ message: 'Authentication token required' });
|
return reply.code(401).send({ message: 'Invalid or expired signed URL' });
|
||||||
}
|
}
|
||||||
|
const userId = result.userId;
|
||||||
// Verify JWT
|
|
||||||
let payload: TokenPayload;
|
|
||||||
try {
|
|
||||||
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
|
||||||
} catch {
|
|
||||||
return reply.code(401).send({ message: 'Invalid or expired token' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = payload.id;
|
|
||||||
|
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
|
|||||||
@ -5,41 +5,40 @@ import { prisma } from '../../../config/database';
|
|||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { requireAdminRole } from '../middleware/auth';
|
import { requireAdminRole } from '../middleware/auth';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
|
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
|
||||||
|
import { verifyMediaSignature } from '../../../utils/signed-url';
|
||||||
import { unlink } from 'fs/promises';
|
import { unlink } from 'fs/promises';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the request is from an authenticated admin user.
|
* Admin check for photo routes. Accepts Bearer header OR signed URL params.
|
||||||
* Supports JWT from Authorization header or ?token= query parameter
|
* Legacy `?token=<JWT>` path removed 2026-04-12 (see video-streaming.routes.ts).
|
||||||
* (needed for <img src> which can't send headers).
|
|
||||||
*/
|
*/
|
||||||
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
let token: string | undefined;
|
let userId: string | undefined;
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
|
const query = request.query as Record<string, string | undefined>;
|
||||||
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
token = authHeader.substring(7);
|
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
||||||
} else {
|
id: string; role: UserRole; roles?: UserRole[];
|
||||||
const query = request.query as Record<string, string | undefined>;
|
};
|
||||||
token = query.token;
|
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
||||||
|
userId = payload.id;
|
||||||
|
} else if (query.sig && query.exp && query.uid) {
|
||||||
|
const result = verifyMediaSignature(request.url, query);
|
||||||
|
if (!result.valid) return false;
|
||||||
|
userId = result.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) return false;
|
if (!userId) return false;
|
||||||
|
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
|
||||||
id: string;
|
|
||||||
role: UserRole;
|
|
||||||
roles?: UserRole[];
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.id },
|
where: { id: userId },
|
||||||
select: { status: true },
|
select: { status: true, role: true, roles: true },
|
||||||
});
|
});
|
||||||
|
if (!user || user.status !== UserStatus.ACTIVE) return false;
|
||||||
return user?.status === UserStatus.ACTIVE;
|
return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
44
api/src/modules/media/routes/sign.routes.ts
Normal file
44
api/src/modules/media/routes/sign.routes.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { signMediaPath } from '../../../utils/signed-url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/media/sign — body: { path: string, ttlSeconds?: number }
|
||||||
|
*
|
||||||
|
* Returns short-lived HMAC-signed query params for embedding the given path
|
||||||
|
* in an `<img src>` / `<video src>` / SSE URL. Requires header auth (the
|
||||||
|
* caller must have a valid Bearer JWT). The returned params are path-scoped
|
||||||
|
* and expire in `ttlSeconds` (capped at 900s / 15 min).
|
||||||
|
*
|
||||||
|
* Replaces the legacy pattern of putting the JWT itself in `?token=` on
|
||||||
|
* 2026-04-12 — see utils/signed-url.ts for background.
|
||||||
|
*/
|
||||||
|
interface SignRequestBody { path?: string; ttlSeconds?: number }
|
||||||
|
|
||||||
|
export async function signRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.post<{ Body: SignRequestBody }>(
|
||||||
|
'/sign',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { path, ttlSeconds } = request.body ?? {};
|
||||||
|
if (typeof path !== 'string' || path.length === 0 || path.length > 512) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||||
|
}
|
||||||
|
// Reject anything that's not a path on our own API — prevents signed-URL
|
||||||
|
// generation for arbitrary URLs (would otherwise be a blind-signer oracle).
|
||||||
|
if (!path.startsWith('/api/')) {
|
||||||
|
return reply.status(400).send({ error: 'Path must start with /api/', code: 'INVALID_PATH' });
|
||||||
|
}
|
||||||
|
const ttl = Math.min(Math.max(Number(ttlSeconds) || 300, 30), 900);
|
||||||
|
const userId = request.user!.id;
|
||||||
|
const signed = signMediaPath(path, userId, ttl);
|
||||||
|
const separator = path.includes('?') ? '&' : '?';
|
||||||
|
const query = `sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
|
||||||
|
return reply.send({
|
||||||
|
url: `${path}${separator}${query}`,
|
||||||
|
...signed,
|
||||||
|
expiresAt: new Date(Number(signed.exp) * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,44 +8,45 @@ import { UserRole, UserStatus } from '@prisma/client';
|
|||||||
import { prisma } from '../../../config/database';
|
import { prisma } from '../../../config/database';
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
|
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
|
||||||
|
import { verifyMediaSignature } from '../../../utils/signed-url';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the request is from an authenticated admin user.
|
* Check if the request is from an authenticated admin user.
|
||||||
* Supports JWT from Authorization header or ?token= query parameter
|
* Accepts either (1) Bearer JWT or (2) path-scoped signed URL params
|
||||||
* (needed for <video src> and <img src> which can't send headers).
|
* (`?sig=&exp=&uid=`). The legacy `?token=<JWT>` path was removed on
|
||||||
|
* 2026-04-12 — full JWTs in query strings were leaking via access logs
|
||||||
|
* and referer headers.
|
||||||
*/
|
*/
|
||||||
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Extract token from Authorization header (priority) or query param (fallback)
|
let userId: string | undefined;
|
||||||
let token: string | undefined;
|
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
|
const query = request.query as Record<string, string | undefined>;
|
||||||
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
token = authHeader.substring(7);
|
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
||||||
} else {
|
id: string; role: UserRole; roles?: UserRole[];
|
||||||
const query = request.query as Record<string, string | undefined>;
|
};
|
||||||
token = query.token;
|
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
||||||
|
userId = payload.id;
|
||||||
|
} else if (query.sig && query.exp && query.uid) {
|
||||||
|
const result = verifyMediaSignature(request.url, query);
|
||||||
|
if (!result.valid) return false;
|
||||||
|
userId = result.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) return false;
|
if (!userId) return false;
|
||||||
|
|
||||||
// Verify JWT signature
|
// Verify user still active AND has media role (signed URLs carry only uid,
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
// so we re-check the role from DB to avoid stale-role privilege escalation).
|
||||||
id: string;
|
|
||||||
role: UserRole;
|
|
||||||
roles?: UserRole[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check admin role from token (multi-role aware)
|
|
||||||
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
|
||||||
|
|
||||||
// Verify user is still active in DB
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.id },
|
where: { id: userId },
|
||||||
select: { status: true },
|
select: { status: true, role: true, roles: true },
|
||||||
});
|
});
|
||||||
|
if (!user || user.status !== UserStatus.ACTIVE) return false;
|
||||||
return user?.status === UserStatus.ACTIVE;
|
return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
|
|||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { ADMIN_ROLES } from '../../utils/roles';
|
import { ADMIN_ROLES, INFLUENCE_ROLES, MAP_ROLES } from '../../utils/roles';
|
||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { peopleService } from './people.service';
|
import { peopleService } from './people.service';
|
||||||
import { profileService } from './profile.service';
|
import { profileService } from './profile.service';
|
||||||
@ -101,9 +101,17 @@ router.get(
|
|||||||
// Household
|
// Household
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Household routes tightened 2026-04-12: contain full-address PII (names,
|
||||||
|
// emails, phones at a specific address). Previously accessible to any ADMIN_ROLE;
|
||||||
|
// now restricted to SUPER_ADMIN + INFLUENCE_ADMIN + MAP_ADMIN (the roles that
|
||||||
|
// legitimately handle location-based contact data). MEDIA/BROADCAST/etc. admins
|
||||||
|
// have no business viewing household PII.
|
||||||
|
const HOUSEHOLD_ROLES = Array.from(new Set([...INFLUENCE_ROLES, ...MAP_ROLES]));
|
||||||
|
|
||||||
// GET /api/people/household/:locationId — all people at a location
|
// GET /api/people/household/:locationId — all people at a location
|
||||||
router.get(
|
router.get(
|
||||||
'/household/:locationId',
|
'/household/:locationId',
|
||||||
|
requireRole(...HOUSEHOLD_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const locationId = req.params.locationId as string;
|
const locationId = req.params.locationId as string;
|
||||||
@ -118,6 +126,7 @@ router.get(
|
|||||||
// POST /api/people/household/:locationId/detect — auto-create HOUSEHOLD connections
|
// POST /api/people/household/:locationId/detect — auto-create HOUSEHOLD connections
|
||||||
router.post(
|
router.post(
|
||||||
'/household/:locationId/detect',
|
'/household/:locationId/detect',
|
||||||
|
requireRole(...HOUSEHOLD_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const locationId = req.params.locationId as string;
|
const locationId = req.params.locationId as string;
|
||||||
|
|||||||
@ -23,11 +23,42 @@ import { challengeRouter } from './challenge.routes';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// EventSource (SSE) doesn't support custom headers — accept token via query param
|
// EventSource SSE auth: accepts signed URL params (`?sig=&exp=&uid=`) and
|
||||||
// Scoped to /sse path only to limit token-in-URL exposure to where it's truly needed
|
// synthesizes an Authorization header so downstream `authenticate` middleware
|
||||||
router.use((req, _res, next) => {
|
// can treat the caller as header-authenticated. The legacy `?token=<JWT>` path
|
||||||
if (req.query.token && !req.headers.authorization && req.path.startsWith('/sse')) {
|
// was removed on 2026-04-12 — full JWTs in URLs leak via logs/referer; signed
|
||||||
req.headers.authorization = `Bearer ${req.query.token}`;
|
// URLs are path-scoped and 5-min TTL.
|
||||||
|
router.use(async (req, _res, next) => {
|
||||||
|
if (
|
||||||
|
!req.headers.authorization &&
|
||||||
|
req.path.startsWith('/sse') &&
|
||||||
|
req.query.sig && req.query.exp && req.query.uid
|
||||||
|
) {
|
||||||
|
const { verifyMediaSignature } = await import('../../utils/signed-url');
|
||||||
|
const result = verifyMediaSignature(
|
||||||
|
req.originalUrl || req.url,
|
||||||
|
req.query as Record<string, string | undefined>
|
||||||
|
);
|
||||||
|
if (result.valid) {
|
||||||
|
// Forge a short-lived access token so downstream authenticate() works
|
||||||
|
// unchanged. Better architecturally would be a dedicated 'signed auth'
|
||||||
|
// middleware, but synthesizing here keeps the blast-radius tiny.
|
||||||
|
const jwt = await import('jsonwebtoken');
|
||||||
|
const { env } = await import('../../config/env');
|
||||||
|
const { prisma } = await import('../../config/database');
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: result.userId },
|
||||||
|
select: { id: true, email: true, role: true, roles: true, status: true },
|
||||||
|
});
|
||||||
|
if (user && user.status === 'ACTIVE') {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, email: user.email, role: user.role, roles: user.roles },
|
||||||
|
env.JWT_ACCESS_SECRET,
|
||||||
|
{ algorithm: 'HS256', expiresIn: '5m' }
|
||||||
|
);
|
||||||
|
req.headers.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -215,11 +215,14 @@ app.use((req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Health Check ---
|
// --- Health Check ---
|
||||||
app.get('/api/health', healthMetricsRateLimit, async (req, res) => {
|
// Public (unauthenticated) for Docker healthcheck compatibility, but the
|
||||||
|
// `?detailed=true` mode — which exposed disk space and internal service status
|
||||||
|
// to any unauthenticated caller — was moved to the authenticated `/api/metrics`
|
||||||
|
// consumers on 2026-04-12. The public path now returns only pass/fail for core
|
||||||
|
// DB + Redis.
|
||||||
|
app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
|
||||||
const checks: Record<string, string> = {};
|
const checks: Record<string, string> = {};
|
||||||
const detailed = req.query.detailed === 'true';
|
|
||||||
|
|
||||||
// Core checks (always run — used by Docker healthcheck)
|
|
||||||
try {
|
try {
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
checks.database = 'ok';
|
checks.database = 'ok';
|
||||||
@ -234,28 +237,6 @@ app.get('/api/health', healthMetricsRateLimit, async (req, res) => {
|
|||||||
checks.redis = 'error';
|
checks.redis = 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended checks (opt-in, for monitoring/debugging)
|
|
||||||
if (detailed) {
|
|
||||||
// MkDocs dev server
|
|
||||||
try {
|
|
||||||
const mkdocsRes = await fetch(`http://${env.MKDOCS_CONTAINER_NAME}:8000`, { signal: AbortSignal.timeout(3000) });
|
|
||||||
checks.mkdocs = mkdocsRes.ok ? 'ok' : 'error';
|
|
||||||
} catch {
|
|
||||||
checks.mkdocs = 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disk space (logs directory)
|
|
||||||
try {
|
|
||||||
const { statfs } = await import('fs/promises');
|
|
||||||
const stats = await statfs(env.LOG_DIR);
|
|
||||||
const freeGB = Number(stats.bavail) * Number(stats.bsize) / (1024 ** 3);
|
|
||||||
checks.disk = freeGB > 1 ? 'ok' : 'warning';
|
|
||||||
checks.diskFreeGB = freeGB.toFixed(1);
|
|
||||||
} catch {
|
|
||||||
checks.disk = 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const coreHealthy = checks.database === 'ok' && checks.redis === 'ok';
|
const coreHealthy = checks.database === 'ok' && checks.redis === 'ok';
|
||||||
res.status(coreHealthy ? 200 : 503).json({
|
res.status(coreHealthy ? 200 : 503).json({
|
||||||
status: coreHealthy ? 'healthy' : 'degraded',
|
status: coreHealthy ? 'healthy' : 'degraded',
|
||||||
|
|||||||
@ -20,7 +20,9 @@ export const passwordResetTokenService = {
|
|||||||
data: { userId, token: tokenHash, expiresAt },
|
data: { userId, token: tokenHash, expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Password reset token created for user ${userId}`);
|
// 2026-04-12: userId removed from log to prevent correlation of reset
|
||||||
|
// activity with specific accounts via log scraping.
|
||||||
|
logger.info('Password reset token created');
|
||||||
return rawToken; // Send raw token in email; DB stores only the hash
|
return rawToken; // Send raw token in email; DB stores only the hash
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const DOCS_REPO_NAME = 'changemaker.lite';
|
|||||||
|
|
||||||
/** Deterministic password — never exposed to users */
|
/** Deterministic password — never exposed to users */
|
||||||
function generateGiteaPassword(userId: string): string {
|
function generateGiteaPassword(userId: string): string {
|
||||||
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
const salt = env.SERVICE_PASSWORD_SALT;
|
||||||
return createHmac('sha256', salt)
|
return createHmac('sha256', salt)
|
||||||
.update(`gitea:${userId}`)
|
.update(`gitea:${userId}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const ROLE_MAP: Record<string, string[]> = {
|
|||||||
|
|
||||||
/** Deterministic password — never exposed to users, only used for RC internal auth */
|
/** Deterministic password — never exposed to users, only used for RC internal auth */
|
||||||
function generateRCPassword(userId: string): string {
|
function generateRCPassword(userId: string): string {
|
||||||
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
const salt = env.SERVICE_PASSWORD_SALT;
|
||||||
return createHmac('sha256', salt)
|
return createHmac('sha256', salt)
|
||||||
.update(`rc:${userId}`)
|
.update(`rc:${userId}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
|||||||
@ -20,7 +20,8 @@ export const verificationTokenService = {
|
|||||||
data: { userId, token: tokenHash, expiresAt },
|
data: { userId, token: tokenHash, expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Verification token created for user ${userId}`);
|
// 2026-04-12: userId removed from log (see password-reset-token.service).
|
||||||
|
logger.info('Verification token created');
|
||||||
return rawToken; // Send raw token in email; DB stores only the hash
|
return rawToken; // Send raw token in email; DB stores only the hash
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
57
api/src/utils/device-fingerprint.ts
Normal file
57
api/src/utils/device-fingerprint.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device fingerprint binding for refresh tokens (added 2026-04-12).
|
||||||
|
*
|
||||||
|
* A refresh token stolen via XSS, log exfiltration, or database breach is
|
||||||
|
* currently valid from any IP/device until natural expiry. To close that
|
||||||
|
* window, we bind each refresh token to the issuing device by embedding a
|
||||||
|
* `df` claim (SHA-256 of user-agent + /24-masked IP) in the refresh JWT, and
|
||||||
|
* reject refresh attempts whose fingerprint doesn't match.
|
||||||
|
*
|
||||||
|
* We mask to /24 (IPv4) and /48 (IPv6) so legitimate mobile network changes
|
||||||
|
* within the same carrier subnet don't force re-login on every tower hop,
|
||||||
|
* while cross-country or VPN changes do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the /24-masked IPv4 or /48-masked IPv6 subnet portion of the
|
||||||
|
* client address. Trusts `req.ip` which Express populates from X-Forwarded-For
|
||||||
|
* when `trust proxy` is set.
|
||||||
|
*/
|
||||||
|
function maskIp(rawIp: string | undefined): string {
|
||||||
|
if (!rawIp) return 'unknown';
|
||||||
|
const ip = rawIp.replace(/^::ffff:/, '').trim();
|
||||||
|
if (ip.includes(':')) {
|
||||||
|
// IPv6: take first 3 hextets (/48)
|
||||||
|
return ip.split(':').slice(0, 3).join(':');
|
||||||
|
}
|
||||||
|
const parts = ip.split('.');
|
||||||
|
if (parts.length === 4) {
|
||||||
|
return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a stable fingerprint for the incoming request. Include major UA
|
||||||
|
* (Chrome/Firefox/Safari etc.) but not full string — we want stability across
|
||||||
|
* minor browser upgrades while still catching device swaps.
|
||||||
|
*/
|
||||||
|
export function computeDeviceFingerprint(req: Request): string {
|
||||||
|
const ua = (req.headers['user-agent'] ?? '').toString().slice(0, 200);
|
||||||
|
const ipSubnet = maskIp(req.ip);
|
||||||
|
return createHash('sha256').update(`${ua}|${ipSubnet}`).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time compare to avoid timing side channels when an attacker is
|
||||||
|
* testing stolen tokens against different fingerprints.
|
||||||
|
*/
|
||||||
|
export function fingerprintsMatch(a: string | undefined, b: string | undefined): boolean {
|
||||||
|
if (!a || !b || a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
105
api/src/utils/signed-url.ts
Normal file
105
api/src/utils/signed-url.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short-lived signed URLs for media streaming (added 2026-04-12).
|
||||||
|
*
|
||||||
|
* Background: `<img src>` and `<video src>` tags can't set Authorization
|
||||||
|
* headers, so the legacy design passed a full JWT access token in the query
|
||||||
|
* string (`?token=...`). JWTs in URLs leak via:
|
||||||
|
* - browser history and screen recordings
|
||||||
|
* - server / proxy / CDN access logs
|
||||||
|
* - Referer headers sent to external domains
|
||||||
|
* - shared links (the URL becomes a long-lived auth token)
|
||||||
|
*
|
||||||
|
* This module replaces that with a per-URL HMAC signature that:
|
||||||
|
* - expires in 5 minutes by default (`DEFAULT_TTL_SECONDS`)
|
||||||
|
* - is bound to one specific resource path (no cross-URL reuse)
|
||||||
|
* - carries only the user-id (not a full session token) in the clear
|
||||||
|
* - is single-purpose (signing key derived, never equals any JWT secret)
|
||||||
|
*
|
||||||
|
* Clients call `POST /api/media/sign` with header auth to get a signed URL,
|
||||||
|
* then set `<img src>`/`<video src>` to it. The server verifies the sig on
|
||||||
|
* fetch; an attacker recovering the URL from logs has at most 5 minutes to
|
||||||
|
* replay it, and can't forge a URL for any other resource.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_TTL_SECONDS = 300; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically derive the media-URL signing key from JWT_ACCESS_SECRET.
|
||||||
|
* This avoids adding yet another required env var while maintaining key
|
||||||
|
* separation — HMAC with a fixed context string produces a key that cannot
|
||||||
|
* be used to forge JWTs (different HMAC inputs ⇒ different outputs, and the
|
||||||
|
* JWT signing path never uses this derivation).
|
||||||
|
*/
|
||||||
|
function getSigningKey(): Buffer {
|
||||||
|
return createHmac('sha256', env.JWT_ACCESS_SECRET)
|
||||||
|
.update('cml:media-url-signing-v1')
|
||||||
|
.digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize the path portion we're signing. Strips the query string and
|
||||||
|
* any trailing slash so that adding/removing query params can't change the
|
||||||
|
* signed payload.
|
||||||
|
*/
|
||||||
|
function canonicalPath(path: string): string {
|
||||||
|
const withoutQuery = path.split('?')[0] ?? path;
|
||||||
|
return withoutQuery.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSignature(path: string, userId: string, exp: number): string {
|
||||||
|
const payload = `${canonicalPath(path)}|${userId}|${exp}`;
|
||||||
|
return createHmac('sha256', getSigningKey()).update(payload).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedUrlParams {
|
||||||
|
sig: string;
|
||||||
|
exp: string; // unix seconds (string in query)
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the signed query-string params for a given path + user.
|
||||||
|
* Does NOT append them to the URL — callers combine as they see fit.
|
||||||
|
*/
|
||||||
|
export function signMediaPath(
|
||||||
|
path: string,
|
||||||
|
userId: string,
|
||||||
|
ttlSeconds: number = DEFAULT_TTL_SECONDS
|
||||||
|
): SignedUrlParams {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||||||
|
return {
|
||||||
|
sig: computeSignature(path, userId, exp),
|
||||||
|
exp: String(exp),
|
||||||
|
uid: userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signed URL. Returns the authenticated userId on success, null on
|
||||||
|
* any failure (expired, tampered, missing params).
|
||||||
|
*/
|
||||||
|
export function verifyMediaSignature(
|
||||||
|
path: string,
|
||||||
|
query: Record<string, string | string[] | undefined>
|
||||||
|
): { valid: true; userId: string } | { valid: false; reason: string } {
|
||||||
|
const sig = typeof query.sig === 'string' ? query.sig : undefined;
|
||||||
|
const exp = typeof query.exp === 'string' ? query.exp : undefined;
|
||||||
|
const uid = typeof query.uid === 'string' ? query.uid : undefined;
|
||||||
|
|
||||||
|
if (!sig || !exp || !uid) return { valid: false, reason: 'missing params' };
|
||||||
|
|
||||||
|
const expNum = Number(exp);
|
||||||
|
if (!Number.isFinite(expNum)) return { valid: false, reason: 'bad exp' };
|
||||||
|
if (expNum < Math.floor(Date.now() / 1000)) return { valid: false, reason: 'expired' };
|
||||||
|
|
||||||
|
const expected = computeSignature(path, uid, expNum);
|
||||||
|
if (sig.length !== expected.length) return { valid: false, reason: 'sig length' };
|
||||||
|
// constant-time compare to avoid timing oracle on the HMAC
|
||||||
|
const ok = timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
|
||||||
|
if (!ok) return { valid: false, reason: 'sig mismatch' };
|
||||||
|
|
||||||
|
return { valid: true, userId: uid };
|
||||||
|
}
|
||||||
@ -131,6 +131,7 @@ export default function InstanceDetailPage() {
|
|||||||
const [tunnelStatusLoading, setTunnelStatusLoading] = useState(false);
|
const [tunnelStatusLoading, setTunnelStatusLoading] = useState(false);
|
||||||
const [tunnelSetupRunning, setTunnelSetupRunning] = useState(false);
|
const [tunnelSetupRunning, setTunnelSetupRunning] = useState(false);
|
||||||
const [tunnelSyncing, setTunnelSyncing] = useState(false);
|
const [tunnelSyncing, setTunnelSyncing] = useState(false);
|
||||||
|
const [tunnelImporting, setTunnelImporting] = useState(false);
|
||||||
|
|
||||||
// Upgrade state
|
// Upgrade state
|
||||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||||
@ -337,6 +338,26 @@ export default function InstanceDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [instance?.status, fetchInstance]);
|
}, [instance?.status, fetchInstance]);
|
||||||
|
|
||||||
|
// Fetch tunnel status for remote instances (must be before early return)
|
||||||
|
const fetchTunnelStatus = useCallback(async () => {
|
||||||
|
if (!instance?.isRemote) return;
|
||||||
|
setTunnelStatusLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/instances/${id}/tunnel/status`);
|
||||||
|
setTunnelStatus(data.data);
|
||||||
|
} catch {
|
||||||
|
setTunnelStatus(null);
|
||||||
|
} finally {
|
||||||
|
setTunnelStatusLoading(false);
|
||||||
|
}
|
||||||
|
}, [id, instance?.isRemote]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'tunnel' && instance?.isRemote) {
|
||||||
|
fetchTunnelStatus();
|
||||||
|
}
|
||||||
|
}, [activeTab, instance?.isRemote, fetchTunnelStatus]);
|
||||||
|
|
||||||
const handleAction = async (action: string, label: string) => {
|
const handleAction = async (action: string, label: string) => {
|
||||||
setActionLoading(action);
|
setActionLoading(action);
|
||||||
try {
|
try {
|
||||||
@ -1162,31 +1183,11 @@ export default function InstanceDetailPage() {
|
|||||||
const tunnelConfigured = !!(instance.pangolinEndpoint && instance.pangolinNewtId);
|
const tunnelConfigured = !!(instance.pangolinEndpoint && instance.pangolinNewtId);
|
||||||
const canConfigureTunnel = isManaged && (instance.status === 'RUNNING' || instance.status === 'STOPPED');
|
const canConfigureTunnel = isManaged && (instance.status === 'RUNNING' || instance.status === 'STOPPED');
|
||||||
|
|
||||||
// Fetch tunnel status for remote instances
|
|
||||||
const fetchTunnelStatus = useCallback(async () => {
|
|
||||||
if (!isRemote) return;
|
|
||||||
setTunnelStatusLoading(true);
|
|
||||||
try {
|
|
||||||
const { data } = await api.get(`/instances/${id}/tunnel/status`);
|
|
||||||
setTunnelStatus(data.data);
|
|
||||||
} catch {
|
|
||||||
setTunnelStatus(null);
|
|
||||||
} finally {
|
|
||||||
setTunnelStatusLoading(false);
|
|
||||||
}
|
|
||||||
}, [id, isRemote]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'tunnel' && isRemote) {
|
|
||||||
fetchTunnelStatus();
|
|
||||||
}
|
|
||||||
}, [activeTab, isRemote, fetchTunnelStatus]);
|
|
||||||
|
|
||||||
const handleRemoteTunnelSetup = async (values: { subdomainPrefix?: string }) => {
|
const handleRemoteTunnelSetup = async (values: { subdomainPrefix?: string }) => {
|
||||||
setTunnelSetupRunning(true);
|
setTunnelSetupRunning(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/instances/${id}/tunnel/setup`, {
|
await api.post(`/instances/${id}/tunnel/setup`, {
|
||||||
subdomainPrefix: values.subdomainPrefix || instance.slug,
|
subdomainPrefix: values.subdomainPrefix || '',
|
||||||
});
|
});
|
||||||
message.success('Tunnel setup complete — Newt credentials pushed to remote instance');
|
message.success('Tunnel setup complete — Newt credentials pushed to remote instance');
|
||||||
fetchInstance();
|
fetchInstance();
|
||||||
@ -1199,6 +1200,23 @@ export default function InstanceDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTunnelImport = async () => {
|
||||||
|
setTunnelImporting(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/instances/${id}/tunnel/import`);
|
||||||
|
message.success(
|
||||||
|
`Tunnel imported — site ${data.data.siteId} (${data.data.online ? 'online' : 'offline'})`
|
||||||
|
);
|
||||||
|
fetchInstance();
|
||||||
|
fetchTunnelStatus();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { response?: { data?: { error?: { message?: string } } } };
|
||||||
|
message.error(e?.response?.data?.error?.message || 'Import failed');
|
||||||
|
} finally {
|
||||||
|
setTunnelImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTunnelSync = async () => {
|
const handleTunnelSync = async () => {
|
||||||
setTunnelSyncing(true);
|
setTunnelSyncing(true);
|
||||||
try {
|
try {
|
||||||
@ -1344,16 +1362,34 @@ export default function InstanceDetailPage() {
|
|||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card title="Import Existing Tunnel" size="small">
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
If this instance already has a Pangolin tunnel set up (e.g. by
|
||||||
|
<code> config.sh --pangolin-site new</code> during install), the CCP can
|
||||||
|
adopt it by reading the remote <code>.env</code> and verifying the site
|
||||||
|
exists in the CCP's Pangolin org. No resources are modified.
|
||||||
|
</p>
|
||||||
|
<Popconfirm
|
||||||
|
title="Import existing tunnel?"
|
||||||
|
description="The CCP will read Pangolin credentials from the remote .env and persist them on this instance."
|
||||||
|
onConfirm={handleTunnelImport}
|
||||||
|
okText="Import"
|
||||||
|
>
|
||||||
|
<Button icon={<CloudOutlined />} loading={tunnelImporting}>
|
||||||
|
Import Existing Tunnel
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Setup Tunnel" size="small">
|
<Card title="Setup Tunnel" size="small">
|
||||||
<Form layout="vertical" onFinish={handleRemoteTunnelSetup}>
|
<Form layout="vertical" onFinish={handleRemoteTunnelSetup}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="subdomainPrefix"
|
name="subdomainPrefix"
|
||||||
label="Subdomain Prefix"
|
label="Subdomain Prefix (optional)"
|
||||||
initialValue={instance.slug}
|
extra={`Leave empty for standard subdomains (app.${instance.domain}, api.${instance.domain}). Set a prefix for multi-tenant domains (e.g. "ck" creates ck-app.${instance.domain}).`}
|
||||||
extra={`Resources will be created as <prefix>-app.${instance.domain}, <prefix>-api.${instance.domain}, etc.`}
|
rules={[{ pattern: /^[a-z0-9-]*$/, message: 'Lowercase alphanumeric + hyphens only' }]}
|
||||||
rules={[{ required: true }, { pattern: /^[a-z0-9-]+$/, message: 'Lowercase alphanumeric + hyphens only' }]}
|
|
||||||
>
|
>
|
||||||
<Input placeholder={instance.slug} />
|
<Input placeholder="(none — uses standard subdomains)" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
<Button type="primary" htmlType="submit" icon={<CloudOutlined />} loading={tunnelSetupRunning}>
|
<Button type="primary" htmlType="submit" icon={<CloudOutlined />} loading={tunnelSetupRunning}>
|
||||||
|
|||||||
@ -122,20 +122,39 @@ async function startPhoneHome() {
|
|||||||
const result = await response.json() as { registrationId: string };
|
const result = await response.json() as { registrationId: string };
|
||||||
logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
|
logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
|
||||||
|
|
||||||
// Step 2: Poll for approval
|
// Step 2: Poll for approval. Every path inside the callback is wrapped in
|
||||||
|
// try/catch so an unexpected throw never kills the interval silently.
|
||||||
|
// On every poll we log either the status transition or a heartbeat every
|
||||||
|
// 10th attempt, so admins can see the loop is alive.
|
||||||
|
let pollCount = 0;
|
||||||
|
let lastLoggedStatus: string | null = null;
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
|
pollCount += 1;
|
||||||
try {
|
try {
|
||||||
const pollResp = await fetch(
|
const pollResp = await fetch(
|
||||||
`${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
|
`${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!pollResp.ok) return;
|
if (!pollResp.ok) {
|
||||||
|
logger.warn(`[phone-home] Poll #${pollCount} HTTP ${pollResp.status} ${pollResp.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pollData = await pollResp.json() as {
|
const pollData = await pollResp.json() as {
|
||||||
status: string;
|
status: string;
|
||||||
certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
|
certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
|
||||||
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Log status transitions and periodic heartbeats so the loop is never
|
||||||
|
// invisible. Previously a stuck loop left no trace in logs.
|
||||||
|
if (pollData.status !== lastLoggedStatus) {
|
||||||
|
logger.info(`[phone-home] Poll #${pollCount}: status=${pollData.status}${pollData.message ? ` — ${pollData.message}` : ''}`);
|
||||||
|
lastLoggedStatus = pollData.status;
|
||||||
|
} else if (pollCount % 10 === 0) {
|
||||||
|
logger.debug(`[phone-home] Poll #${pollCount}: still ${pollData.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (pollData.status === 'APPROVED' && pollData.certBundle) {
|
if (pollData.status === 'APPROVED' && pollData.certBundle) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
logger.info('[phone-home] Approved! Saving certificates...');
|
logger.info('[phone-home] Approved! Saving certificates...');
|
||||||
@ -161,14 +180,28 @@ async function startPhoneHome() {
|
|||||||
|
|
||||||
// Exit so Docker restart policy brings us back with certs
|
// Exit so Docker restart policy brings us back with certs
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
} else if (pollData.status === 'APPROVED' && !pollData.certBundle) {
|
||||||
|
// Admin approved but cert bundle was consumed (e.g. by debug curl).
|
||||||
|
// Keep polling — admin can re-issue certs via the new endpoint and we'll
|
||||||
|
// pick them up on the next poll.
|
||||||
|
// (No action needed; the status-transition log above covers visibility.)
|
||||||
} else if (pollData.status === 'REJECTED') {
|
} else if (pollData.status === 'REJECTED') {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
logger.error('[phone-home] Registration was rejected by CCP admin');
|
logger.error('[phone-home] Registration was rejected by CCP admin');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`[phone-home] Poll failed: ${(err as Error).message}`);
|
// CRITICAL: this catch MUST swallow every error — if it rethrows the
|
||||||
|
// setInterval callback becomes an unhandled rejection and Node may kill
|
||||||
|
// the interval depending on the runtime config. We saw this in prod.
|
||||||
|
logger.warn(`[phone-home] Poll #${pollCount} failed: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
// Defensive: if the Node process receives an unhandled rejection that
|
||||||
|
// somehow originates from the poll path, log it instead of dying quietly.
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
logger.error(`[phone-home] Unhandled rejection in poll loop: ${reason}`);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
|
logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "instances_compose_project_key";
|
||||||
@ -66,7 +66,7 @@ model Instance {
|
|||||||
statusMessage String? @map("status_message")
|
statusMessage String? @map("status_message")
|
||||||
|
|
||||||
basePath String @map("base_path")
|
basePath String @map("base_path")
|
||||||
composeProject String @unique @map("compose_project")
|
composeProject String @map("compose_project")
|
||||||
gitBranch String @default("v2") @map("git_branch")
|
gitBranch String @default("v2") @map("git_branch")
|
||||||
gitCommit String? @map("git_commit")
|
gitCommit String? @map("git_commit")
|
||||||
|
|
||||||
|
|||||||
@ -245,4 +245,49 @@ router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN'
|
|||||||
res.json({ message: 'Registration rejected' });
|
res.json({ message: 'Registration rejected' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agents/registrations/:id/reissue-certs
|
||||||
|
* Re-issue certificates for an approved registration whose cert bundle was already
|
||||||
|
* delivered and wiped (e.g. agent missed the one-shot delivery).
|
||||||
|
*/
|
||||||
|
router.post('/registrations/:id/reissue-certs', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
|
||||||
|
if (!registration) throw new AppError(404, 'Registration not found');
|
||||||
|
if (registration.status !== AgentRegistrationStatus.APPROVED) {
|
||||||
|
throw new AppError(400, `Registration is ${registration.status}, not APPROVED`);
|
||||||
|
}
|
||||||
|
if (!registration.instanceId) {
|
||||||
|
throw new AppError(400, 'Registration has no linked instance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-issue certs and write back to registration for agent to pick up
|
||||||
|
const certMaterials = await issueAgentCert(registration.instanceId, registration.slug, registration.agentUrl);
|
||||||
|
|
||||||
|
await prisma.agentRegistration.update({
|
||||||
|
where: { id: id as string },
|
||||||
|
data: {
|
||||||
|
certBundle: {
|
||||||
|
caCertPem: certMaterials.caCertPem,
|
||||||
|
agentCertPem: certMaterials.agentCertPem,
|
||||||
|
agentKeyPem: certMaterials.agentKeyPem,
|
||||||
|
ccpFingerprint: certMaterials.fingerprint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: (req as unknown as { user: { id: string } }).user.id,
|
||||||
|
instanceId: registration.instanceId,
|
||||||
|
action: AuditAction.AGENT_APPROVE,
|
||||||
|
details: { slug: registration.slug, reason: 'cert-reissue' },
|
||||||
|
ipAddress: req.ip || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[agents] Certificates re-issued for ${registration.slug}`);
|
||||||
|
res.json({ message: 'Certificates re-issued — agent will receive them on next poll' });
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -250,6 +250,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Adopt a tunnel that was set up outside CCP (e.g. by config.sh --pangolin-site new)
|
||||||
|
router.post(
|
||||||
|
'/:id/tunnel/import',
|
||||||
|
requireRole('SUPER_ADMIN'),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const result = await tunnelService.importTunnel(
|
||||||
|
req.params.id as string,
|
||||||
|
req.user!.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
res.json({ data: result });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
|
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@ -122,11 +122,12 @@ export const startUpgradeSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const setupRemoteTunnelSchema = z.object({
|
export const setupRemoteTunnelSchema = z.object({
|
||||||
|
// Empty string or omitted → resources use standard subdomains (app., api., etc.)
|
||||||
|
// A value like "ck" → creates ck-app., ck-api., etc. for multi-tenant domains
|
||||||
subdomainPrefix: z
|
subdomainPrefix: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
|
||||||
.max(50)
|
.max(50)
|
||||||
.regex(/^[a-z0-9-]+$/, 'Prefix must be lowercase alphanumeric with hyphens')
|
.regex(/^[a-z0-9-]*$/, 'Prefix must be lowercase alphanumeric with hyphens')
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,18 @@ const exec = promisify(execCb);
|
|||||||
const CA_VALIDITY_DAYS = 3650; // ~10 years
|
const CA_VALIDITY_DAYS = 3650; // ~10 years
|
||||||
const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
|
const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell/cert-injection guard. Slug flows into the OpenSSL -subj `/CN=...` string
|
||||||
|
* and into SAN DNS entries. An unvalidated slug like `foo/O=EvilOrg/CN=` would
|
||||||
|
* let callers forge arbitrary DN components. Added 2026-04-12.
|
||||||
|
*/
|
||||||
|
const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
function assertSafeSlug(slug: string): void {
|
||||||
|
if (!SAFE_SLUG.test(slug)) {
|
||||||
|
throw new Error(`Invalid agent slug: must match ${SAFE_SLUG}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function computeFingerprint(certPem: string): string {
|
function computeFingerprint(certPem: string): string {
|
||||||
const der = Buffer.from(
|
const der = Buffer.from(
|
||||||
certPem
|
certPem
|
||||||
@ -91,6 +103,7 @@ export async function ensureCA() {
|
|||||||
* Returns the certificate materials (plaintext) for one-time display.
|
* Returns the certificate materials (plaintext) for one-time display.
|
||||||
*/
|
*/
|
||||||
export async function issueAgentCert(instanceId: string, slug: string, agentUrl?: string) {
|
export async function issueAgentCert(instanceId: string, slug: string, agentUrl?: string) {
|
||||||
|
assertSafeSlug(slug);
|
||||||
const ca = await ensureCA();
|
const ca = await ensureCA();
|
||||||
const caKeyPem = decrypt(ca.encryptedKey);
|
const caKeyPem = decrypt(ca.encryptedKey);
|
||||||
|
|
||||||
@ -115,6 +128,11 @@ export async function issueAgentCert(instanceId: string, slug: string, agentUrl?
|
|||||||
if (agentUrl) {
|
if (agentUrl) {
|
||||||
try {
|
try {
|
||||||
const hostname = new URL(agentUrl).hostname;
|
const hostname = new URL(agentUrl).hostname;
|
||||||
|
// Guard against SAN injection via crafted hostname (commas/newlines would
|
||||||
|
// inject extra SAN entries into the extfile). Added 2026-04-12.
|
||||||
|
if (/[,\n\r\0]/.test(hostname)) {
|
||||||
|
throw new Error('Invalid hostname');
|
||||||
|
}
|
||||||
// Detect IP vs DNS name
|
// Detect IP vs DNS name
|
||||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) {
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) {
|
||||||
sanEntries.push(`IP:${hostname}`);
|
sanEntries.push(`IP:${hostname}`);
|
||||||
|
|||||||
@ -152,15 +152,22 @@ export async function checkInstanceHealth(instanceId: string) {
|
|||||||
if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
|
if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
|
||||||
await prisma.instance.update({
|
await prisma.instance.update({
|
||||||
where: { id: instanceId },
|
where: { id: instanceId },
|
||||||
data: { status: InstanceStatus.STOPPED },
|
data: {
|
||||||
|
status: InstanceStatus.STOPPED,
|
||||||
|
statusMessage: `No running containers detected at ${new Date().toISOString()}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
|
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
|
||||||
} else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
|
} else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
|
||||||
|
const runningCount = containers.filter((c) => c.state === 'running').length;
|
||||||
await prisma.instance.update({
|
await prisma.instance.update({
|
||||||
where: { id: instanceId },
|
where: { id: instanceId },
|
||||||
data: { status: InstanceStatus.RUNNING },
|
data: {
|
||||||
|
status: InstanceStatus.RUNNING,
|
||||||
|
statusMessage: `${runningCount} container(s) running — detected at ${new Date().toISOString()}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${containers.filter((c) => c.state === 'running').length} running containers detected)`);
|
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${runningCount} running containers detected)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync domain and feature flags from .env if they have drifted
|
// Sync domain and feature flags from .env if they have drifted
|
||||||
|
|||||||
@ -44,6 +44,12 @@ export interface InstanceSecrets {
|
|||||||
jwtAccessSecret: string;
|
jwtAccessSecret: string;
|
||||||
jwtRefreshSecret: string;
|
jwtRefreshSecret: string;
|
||||||
jwtInviteSecret: string;
|
jwtInviteSecret: string;
|
||||||
|
// Added 2026-04-12 (P2-2): Changemaker Lite now requires distinct secrets for
|
||||||
|
// Gitea SSO cookies and service-account password derivation — the old
|
||||||
|
// JWT_ACCESS_SECRET fallback was removed. New instances provisioned by CCP
|
||||||
|
// must receive both to boot.
|
||||||
|
giteaSsoSecret: string;
|
||||||
|
servicePasswordSalt: string;
|
||||||
encryptionKey: string;
|
encryptionKey: string;
|
||||||
initialAdminPassword: string;
|
initialAdminPassword: string;
|
||||||
nocodbAdminPassword: string;
|
nocodbAdminPassword: string;
|
||||||
@ -69,6 +75,8 @@ export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEm
|
|||||||
jwtAccessSecret: randomHex(32),
|
jwtAccessSecret: randomHex(32),
|
||||||
jwtRefreshSecret: randomHex(32),
|
jwtRefreshSecret: randomHex(32),
|
||||||
jwtInviteSecret: randomHex(32),
|
jwtInviteSecret: randomHex(32),
|
||||||
|
giteaSsoSecret: randomHex(32),
|
||||||
|
servicePasswordSalt: randomHex(32),
|
||||||
encryptionKey: randomHex(32),
|
encryptionKey: randomHex(32),
|
||||||
initialAdminPassword: randomPassword(16),
|
initialAdminPassword: randomPassword(16),
|
||||||
nocodbAdminPassword: randomPassword(16),
|
nocodbAdminPassword: randomPassword(16),
|
||||||
|
|||||||
@ -62,7 +62,8 @@ function getPangolinClient(): CcpPangolinClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fullSubdomain(prefix: string, sub: string): string {
|
function fullSubdomain(prefix: string, sub: string): string {
|
||||||
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
|
if (!prefix) return sub; // no prefix → use subdomain as-is (e.g., "app")
|
||||||
|
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
|
||||||
return `${prefix}-${sub}`; // e.g., "ck-app", "ck-api"
|
return `${prefix}-${sub}`; // e.g., "ck-app", "ck-api"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,7 +131,10 @@ export async function setupTunnel(
|
|||||||
throw new AppError(400, 'Tunnel is already configured. Use sync to update resources, or teardown first.', 'ALREADY_CONFIGURED');
|
throw new AppError(400, 'Tunnel is already configured. Use sync to update resources, or teardown first.', 'ALREADY_CONFIGURED');
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = options.subdomainPrefix || instance.slug;
|
// Empty prefix means resources use standard subdomains (app., api., etc.)
|
||||||
|
// matching the instance's nginx config. A prefix like "ck" creates
|
||||||
|
// ck-app., ck-api., etc. for multi-tenant Pangolin domains.
|
||||||
|
const prefix = options.subdomainPrefix ?? '';
|
||||||
|
|
||||||
const driver = await getRemoteDriverForInstance({
|
const driver = await getRemoteDriverForInstance({
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
@ -318,7 +322,7 @@ export async function syncResources(
|
|||||||
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||||
if (!instance.pangolinSiteId) throw new AppError(400, 'No tunnel configured', 'NO_TUNNEL');
|
if (!instance.pangolinSiteId) throw new AppError(400, 'No tunnel configured', 'NO_TUNNEL');
|
||||||
|
|
||||||
const prefix = instance.pangolinSubdomainPrefix || instance.slug;
|
const prefix = instance.pangolinSubdomainPrefix ?? '';
|
||||||
const domain = await findDomainForInstance(client, instance.domain);
|
const domain = await findDomainForInstance(client, instance.domain);
|
||||||
const existingResources = await client.listResources();
|
const existingResources = await client.listResources();
|
||||||
const siteId = instance.pangolinSiteId;
|
const siteId = instance.pangolinSiteId;
|
||||||
@ -387,7 +391,26 @@ export async function teardownTunnel(
|
|||||||
|
|
||||||
const siteId = instance.pangolinSiteId;
|
const siteId = instance.pangolinSiteId;
|
||||||
|
|
||||||
// Delete site from Pangolin (cascades resources + targets)
|
// Pangolin does NOT cascade-delete resources when a site is deleted.
|
||||||
|
// We must delete resources first to avoid orphaned entries.
|
||||||
|
try {
|
||||||
|
const allResources = await client.listResources();
|
||||||
|
const siteIdNum = Number(siteId);
|
||||||
|
for (const res of allResources) {
|
||||||
|
try {
|
||||||
|
const targets = await client.listTargets(String(res.resourceId));
|
||||||
|
const ours = targets.some((t) => Number(t.siteId) === siteIdNum);
|
||||||
|
if (ours) {
|
||||||
|
await client.deleteResource(String(res.resourceId));
|
||||||
|
logger.info(`[tunnel] ${instance.slug}: deleted resource ${res.name} (${res.fullDomain})`);
|
||||||
|
}
|
||||||
|
} catch { /* target lookup failed — skip */ }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[tunnel] ${instance.slug}: resource cleanup failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now delete the site itself
|
||||||
try {
|
try {
|
||||||
await client.deleteSite(siteId);
|
await client.deleteSite(siteId);
|
||||||
logger.info(`[tunnel] ${instance.slug}: deleted Pangolin site ${siteId}`);
|
logger.info(`[tunnel] ${instance.slug}: deleted Pangolin site ${siteId}`);
|
||||||
@ -549,6 +572,120 @@ export async function getTunnelStatus(instanceId: string): Promise<TunnelStatus>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Import existing tunnel ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt a tunnel that was set up outside the CCP (e.g. by `config.sh --pangolin-site new`
|
||||||
|
* on the remote instance itself). Reads the remote `.env` via the agent, pulls the
|
||||||
|
* Pangolin vars, verifies the site exists in the CCP's Pangolin org, and persists the
|
||||||
|
* values on the Instance record so subsequent sync/teardown/status calls work.
|
||||||
|
*
|
||||||
|
* We DO NOT fetch the Newt secret from Pangolin — it's only in the remote .env, and
|
||||||
|
* Pangolin's API does not expose it after site creation. If the remote .env is missing
|
||||||
|
* it, the user has to teardown + setup via CCP instead.
|
||||||
|
*/
|
||||||
|
export async function importTunnel(
|
||||||
|
instanceId: string,
|
||||||
|
userId?: string,
|
||||||
|
ipAddress?: string | null
|
||||||
|
) {
|
||||||
|
const client = getPangolinClient();
|
||||||
|
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
|
||||||
|
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||||
|
if (!instance.isRemote) throw new AppError(400, 'Import only applies to remote instances', 'NOT_REMOTE');
|
||||||
|
if (instance.pangolinSiteId) {
|
||||||
|
throw new AppError(400, 'Tunnel already imported/configured — use sync or teardown instead', 'ALREADY_CONFIGURED');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Read the remote .env via mTLS agent
|
||||||
|
const driver = await getRemoteDriverForInstance({
|
||||||
|
id: instance.id,
|
||||||
|
slug: instance.slug,
|
||||||
|
isRemote: instance.isRemote,
|
||||||
|
agentUrl: instance.agentUrl,
|
||||||
|
});
|
||||||
|
const envVars = await driver.readEnvFile('');
|
||||||
|
if (!envVars) {
|
||||||
|
throw new AppError(502, 'Could not read .env from remote agent', 'AGENT_READ_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Extract Pangolin vars
|
||||||
|
const siteId = envVars.PANGOLIN_SITE_ID?.trim();
|
||||||
|
const newtId = envVars.PANGOLIN_NEWT_ID?.trim();
|
||||||
|
const newtSecret = envVars.PANGOLIN_NEWT_SECRET?.trim();
|
||||||
|
const endpoint = envVars.PANGOLIN_ENDPOINT?.trim() || env.PANGOLIN_API_URL?.replace(/\/v1$/, '') || '';
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!siteId) missing.push('PANGOLIN_SITE_ID');
|
||||||
|
if (!newtId) missing.push('PANGOLIN_NEWT_ID');
|
||||||
|
if (!newtSecret) missing.push('PANGOLIN_NEWT_SECRET');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new AppError(
|
||||||
|
400,
|
||||||
|
`Remote .env missing Pangolin credentials: ${missing.join(', ')}. ` +
|
||||||
|
`Either the tunnel was never set up, or it was torn down. ` +
|
||||||
|
`Use "Setup Tunnel" to create a fresh one via CCP.`,
|
||||||
|
'REMOTE_ENV_MISSING_TUNNEL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify the site exists in the CCP's Pangolin org (and that our API key can see it)
|
||||||
|
let onlineNow = false;
|
||||||
|
try {
|
||||||
|
const site = await client.getSite(siteId!);
|
||||||
|
onlineNow = site.online ?? false;
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppError(
|
||||||
|
400,
|
||||||
|
`Site ${siteId} not found in CCP's Pangolin org (${env.PANGOLIN_ORG_ID}). ` +
|
||||||
|
`The remote instance may be using a different Pangolin org than the CCP is configured for. ` +
|
||||||
|
`Error: ${(err as Error).message}`,
|
||||||
|
'SITE_NOT_IN_CCP_ORG'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Persist on Instance record
|
||||||
|
await prisma.instance.update({
|
||||||
|
where: { id: instanceId },
|
||||||
|
data: {
|
||||||
|
pangolinEndpoint: endpoint || null,
|
||||||
|
pangolinSiteId: siteId,
|
||||||
|
pangolinNewtId: newtId,
|
||||||
|
pangolinNewtSecret: newtSecret,
|
||||||
|
// Note: we do NOT infer pangolinSubdomainPrefix — import assumes standard
|
||||||
|
// subdomains (app., api., etc.). If the user was using a prefix, they should
|
||||||
|
// teardown + setup via CCP instead.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Audit log
|
||||||
|
if (userId) {
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
instanceId,
|
||||||
|
action: AuditAction.PANGOLIN_SETUP,
|
||||||
|
details: {
|
||||||
|
source: 'import',
|
||||||
|
siteId,
|
||||||
|
endpoint,
|
||||||
|
onlineAtImport: onlineNow,
|
||||||
|
} as unknown as Prisma.InputJsonValue,
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[tunnel] ${instance.slug}: imported existing tunnel (site ${siteId}, online=${onlineNow})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
imported: true,
|
||||||
|
siteId,
|
||||||
|
endpoint,
|
||||||
|
online: onlineNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── .env Helpers ──────────────────────────────────────────────────
|
// ─── .env Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -9,6 +9,29 @@ import { createEvent } from './event.service';
|
|||||||
import { getRemoteDriverForInstance } from './execution-driver';
|
import { getRemoteDriverForInstance } from './execution-driver';
|
||||||
import type { AgentUpdateStatus } from './remote-driver';
|
import type { AgentUpdateStatus } from './remote-driver';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell-injection guards. Any user- or DB-controlled value that flows into
|
||||||
|
* `bash`/`git` via `exec()` must be validated against these regexes first.
|
||||||
|
* Added 2026-04-12 after red-team audit found unvalidated `branch` and `basePath`
|
||||||
|
* values reaching the shell.
|
||||||
|
*/
|
||||||
|
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
|
||||||
|
const SAFE_PATH = /^\/[a-zA-Z0-9/_.-]{1,255}$/;
|
||||||
|
|
||||||
|
function assertSafeBranch(branch: string | null | undefined, ctx: string): void {
|
||||||
|
if (!branch) return;
|
||||||
|
if (!SAFE_BRANCH.test(branch)) {
|
||||||
|
throw new Error(`Invalid git branch name (${ctx}): must match ${SAFE_BRANCH}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafePath(p: string | null | undefined, ctx: string): void {
|
||||||
|
if (!p) return;
|
||||||
|
if (!SAFE_PATH.test(p)) {
|
||||||
|
throw new Error(`Invalid path (${ctx}): must match ${SAFE_PATH}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write an INSTANCE_UPGRADE audit log entry capturing a terminal outcome.
|
* Write an INSTANCE_UPGRADE audit log entry capturing a terminal outcome.
|
||||||
* Wrapped in try/catch so that an audit-log DB failure cannot mask the
|
* Wrapped in try/catch so that an audit-log DB failure cannot mask the
|
||||||
@ -227,6 +250,10 @@ export async function startUpgrade(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard against shell injection via branch name (flows into bash exec).
|
||||||
|
assertSafeBranch(options?.branch, 'options.branch');
|
||||||
|
assertSafeBranch(instance.gitBranch, 'instance.gitBranch');
|
||||||
|
assertSafePath(instance.basePath, 'instance.basePath');
|
||||||
const branch = options?.branch || instance.gitBranch;
|
const branch = options?.branch || instance.gitBranch;
|
||||||
|
|
||||||
// Create upgrade record
|
// Create upgrade record
|
||||||
|
|||||||
@ -26,7 +26,15 @@ JWT_ACCESS_SECRET={{secrets.jwtAccessSecret}}
|
|||||||
JWT_REFRESH_SECRET={{secrets.jwtRefreshSecret}}
|
JWT_REFRESH_SECRET={{secrets.jwtRefreshSecret}}
|
||||||
JWT_INVITE_SECRET={{secrets.jwtInviteSecret}}
|
JWT_INVITE_SECRET={{secrets.jwtInviteSecret}}
|
||||||
JWT_ACCESS_EXPIRY=15m
|
JWT_ACCESS_EXPIRY=15m
|
||||||
JWT_REFRESH_EXPIRY=7d
|
# Reduced 2026-04-12 from 7d → 24h (P2-3). Combined with device-fingerprint
|
||||||
|
# binding in the refresh JWT payload, this tightens the exploitation window
|
||||||
|
# for stolen refresh tokens.
|
||||||
|
JWT_REFRESH_EXPIRY=24h
|
||||||
|
|
||||||
|
# Gitea SSO cookie signing + service password salt — REQUIRED 2026-04-12 (P2-2).
|
||||||
|
# Distinct from JWT secrets; empty values will now fail Zod validation on boot.
|
||||||
|
GITEA_SSO_SECRET={{secrets.giteaSsoSecret}}
|
||||||
|
SERVICE_PASSWORD_SALT={{secrets.servicePasswordSalt}}
|
||||||
|
|
||||||
# Encryption
|
# Encryption
|
||||||
ENCRYPTION_KEY={{secrets.encryptionKey}}
|
ENCRYPTION_KEY={{secrets.encryptionKey}}
|
||||||
|
|||||||
@ -40,10 +40,12 @@ services:
|
|||||||
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
||||||
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
||||||
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET:-}
|
# Updated 2026-04-12 (P2-2, P2-3): these secrets are now REQUIRED (Zod
|
||||||
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT:-}
|
# .min(32)) — empty fallback removed. Refresh expiry default 7d → 24h.
|
||||||
|
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
|
||||||
|
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
|
||||||
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
|
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
|
||||||
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-7d}
|
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
|
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
|
||||||
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?INITIAL_ADMIN_PASSWORD must be set in .env}
|
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?INITIAL_ADMIN_PASSWORD must be set in .env}
|
||||||
@ -185,6 +187,12 @@ services:
|
|||||||
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
||||||
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
||||||
|
# Added 2026-04-12 (P2-2): media-api shares the api's env schema; both
|
||||||
|
# require these secrets to boot.
|
||||||
|
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
|
||||||
|
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
|
||||||
|
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
|
||||||
|
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
|
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
|
||||||
@ -1381,6 +1389,9 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ccp-agent-data:/var/lib/ccp-agent
|
- ccp-agent-data:/var/lib/ccp-agent
|
||||||
- ccp-agent-certs:/etc/ccp-agent
|
- ccp-agent-certs:/etc/ccp-agent
|
||||||
|
# Mount the instance directory so the agent can read compose files and run
|
||||||
|
# `docker compose -p <project>` commands against the real project on disk.
|
||||||
|
- .:/app/instance:ro
|
||||||
environment:
|
environment:
|
||||||
- AGENT_PORT=7443
|
- AGENT_PORT=7443
|
||||||
- AGENT_DATA_DIR=/var/lib/ccp-agent
|
- AGENT_DATA_DIR=/var/lib/ccp-agent
|
||||||
@ -1390,6 +1401,9 @@ services:
|
|||||||
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
|
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
|
||||||
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
|
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
|
||||||
- INSTANCE_BASE_PATH=/app/instance
|
- INSTANCE_BASE_PATH=/app/instance
|
||||||
|
# Pass the host's compose project name so the agent runs `docker compose -p <project>`
|
||||||
|
# against the right project (not basename of INSTANCE_BASE_PATH, which is "instance").
|
||||||
|
- COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME:-changemaker-lite}
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite
|
||||||
|
|||||||
@ -39,10 +39,12 @@ services:
|
|||||||
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
||||||
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
||||||
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET:-}
|
# Updated 2026-04-12 (P2-2, P2-3): removed `:-` fallback (empty default)
|
||||||
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT:-}
|
# now that these are required, and shortened refresh expiry default 7d→24h.
|
||||||
|
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
|
||||||
|
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
|
||||||
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
|
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
|
||||||
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-7d}
|
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
|
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
|
||||||
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?INITIAL_ADMIN_PASSWORD must be set in .env}
|
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?INITIAL_ADMIN_PASSWORD must be set in .env}
|
||||||
@ -192,6 +194,12 @@ services:
|
|||||||
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
||||||
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
|
||||||
|
# Added 2026-04-12 (P2-2): media-api shares the same env schema as api;
|
||||||
|
# both require these secrets to boot.
|
||||||
|
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
|
||||||
|
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
|
||||||
|
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
|
||||||
|
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
|
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
|
||||||
@ -1411,6 +1419,9 @@ services:
|
|||||||
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
|
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
|
||||||
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
|
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
|
||||||
- INSTANCE_BASE_PATH=/app/instance
|
- INSTANCE_BASE_PATH=/app/instance
|
||||||
|
# Pass the host's compose project name so the agent runs `docker compose -p <project>`
|
||||||
|
# against the right project (not basename of INSTANCE_BASE_PATH, which is "instance").
|
||||||
|
- COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME:-changemaker-lite}
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user