From 5a0c4641a1074a3baa37e6cc90d09c8972f0280a Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Tue, 31 Mar 2026 18:30:17 -0600 Subject: [PATCH] Security audit fixes, mobile responsiveness across 40+ admin pages Security hardening from Mar 31 audit: - Separate login rate limit (10/15min) from general auth budget (15/15min) - Timing-safe webhook secret comparison (Listmonk) - Docs file creation ACL check (matches PUT/DELETE guards) - Key separation warnings for GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT - Clear GITEA_ADMIN_PASSWORD from .env after auto-setup - SQL injection prevention in effectiveness groupBy (pre-validated map) - Token hashing for password reset and verification tokens Mobile responsiveness (Phase 2C): - Add MobilePageHeader component and useMobile hook - Responsive table columns (hide secondary cols on mobile) - scroll={{ x: 'max-content' }} across all data tables - Mobile-adapted layouts for Dashboard, Settings, Calendar, SMS, Social pages - Conditional toolbar buttons on mobile viewports Infrastructure: - Updated docker-compose and nginx templates - Build script and mirror script updates Bunker Admin --- CLAUDE.md | 221 +++++++++++------- admin/src/components/AppLayout.tsx | 9 +- admin/src/components/MobilePageHeader.tsx | 47 ++++ .../components/media/FetchVideosDrawer.tsx | 12 +- .../src/components/people/VideoCallModal.tsx | 2 +- admin/src/hooks/useMobile.ts | 18 ++ admin/src/pages/ActionItemsPage.tsx | 1 + admin/src/pages/AdminCalendarPage.tsx | 7 + admin/src/pages/CanvassDashboardPage.tsx | 11 +- admin/src/pages/DashboardPage.tsx | 31 +-- admin/src/pages/DocsCommentsPage.tsx | 11 +- admin/src/pages/JitsiMeetPage.tsx | 4 +- admin/src/pages/LandingPagesPage.tsx | 1 + admin/src/pages/ListmonkPage.tsx | 2 +- admin/src/pages/MeetingAgendaPage.tsx | 2 + admin/src/pages/MeetingPlannerPage.tsx | 1 + admin/src/pages/NavigationSettingsPage.tsx | 13 +- admin/src/pages/PangolinPage.tsx | 2 + admin/src/pages/ResponsesPage.tsx | 2 +- admin/src/pages/SchedulingCalendarPage.tsx | 18 +- admin/src/pages/SettingsPage.tsx | 10 +- admin/src/pages/events/EventDetailPage.tsx | 7 +- admin/src/pages/events/TicketedEventsPage.tsx | 1 + .../influence/CampaignModerationPage.tsx | 11 +- .../src/pages/influence/ImpactStoriesPage.tsx | 8 +- admin/src/pages/influence/StrawPollsPage.tsx | 1 + .../pages/media/AdAnalyticsDashboardPage.tsx | 3 + .../pages/media/AnalyticsDashboardPage.tsx | 13 +- admin/src/pages/media/GalleryAdsPage.tsx | 2 + admin/src/pages/media/MediaJobsPage.tsx | 1 + .../pages/media/PlaylistManagementPage.tsx | 17 +- .../src/pages/payments/DonationPagesPage.tsx | 1 + admin/src/pages/payments/DonationsPage.tsx | 6 +- .../pages/payments/PaymentsDashboardPage.tsx | 1 + admin/src/pages/payments/PlansPage.tsx | 1 + admin/src/pages/payments/ProductsPage.tsx | 1 + admin/src/pages/payments/SubscribersPage.tsx | 1 + admin/src/pages/public/MeetingJoinPage.tsx | 2 +- admin/src/pages/sms/SmsCampaignsPage.tsx | 16 +- admin/src/pages/sms/SmsContactsPage.tsx | 2 + admin/src/pages/sms/SmsTemplatesPage.tsx | 10 +- .../src/pages/social/ChallengesAdminPage.tsx | 19 +- admin/src/pages/social/ReferralAdminPage.tsx | 2 + .../src/pages/social/SocialDashboardPage.tsx | 1 + .../src/pages/social/SocialModerationPage.tsx | 10 +- admin/src/pages/volunteer/ReferralsPage.tsx | 1 + admin/src/stores/auth.store.ts | 22 +- api/src/config/env.ts | 16 +- api/src/middleware/rate-limit.ts | 21 +- api/src/modules/auth/auth.routes.ts | 4 +- .../docs-analytics/docs-analytics.routes.ts | 2 +- api/src/modules/docs/docs.routes.ts | 9 + .../gitea-setup/gitea-setup.service.ts | 12 + .../effectiveness/effectiveness.service.ts | 7 +- .../listmonk/listmonk-webhook.routes.ts | 10 +- api/src/modules/map/canvass/canvass.routes.ts | 3 +- .../modules/map/tracking/tracking.routes.ts | 2 +- .../modules/map/tracking/tracking.service.ts | 10 +- api/src/modules/og/og.routes.ts | 13 +- api/src/modules/search/search.routes.ts | 13 +- api/src/modules/search/search.service.ts | 4 +- .../ticketed-events-admin.routes.ts | 4 +- .../ticketed-events/tickets.service.ts | 7 +- .../services/password-reset-token.service.ts | 18 +- .../services/verification-token.service.ts | 15 +- api/src/services/video-fetch-queue.service.ts | 63 ++++- api/src/utils/crypto.ts | 58 ++++- docker-compose.prod.yml | 16 +- docker-compose.yml | 16 +- nginx/conf.d/api.conf.template | 13 ++ scripts/build-and-push.sh | 2 +- scripts/mirror-images.sh | 2 +- 72 files changed, 684 insertions(+), 241 deletions(-) create mode 100644 admin/src/components/MobilePageHeader.tsx create mode 100644 admin/src/hooks/useMobile.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6ab9fdd5..6bccb770 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,15 +10,19 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker **Status Summary:** - ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps) -- ✅ Security Audit Complete (13 findings addressed, Feb 2026) -- ✅ NAR 2025 Server Import (Canadian electoral data) -- ✅ Media Manager Integration (dual API architecture) -- ✅ Email Templates System -- ✅ Data Quality Dashboard -- ✅ Observability Dashboard -- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026) -- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026) -- ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026) +- ✅ Drizzle to Prisma Migration Complete (single-ORM, Feb 2026) +- ✅ Automated Pangolin Setup (one-command tunnel deployment) +- ✅ 3 Security Audits Complete (Feb 2025 + Mar 22/27/30 2026) +- ✅ Social Connections + Calendar (friendship, shared views, availability finder) +- ✅ Payments + Ticketed Events (Stripe integration, check-in scanner) +- ✅ Meeting Planner + Straw Polls (scheduling, voting) +- ✅ SMS Campaign Connector (Termux Android bridge) +- ✅ Docs CMS (blog authoring, access policies, collaboration, version history) +- ✅ User Provisioning Framework (Gitea, Vaultwarden, Listmonk) +- ✅ Granular Admin Roles (9 admin roles + module-specific RBAC) +- ✅ Collaborative Docs Editing (Y.js CRDT + Hocuspocus) +- ✅ Engagement Scoring + EventBus + Gitea SSO +- ✅ MCP Server (Claude Code integration, 27 core + 40 on-demand tools) - 🚧 Phase 15 (Testing + Polish) - Next --- @@ -59,10 +63,9 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker changemaker.lite/ ├── api/ # Dual API servers (Express + Fastify) │ ├── prisma/ -│ │ ├── schema.prisma # 30+ models: User, Campaign, Location, Shift, etc. -│ │ ├── migrations/ # Prisma migration history +│ │ ├── schema.prisma # 186 models: User, Campaign, Location, Shift, Payment, Social, etc. +│ │ ├── migrations/ # 44 Prisma migrations (full schema history) │ │ └── seed.ts # Admin user, settings, page blocks -│ ├── drizzle/ # Media tables (Drizzle ORM) │ ├── Dockerfile.media # Fastify media server container │ └── src/ │ ├── server.ts # Express API entry point (port 4000) @@ -70,10 +73,10 @@ changemaker.lite/ │ ├── config/ │ │ └── env.ts # Zod-validated environment config (100+ vars) │ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler -│ ├── modules/ +│ ├── modules/ # 40 modules total │ │ ├── auth/ # JWT login, register, refresh, logout │ │ ├── users/ # User CRUD + pagination + search -│ │ ├── settings/ # Site settings singleton +│ │ ├── settings/ # Site settings singleton (20+ feature flags) │ │ ├── services/ # Service health checks │ │ ├── influence/ │ │ │ ├── campaigns/ # Campaign CRUD + public routes @@ -90,16 +93,39 @@ changemaker.lite/ │ │ │ ├── canvass/ # Canvassing sessions + visits + routes │ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes) │ │ │ └── settings/ # Map settings singleton -│ │ ├── pages/ -│ │ │ ├── pages-admin.routes.ts # Landing page CRUD -│ │ │ ├── pages-public.routes.ts # Public page renderer -│ │ │ └── blocks.routes.ts # Block library API +│ │ ├── pages/ # Landing page CRUD + block library + public renderer │ │ ├── email-templates/ # Email template CRUD + rendering -│ │ ├── media/ # Fastify media API (videos, reactions, jobs) +│ │ ├── media/ # Fastify media API (videos, reactions, jobs, analytics) +│ │ ├── social/ # Friendships, challenges, spotlights, referrals +│ │ ├── calendar/ # Calendar layers, items, shared views, availability +│ │ ├── payments/ # Stripe products, donations, subscriptions +│ │ ├── ticketed-events/ # Event ticketing, tiers, check-in +│ │ ├── sms/ # SMS campaigns via Termux Android bridge +│ │ ├── meeting-planner/ # Meeting scheduling with polls +│ │ ├── meetings/ # Meeting agendas, minutes, action items +│ │ ├── polls/ # Straw polls with comments + voting +│ │ ├── docs/ # MkDocs health checks + export routes +│ │ ├── docs-analytics/ # Docs page view tracking +│ │ ├── docs-comments/ # Gitea-backed comments on docs +│ │ ├── people/ # CRM people module +│ │ ├── events/ # Gancio event integration +│ │ ├── newsletter/ # Newsletter management │ │ ├── listmonk/ # Newsletter sync admin routes │ │ ├── pangolin/ # Tunnel management (Newt integration) -│ │ ├── docs/ # MkDocs + Code Server health checks +│ │ ├── rocketchat/ # Rocket.Chat integration +│ │ ├── jitsi/ # Jitsi video conferencing auth +│ │ ├── registry/ # Docker image registry management +│ │ ├── upgrade/ # Auto-upgrade checks + deployment +│ │ ├── gitea-setup/ # Gitea SSO + API token management +│ │ ├── volunteer-invite/ # Invite codes + setup workflows +│ │ ├── gallery-ads/ # Media gallery ads +│ │ ├── homepage/ # Homepage stats + dashboard +│ │ ├── search/ # Cross-module search +│ │ ├── reports/ # Analytics + reporting +│ │ ├── og/ # Open Graph metadata │ │ ├── qr/ # QR code PNG generation (public) +│ │ ├── dashboard/ # Admin dashboard data +│ │ ├── activity/ # Activity feed │ │ └── observability/ # Prometheus/Grafana/Alertmanager integration │ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker │ ├── types/ # express.d.ts (Request augmentation) @@ -119,23 +145,25 @@ changemaker.lite/ │ │ ├── media/ # VideoCard, BulkActions, gallery components │ │ ├── email-templates/ # Email template components │ │ └── observability/ # Monitoring components -│ ├── pages/ -│ │ ├── auth/ # LoginPage -│ │ ├── influence/ # CampaignsPage, ResponsesPage, RepresentativesPage, EmailQueuePage -│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboardPage -│ │ ├── volunteer/ # VolunteerMapPage, VolunteerShiftsPage, MyActivityPage, MyRoutesPage -│ │ ├── public/ # CampaignsListPage, CampaignPage, ResponseWallPage, MapPage, ShiftsPage, LandingPage, MediaGalleryPage, MediaViewerPage -│ │ ├── media/ # LibraryPage, SharedMediaPage, MediaJobsPage, AnalyticsDashboardPage -│ │ ├── services/ # MiniQRPage, MailHogPage, CodeEditorPage, N8nPage, GiteaPage, NocoDBPage -│ │ └── (root) # DashboardPage, UsersPage, SettingsPage, CanvassDashboardPage, WalkSheetPage, CutExportPage, LandingPagesPage, PageEditorPage, EmailTemplatesPage, ListmonkPage, PangolinPage, ObservabilityPage -│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand) -│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios) +│ ├── pages/ # 52 root pages + 8 subdirectories +│ │ ├── influence/ # Campaign moderation, effectiveness, impact stories, straw polls +│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard +│ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation +│ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings +│ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges +│ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup +│ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner +│ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements +│ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet +│ │ └── (root) # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc. +│ ├── stores/ # 9 Zustand stores (auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking) +│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts, nav-defaults.ts, service-url.ts, y-textarea.ts │ ├── hooks/ # useDebounce, useLocalStorage │ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces) │ -├── media-manager/ # Legacy media manager (reference) +├── mcp-server/ # Claude Code MCP server (27 core + 40 on-demand tools) ├── nginx/ # Reverse proxy config (subdomain routing + CSP) -├── configs/ # Prometheus, Grafana, Alertmanager configs +├── configs/ # Prometheus, Grafana, Alertmanager, Pangolin configs ├── scripts/ # Deployment, backup, upgrade, registry scripts │ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh) │ ├── build-and-push.sh # Build production images → push to Gitea registry @@ -144,9 +172,10 @@ changemaker.lite/ │ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode) │ ├── upgrade-check.sh # Check for updates (git or Gitea API) │ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades -│ └── backup.sh # PostgreSQL + Listmonk + uploads backup -├── docker-compose.yml # V2 orchestration (20+ services) -├── docker-compose.v1.yml # V1 backup (reference) +│ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore +│ └── validate-env.sh # Required env variable validation +├── docker-compose.yml # V2 orchestration (40+ services) +├── docker-compose.prod.yml # Production (image-only, no source mounts) ├── .env.example # All required environment variables └── V2_PLAN.md # Full 14-phase roadmap ``` @@ -238,27 +267,34 @@ cd api && npm run dev:media |---------|-----|---------------------| | Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env | | API | http://localhost:4000 | - | +| Media API | http://localhost:4100 | - | | NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env | +| Gitea | http://localhost:3030 | See `GITEA_ADMIN_USER`/`GITEA_ADMIN_PASSWORD` in .env | | MailHog | http://localhost:8025 | - | | Grafana | http://localhost:3001 | admin / admin | | Prometheus | http://localhost:9090 | - | | Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env | +| Rocket.Chat | http://localhost:3100 | See RC env vars in .env | +| Excalidraw | http://localhost:8090 | - | +| Vaultwarden | http://localhost:8093 | See `VAULTWARDEN_ADMIN_TOKEN` in .env | ### Feature Flags -Enable optional features in `.env`: +Most features are toggled via **SiteSettings** in the database (admin Settings page). Some also have `.env` overrides: ```bash -# Media Manager -ENABLE_MEDIA_FEATURES=true - -# Listmonk Newsletter Sync -LISTMONK_SYNC_ENABLED=true - -# Email Test Mode (sends to MailHog instead of SMTP) -EMAIL_TEST_MODE=true +# .env feature flags (env-level) +ENABLE_MEDIA_FEATURES=true # Media manager +ENABLE_PAYMENTS=true # Stripe integration +ENABLE_SMS=true # SMS campaigns +ENABLE_CHAT=true # Rocket.Chat +ENABLE_MEET=true # Jitsi meetings +LISTMONK_SYNC_ENABLED=true # Newsletter sync +EMAIL_TEST_MODE=true # MailHog vs SMTP ``` +**Database feature flags (SiteSettings):** `enableInfluence`, `enableMap`, `enableNewsletter`, `enableLandingPages`, `enableMediaFeatures`, `enablePayments`, `enableGalleryAds`, `enableChat`, `enableEvents`, `enableDocsComments`, `enableSms`, `enablePeople`, `enableSocial`, `enableMeet`, `enableMeetingPlanner`, `enableTicketedEvents`, `enableSocialCalendar`, `enablePolls`, `enableDocsCollaboration`, `enableUserProvisioning` + --- ## Development Commands @@ -272,7 +308,6 @@ cd api && npm run dev:media # Fastify media dev server (port 4100) cd api && npx tsc --noEmit # Type-check cd api && npx prisma migrate dev # Run/create Prisma migrations cd api && npx prisma studio # Browse database -cd api && npx drizzle-kit push # Push Drizzle schema changes (media) ``` ### Admin Development @@ -295,7 +330,6 @@ docker compose logs -f media-api # Database operations docker compose exec api npx prisma migrate dev -docker compose exec api npx drizzle-kit push # Stop services docker compose down @@ -513,20 +547,25 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit | **Core Services** | | | | 3000 | Admin GUI | Vite dev / React production | | 4000 | Express API | Main V2 API (Prisma) | -| 4100 | Fastify Media API | Video library (Drizzle) | +| 4100 | Fastify Media API | Video library (Prisma) | | 5433 | V2 PostgreSQL | Localhost (container: 5432) | | 6379 | Redis | Cache, rate limit, BullMQ | | **Supporting Services** | | | | 3001 | Grafana | Metrics visualization | | 3010 | Homepage | Service dashboard | -| 3030 | Gitea | Git hosting | +| 3030 | Gitea | Git hosting + SSO | +| 3100 | Rocket.Chat | Team chat (embed proxy) | | 4001 | MkDocs Site | Served docs | | 4003 | MkDocs Dev | Live preview | | 5432 | Listmonk PostgreSQL | Listmonk DB | | 5678 | n8n | Workflow automation | | 8025 | MailHog | Email capture (dev) | | 8089 | Mini QR | QR generator | +| 8090 | Excalidraw | Collaborative whiteboard | | 8091 | NocoDB | Data browser | +| 8092 | Gancio | Event management | +| 8093 | Vaultwarden | Password manager | +| 8443 | Jitsi Web | Video conferencing | | 8885 | Mini QR Proxy | Iframe-friendly | | 8888 | Code Server | Web IDE | | 9001 | Listmonk | Newsletter platform | @@ -551,11 +590,17 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit | `docs.cmlite.org` | MkDocs (4003) | Docs site | | `code.cmlite.org` | Code Server (8888) | Web IDE | | `n8n.cmlite.org` | n8n (5678) | Workflow automation | -| `git.cmlite.org` | Gitea (3030) | Git hosting | +| `git.cmlite.org` | Gitea (3030) | Git hosting + SSO | | `home.cmlite.org` | Homepage (3010) | Dashboard | | `grafana.cmlite.org` | Grafana (3001) | Metrics viz | | `listmonk.cmlite.org` | Listmonk (9001) | Newsletters | | `qr.cmlite.org` | Mini QR (8089) | QR generator | +| `chat.cmlite.org` | Rocket.Chat (3100) | Team chat | +| `meet.cmlite.org` | Jitsi (8443) | Video conferencing | +| `events.cmlite.org` | Gancio (8092) | Event management | +| `draw.cmlite.org` | Excalidraw (8090) | Collaborative whiteboard | +| `vault.cmlite.org` | Vaultwarden (8093) | Password manager | +| `mail.cmlite.org` | MailHog (8025) | Email capture (dev) | | `cmlite.org` | MkDocs Static (4004) | **Documentation/marketing site only** | **Clean separation:** Root domain (`${DOMAIN}`) serves MkDocs documentation site. All application functionality (admin GUI, public campaigns, map, shifts, media gallery) is accessible via `app.${DOMAIN}` subdomain. This provides clear separation between public documentation and the application. @@ -564,7 +609,7 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit ## Common Patterns -**Note:** See `MEMORY.md` for comprehensive development patterns, gotchas, and lessons learned. Below are V2-specific patterns only. +**Note:** Below are the key development patterns for this project. ### API Router Structure - Service layer (`*.service.ts`) — business logic, database queries @@ -579,47 +624,57 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit - Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer` ### Frontend Architecture -- Admin pages: `admin/src/pages/` (AppLayout) +- Admin pages: `admin/src/pages/` + subdirs (AppLayout) - Public pages: `admin/src/pages/public/` (PublicLayout, dark theme) - Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout) -- Zustand stores: `auth.store.ts`, `canvass.store.ts` +- Zustand stores (9): auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking - API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts` -### Database ORMs -- **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays -- **Drizzle** (media API): Separate schema file, push with `npx drizzle-kit push`, no migrations generated +### Database ORM +- **Prisma** (both APIs): 186 models in single `schema.prisma`. Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays ### Prisma Migration Workflow - **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift -- **Migration history:** 14 migrations in `api/prisma/migrations/` fully cover the schema (baseline catch-up applied Feb 2026) -- **Fixing drift:** Use `prisma migrate diff --from-migrations ... --to-schema-datamodel ... --script` with a shadow DB to generate catch-up SQL, then `prisma migrate resolve --applied`. See MEMORY.md for detailed steps +- **Migration history:** 44 migrations in `api/prisma/migrations/` fully cover the schema - **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`) -### V2-Specific Gotchas +### Key Gotchas - **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync - Nginx media API block must come BEFORE general API block - `IMAGE_TAG=local` (default) never pulls from registry; set to SHA or `latest` for pre-built images - **Release vs source installs:** Detected by `VERSION` file + absence of `.git/`; release uses `docker-compose.prod.yml`, source uses `docker-compose.yml` - **`api/dist/` is gitignored** — never commit; if root-owned from container builds, fix with `chown` -- See MEMORY.md "Common Gotchas" for additional gotchas (ports, volumes, media upload, registry, etc.) +- **`!` in passwords** triggers bash history expansion — use Write tool to write JSON to file, then `curl -d @file` +- **Port mappings:** API container 4000 → host 4002, Admin container 3000 → host 3002 +- **BullMQ** needs its own Redis connections (pass URL string, not shared ioredis instance) +- **Public pages** use `axios` directly (no auth interceptor), admin pages use `{ api }` from lib +- **Prisma JSON fields:** typed arrays need `as unknown as Prisma.InputJsonValue` cast +- **nginx conf.d files** have `.template` counterparts used by envsubst at startup --- ## Security & Configuration -### Security Audit -Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report. +### Security Audits +Four security audits completed. See audit reports for full details: +- **Feb 2025:** 13 findings (password policy, rate limits, token rotation, XSS prevention). `SECURITY_AUDIT_2025-02-11.md` +- **Mar 22 2026:** JWT algorithm lockdown, invite secret separation, webhook hardening, CSV injection, QR DoS +- **Mar 27 2026:** 33 findings (30 fixed) — IDOR, XSS, path traversal, MongoDB auth, SSTI, open redirect +- **Mar 30 2026:** 19 findings — IDOR action items/ticketed events, nginx rate limit, JWT secret reuse -**Key improvements:** +**Key security features:** - Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced) -- Rate limits on auth endpoints (10/min per IP) -- Refresh token rotation (atomic transaction) +- Rate limits on auth endpoints (10/min per IP) + nginx rate limiting +- Refresh token rotation (atomic Prisma transaction) +- JWT algorithm locked to HS256, separate invite secret - User enumeration prevention (401 not 404) - Redis authentication required -- XSS/injection prevention (HTML escaping) -- Path traversal protection +- XSS/injection prevention (HTML escaping, DOMPurify, SSTI protection) +- Path traversal protection (resolve + startsWith checks) - Encryption key for DB secrets (`ENCRYPTION_KEY` required in all environments) -- Nginx security headers (HSTS, Permissions-Policy, CSP) +- Nginx security headers (HSTS, Permissions-Policy, CSP, X-Forwarded-For) +- MongoDB keyfile authentication +- httpOnly cookies for refresh tokens ### Required Environment Variables See `.env.example` for all 100+ variables. Critical ones: @@ -642,8 +697,8 @@ See `.env.example` for all 100+ variables. Critical ones: When deploying to a production domain via Pangolin tunnel, you MUST update the `.env` file to include the production domain in `CORS_ORIGINS`: ```bash -# Example for betteredmonton.org -CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost +# Example for cmlite.org +CORS_ORIGINS=http://app.cmlite.org,https://app.cmlite.org,http://localhost:3000,http://localhost # Also set production mode NODE_ENV=production @@ -672,18 +727,16 @@ docker compose restart api 4. Save changes **Critical resources to fix first:** -- `api.betteredmonton.org` - Main API (all endpoints fail without this) -- `app.betteredmonton.org` - Admin GUI + public pages -- `media.betteredmonton.org` - Media API +- `api.${DOMAIN}` - Main API (all endpoints fail without this) +- `app.${DOMAIN}` - Admin GUI + public pages +- `media.${DOMAIN}` - Media API **Verification:** ```bash # Should return JSON, NOT a 302 redirect -curl https://api.betteredmonton.org/api/health +curl https://api.cmlite.org/api/health ``` -**See Also:** `PRODUCTION_403_FIX.md` for detailed step-by-step instructions. - ### CORS Errors in Production **Symptom:** Browser console shows CORS errors when accessing production domain. @@ -706,25 +759,21 @@ Check container status (`docker compose ps`), verify credentials in `.env`, chec ## V1 Reference (Legacy) -V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two independent Express apps using NocoDB REST API. See individual README files for V1 documentation: -- `influence/README.MD` — Features, config, campaign management -- `map/README.md` — Features, config, setup instructions -- Both use session-based auth, bcryptjs passwords, Bull job queues +V1 code has been removed from the repo. History preserved as `v1-archive` git tag. `docker-compose.v1.yml` remains as reference only. --- ## Key Configuration Files ### Infrastructure -- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 20+ services) +- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 40+ services) - `docker-compose.prod.yml` — Production orchestration (image-only, no source mounts, `IMAGE_TAG:-latest`) - `.env` / `.env.example` — Environment variables (100+ vars) - `config.sh` — Interactive setup wizard (14 steps, release-mode aware) ### Database -- `api/prisma/schema.prisma` — Main schema (30+ Prisma models) -- `api/prisma/migrations/` — 14 migration files (fully cover schema as of Feb 2026) -- `api/drizzle.config.ts` — Drizzle config for media tables +- `api/prisma/schema.prisma` — Main schema (186 Prisma models) +- `api/prisma/migrations/` — 44 migration files (full schema history) - `api/prisma/seed.ts` — Database seeding ### Nginx @@ -742,5 +791,5 @@ V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two indep ### Documentation - `CLAUDE.md` — Project-wide instructions (this file) - `V2_PLAN.md` — Full 14-phase roadmap -- `SECURITY_AUDIT_2025-02-11.md` — Security audit report -- `MEMORY.md` — Development patterns and gotchas +- `SECURITY_AUDIT_2025-02-11.md` — Initial security audit report +- `.mcp.json` — MCP server configuration for Claude Code diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx index 47ce2ca5..f63c3dfe 100644 --- a/admin/src/components/AppLayout.tsx +++ b/admin/src/components/AppLayout.tsx @@ -647,11 +647,14 @@ export default function AppLayout() { const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? ; const handleItemClick = (item: NavItem) => { if (item.path.startsWith('$')) { - window.open(resolveNavUrl(item.path), '_blank'); + window.open(resolveNavUrl(item.path), '_blank', 'noopener,noreferrer'); } else if (item.external && item.id === 'home') { - window.open(buildHomeUrl(), '_blank'); + window.open(buildHomeUrl(), '_blank', 'noopener,noreferrer'); } else if (item.external) { - window.open(item.path, '_blank'); + // Only open http/https URLs to prevent javascript: URI injection + if (item.path.startsWith('http://') || item.path.startsWith('https://')) { + window.open(item.path, '_blank', 'noopener,noreferrer'); + } } else { navigate(item.path); } diff --git a/admin/src/components/MobilePageHeader.tsx b/admin/src/components/MobilePageHeader.tsx new file mode 100644 index 00000000..6cb294ae --- /dev/null +++ b/admin/src/components/MobilePageHeader.tsx @@ -0,0 +1,47 @@ +import { Row, Col, Typography, Space } from 'antd'; +import type { ReactNode } from 'react'; +import { useMobile } from '@/hooks/useMobile'; + +interface MobilePageHeaderProps { + title: string; + /** Optional element next to the title (badge, count, etc.) */ + extra?: ReactNode; + /** Action buttons — will wrap on mobile, stay inline on desktop */ + actions?: ReactNode; + style?: React.CSSProperties; +} + +/** + * Responsive page header that stacks title and actions on mobile. + * On desktop: title left, actions right (single row). + * On mobile: title full-width, actions below with wrapping. + */ +export function MobilePageHeader({ title, extra, actions, style }: MobilePageHeaderProps) { + const { isMobile } = useMobile(); + + return ( + + + + + {title} + + {extra} + + + {actions && ( + + + {actions} + + + )} + + ); +} diff --git a/admin/src/components/media/FetchVideosDrawer.tsx b/admin/src/components/media/FetchVideosDrawer.tsx index a88184ff..81a399f8 100644 --- a/admin/src/components/media/FetchVideosDrawer.tsx +++ b/admin/src/components/media/FetchVideosDrawer.tsx @@ -148,15 +148,9 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid const connectSSE = async () => { try { - // Get auth token from localStorage - const stored = localStorage.getItem('auth-storage'); - let token = ''; - if (stored) { - try { - const parsed = JSON.parse(stored); - token = parsed?.state?.accessToken || ''; - } catch {} - } + // Get auth token from in-memory store (not localStorage) + const { useAuthStore } = await import('@/stores/auth.store'); + const token = useAuthStore.getState().accessToken || ''; const response = await fetch(baseUrl, { headers: { diff --git a/admin/src/components/people/VideoCallModal.tsx b/admin/src/components/people/VideoCallModal.tsx index 9b283606..ec9a28ba 100644 --- a/admin/src/components/people/VideoCallModal.tsx +++ b/admin/src/components/people/VideoCallModal.tsx @@ -57,7 +57,7 @@ export default function VideoCallModal({ open, onClose, personName }: VideoCallM const { data } = await api.post<{ token: string; jitsiRoom: string; domain: string }>( `/jitsi/meetings/${meeting.slug}/token`, ); - window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank'); + window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank', 'noopener,noreferrer'); } catch (err: unknown) { message.error(getErrorMessage(err, 'Failed to get moderator token')); } finally { diff --git a/admin/src/hooks/useMobile.ts b/admin/src/hooks/useMobile.ts new file mode 100644 index 00000000..4e602cdf --- /dev/null +++ b/admin/src/hooks/useMobile.ts @@ -0,0 +1,18 @@ +import { Grid } from 'antd'; + +const { useBreakpoint } = Grid; + +/** + * Shared mobile detection hook. + * Replaces duplicated `const screens = Grid.useBreakpoint(); const isMobile = !screens.md;` + * + * Breakpoints (Ant Design defaults): + * xs: 0px, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1600px + */ +export function useMobile() { + const screens = useBreakpoint(); + const isMobile = !screens.md; // < 768px + const isSmall = !screens.sm; // < 576px (very narrow phones) + const isTablet = !!screens.md && !screens.lg; // 768–991px + return { isMobile, isSmall, isTablet, screens }; +} diff --git a/admin/src/pages/ActionItemsPage.tsx b/admin/src/pages/ActionItemsPage.tsx index 9830ffd9..e5208d3f 100644 --- a/admin/src/pages/ActionItemsPage.tsx +++ b/admin/src/pages/ActionItemsPage.tsx @@ -361,6 +361,7 @@ export default function ActionItemsPage() { (); const [views, setViews] = useState([]); const [loading, setLoading] = useState(true); @@ -125,6 +127,7 @@ export default function AdminCalendarPage() { title: 'Layer Types', dataIndex: 'includedLayerTypes', key: 'layerTypes', + responsive: ['md'] as any, render: (types: string[]) => ( {types.map((t) => ( @@ -138,12 +141,14 @@ export default function AdminCalendarPage() { dataIndex: 'userCount', key: 'userCount', width: 80, + responsive: ['lg'] as any, }, { title: 'Created', dataIndex: 'createdAt', key: 'createdAt', width: 120, + responsive: ['md'] as any, render: (d: string) => dayjs(d).format('MMM D, YYYY'), }, { @@ -187,6 +192,7 @@ export default function AdminCalendarPage() {
setModalOpen(false)} confirmLoading={saving} + width={isMobile ? '100%' : undefined} destroyOnHidden > diff --git a/admin/src/pages/CanvassDashboardPage.tsx b/admin/src/pages/CanvassDashboardPage.tsx index 4dc66626..7661862a 100644 --- a/admin/src/pages/CanvassDashboardPage.tsx +++ b/admin/src/pages/CanvassDashboardPage.tsx @@ -132,6 +132,7 @@ export default function CanvassDashboardPage() { dataIndex: 'visitedAt', key: 'visitedAt', width: 80, + responsive: ['md'] as any, render: (val: string) => dayjs(val).format('h:mm A'), }, ]; @@ -144,12 +145,13 @@ export default function CanvassDashboardPage() { render: (_: unknown, r: VolunteerSummary) => r.name || r.email, }, { title: 'Visits', dataIndex: 'totalVisits', key: 'visits', width: 60, sorter: (a: VolunteerSummary, b: VolunteerSummary) => a.totalVisits - b.totalVisits }, - { title: 'Sessions', dataIndex: 'sessions', key: 'sessions', width: 70 }, + { title: 'Sessions', dataIndex: 'sessions', key: 'sessions', width: 70, responsive: ['md'] as any }, { title: 'Last Active', dataIndex: 'lastActive', key: 'lastActive', width: 120, + responsive: ['md'] as any, render: (val: string | null) => val ? dayjs(val).format('MMM D, h:mm A') : '—', }, ]; @@ -173,6 +175,7 @@ export default function CanvassDashboardPage() { dataIndex: 'lastCanvassed', key: 'lastCanvassed', width: 100, + responsive: ['md'] as any, render: (val: string | null) => val ? dayjs(val).format('MMM D') : 'Never', }, ]; @@ -288,7 +291,7 @@ export default function CanvassDashboardPage() { rowKey="id" size="small" pagination={false} - scroll={{ x: true }} + scroll={{ x: 'max-content' }} locale={{ emptyText: 'No activity recorded yet.' }} /> {activityPagination && activityPagination.total > 10 && ( @@ -305,7 +308,7 @@ export default function CanvassDashboardPage() { rowKey="id" size="small" pagination={false} - scroll={{ x: true }} + scroll={{ x: 'max-content' }} locale={{ emptyText: 'No cuts assigned.' }} /> @@ -317,7 +320,7 @@ export default function CanvassDashboardPage() { rowKey="userId" size="small" pagination={false} - scroll={{ x: true }} + scroll={{ x: 'max-content' }} locale={{ emptyText: 'No sessions recorded yet.' }} /> diff --git a/admin/src/pages/DashboardPage.tsx b/admin/src/pages/DashboardPage.tsx index 4c687bc8..6b859dbd 100644 --- a/admin/src/pages/DashboardPage.tsx +++ b/admin/src/pages/DashboardPage.tsx @@ -177,6 +177,7 @@ export default function DashboardPage() { const { user } = useAuthStore(); const { settings } = useSettingsStore(); const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']); useEffect(() => { @@ -341,18 +342,22 @@ export default function DashboardPage() { {showInfluence && } loading={moderating[record.id]} > - Reject + {!isMobile && 'Reject'} @@ -404,6 +408,7 @@ export default function DocsCommentsPage() {
(`/jitsi/meetings/${meeting.slug}/token`); const url = `${meetUrl}/${res.data.jitsiRoom}?jwt=${res.data.token}`; - window.open(url, '_blank'); + window.open(url, '_blank', 'noopener,noreferrer'); } catch { message.error('Failed to generate meeting token'); } @@ -149,7 +149,7 @@ export default function JitsiMeetPage() { await navigator.clipboard.writeText(guestLink); message.success('Guest link copied — opening meeting...'); - window.open(`${meetUrl}/${tokenRes.data.jitsiRoom}?jwt=${tokenRes.data.token}`, '_blank'); + window.open(`${meetUrl}/${tokenRes.data.jitsiRoom}?jwt=${tokenRes.data.token}`, '_blank', 'noopener,noreferrer'); fetchMeetings(); } catch { message.error('Failed to create fast meeting'); diff --git a/admin/src/pages/LandingPagesPage.tsx b/admin/src/pages/LandingPagesPage.tsx index 72a0fa33..4a94c7c4 100644 --- a/admin/src/pages/LandingPagesPage.tsx +++ b/admin/src/pages/LandingPagesPage.tsx @@ -484,6 +484,7 @@ export default function LandingPagesPage() { dataSource={pages} rowKey="id" loading={loading} + scroll={{ x: 'max-content' }} pagination={{ current: pagination.page, pageSize: pagination.limit, diff --git a/admin/src/pages/ListmonkPage.tsx b/admin/src/pages/ListmonkPage.tsx index 3a4b219a..814e7080 100644 --- a/admin/src/pages/ListmonkPage.tsx +++ b/admin/src/pages/ListmonkPage.tsx @@ -202,7 +202,7 @@ export default function ListmonkPage() { try { const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url'); const url = buildProxyAuthUrl(res.data.token); - if (url) window.open(url, '_blank'); + if (url) window.open(url, '_blank', 'noopener,noreferrer'); } catch { message.error('Failed to get Listmonk auth URL'); } diff --git a/admin/src/pages/MeetingAgendaPage.tsx b/admin/src/pages/MeetingAgendaPage.tsx index 86db336e..aab9fa92 100644 --- a/admin/src/pages/MeetingAgendaPage.tsx +++ b/admin/src/pages/MeetingAgendaPage.tsx @@ -501,6 +501,7 @@ export default function MeetingAgendaPage() {
(); + const { isMobile } = useMobile(); const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore(); const [navItems, setNavItems] = useState(DEFAULT_NAV_ITEMS); @@ -293,6 +295,7 @@ export default function NavigationSettingsPage() { alignItems: 'center', padding: '8px 12px', paddingLeft: indent ? 52 : 12, + minWidth: 600, background: isGroup ? 'rgba(100,150,255,0.06)' : item.enabled ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.01)', @@ -432,7 +435,7 @@ export default function NavigationSettingsPage() { const sorted = [...navItems].sort((a, b) => a.order - b.order); return ( -
+
)} -
+
{sorted.map((item, idx) => (
{renderItemRow(item, idx, sorted, false)} @@ -469,18 +472,18 @@ export default function NavigationSettingsPage() { ))}
- + diff --git a/admin/src/pages/PangolinPage.tsx b/admin/src/pages/PangolinPage.tsx index d6092b92..5d1840c3 100644 --- a/admin/src/pages/PangolinPage.tsx +++ b/admin/src/pages/PangolinPage.tsx @@ -406,6 +406,7 @@ export default function PangolinPage() { Resources to Create ({resourceDefinitions.length})
0 && (
({ onClick: () => setDetailResponse(record), style: { cursor: 'pointer' }, diff --git a/admin/src/pages/SchedulingCalendarPage.tsx b/admin/src/pages/SchedulingCalendarPage.tsx index 23ad5506..d8f4eab6 100644 --- a/admin/src/pages/SchedulingCalendarPage.tsx +++ b/admin/src/pages/SchedulingCalendarPage.tsx @@ -22,6 +22,7 @@ import { import dayjs from 'dayjs'; import UnifiedCalendar from '@/components/calendar/UnifiedCalendar'; import { api } from '@/lib/api'; +import { useMobile } from '@/hooks/useMobile'; import type { UnifiedCalendarItem, AdminCalendarView } from '@/types/api'; import { useNavigate } from 'react-router-dom'; import { ROLE_COLORS, ROLE_OPTIONS } from '@/utils/role-constants'; @@ -40,6 +41,7 @@ const LAYER_TYPE_OPTIONS = [ export default function SchedulingCalendarPage() { const navigate = useNavigate(); + const { isMobile } = useMobile(); const addEventRef = useRef<(() => void) | null>(null); // Panel state @@ -154,6 +156,7 @@ export default function SchedulingCalendarPage() { dataIndex: 'createdAt', key: 'createdAt', width: 100, + responsive: ['md'] as any, render: (d: string) => dayjs(d).format('MMM D, YYYY'), }, { @@ -171,8 +174,10 @@ export default function SchedulingCalendarPage() { }, ]; - // Compute right margin to squish calendar when drawers are open - const drawerOffset = (viewsOpen ? VIEWS_PANEL_WIDTH : 0) + (formOpen ? FORM_PANEL_WIDTH : 0); + // Compute right margin to squish calendar when drawers are open (skip on mobile — drawers overlay) + const viewsWidth = isMobile ? '100%' : VIEWS_PANEL_WIDTH; + const formWidth = isMobile ? '100%' : FORM_PANEL_WIDTH; + const drawerOffset = isMobile ? 0 : (viewsOpen ? VIEWS_PANEL_WIDTH : 0) + (formOpen ? FORM_PANEL_WIDTH : 0); const DRAWER_ROOT = { position: 'absolute' as const, top: 64, height: 'calc(100vh - 64px)' }; @@ -212,7 +217,7 @@ export default function SchedulingCalendarPage() { type={viewsOpen ? 'primary' : 'default'} onClick={() => viewsOpen ? closeViews() : setViewsOpen(true)} > - Shared Views + {!isMobile && 'Shared Views'} @@ -228,13 +233,13 @@ export default function SchedulingCalendarPage() { open={viewsOpen} onClose={closeViews} mask={false} - width={VIEWS_PANEL_WIDTH} + width={viewsWidth} rootStyle={DRAWER_ROOT} destroyOnHidden styles={{ wrapper: { transition: 'transform 0.3s ease, width 0.3s ease', - transform: formOpen ? `translateX(-${FORM_PANEL_WIDTH}px)` : undefined, + transform: formOpen && !isMobile ? `translateX(-${FORM_PANEL_WIDTH}px)` : undefined, }, }} extra={ @@ -246,6 +251,7 @@ export default function SchedulingCalendarPage() {
setFormOpen(false)} mask={false} - width={FORM_PANEL_WIDTH} + width={formWidth} rootStyle={DRAWER_ROOT} destroyOnHidden extra={ diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index 9a4725c4..ccd2e043 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -58,6 +58,7 @@ import { } from '@ant-design/icons'; import { useSettingsStore } from '@/stores/settings.store'; import { api } from '@/lib/api'; +import { useMobile } from '@/hooks/useMobile'; import { PageTour } from '@/components/tour/PageTour'; import type { AppOutletContext } from '@/components/AppLayout'; import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult, UpgradeHistoryResponse } from '@/types/api'; @@ -66,6 +67,7 @@ const { Text, Paragraph } = Typography; export default function SettingsPage() { const { setPageHeader } = useOutletContext(); + const { isMobile } = useMobile(); const locationState = useLocation().state as { activeTab?: string } | null; const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore(); const [form] = Form.useForm(); @@ -173,7 +175,7 @@ export default function SettingsPage() { label: 'Organization', icon: , children: ( -
+
@@ -199,7 +201,7 @@ export default function SettingsPage() { key: 'theme', label: 'Theme Colors', children: ( -
+
Admin Theme @@ -250,7 +252,7 @@ export default function SettingsPage() { const isMailhog = (settings?.smtpActiveProvider || 'mailhog') === 'mailhog'; return ( -
+
{/* Current Configuration Summary */} {eff && ( @@ -682,7 +684,7 @@ export default function SettingsPage() { return ( - +
), diff --git a/admin/src/pages/events/TicketedEventsPage.tsx b/admin/src/pages/events/TicketedEventsPage.tsx index 859d4c62..0fc69c15 100644 --- a/admin/src/pages/events/TicketedEventsPage.tsx +++ b/admin/src/pages/events/TicketedEventsPage.tsx @@ -334,6 +334,7 @@ export default function TicketedEventsPage() { columns={columns} rowKey="id" loading={loading} + scroll={{ x: 'max-content' }} pagination={{ current: pagination.page, pageSize: pagination.limit, diff --git a/admin/src/pages/influence/CampaignModerationPage.tsx b/admin/src/pages/influence/CampaignModerationPage.tsx index 5759545b..da2f5166 100644 --- a/admin/src/pages/influence/CampaignModerationPage.tsx +++ b/admin/src/pages/influence/CampaignModerationPage.tsx @@ -134,6 +134,7 @@ export default function CampaignModerationPage() { { title: 'Creator', key: 'creator', + responsive: ['md'] as any, render: (_, record) => (
{record.createdByUserName || 'Unknown'} @@ -155,6 +156,7 @@ export default function CampaignModerationPage() { title: 'Levels', dataIndex: 'targetGovernmentLevels', key: 'levels', + responsive: ['lg'] as any, render: (levels: GovernmentLevel[]) => ( {levels.map(l => {GOVERNMENT_LEVEL_LABELS[l]})} @@ -165,6 +167,7 @@ export default function CampaignModerationPage() { title: 'Submitted', dataIndex: 'createdAt', key: 'createdAt', + responsive: ['md'] as any, render: (d: string) => dayjs(d).format('MMM D, YYYY'), }, { @@ -181,7 +184,7 @@ export default function CampaignModerationPage() { style={{ background: '#52c41a', borderColor: '#52c41a' }} onClick={() => handleModerate(record.id, 'approve')} > - Approve + {!isMobile && 'Approve'} )} @@ -278,7 +281,7 @@ export default function CampaignModerationPage() { showSizeChanger: false, showTotal: (t) => `${t} campaigns`, }} - scroll={{ x: 800 }} + scroll={{ x: 'max-content' }} /> {/* Detail Drawer */} diff --git a/admin/src/pages/influence/ImpactStoriesPage.tsx b/admin/src/pages/influence/ImpactStoriesPage.tsx index 38ad71f4..57a02f01 100644 --- a/admin/src/pages/influence/ImpactStoriesPage.tsx +++ b/admin/src/pages/influence/ImpactStoriesPage.tsx @@ -23,6 +23,7 @@ import { import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import dayjs from 'dayjs'; import { api } from '@/lib/api'; +import { useMobile } from '@/hooks/useMobile'; import axios from 'axios'; const { TextArea } = Input; @@ -62,6 +63,7 @@ const statusColors: Record = { }; export default function ImpactStoriesPage() { + const { isMobile } = useMobile(); const [stories, setStories] = useState([]); const [campaigns, setCampaigns] = useState([]); const [loading, setLoading] = useState(false); @@ -197,6 +199,7 @@ export default function ImpactStoriesPage() { title: 'Campaign', dataIndex: ['campaign', 'title'], ellipsis: true, + responsive: ['md'] as any, }, { title: 'Type', @@ -214,12 +217,14 @@ export default function ImpactStoriesPage() { title: 'Milestone', dataIndex: 'milestoneValue', width: 100, + responsive: ['lg'] as any, render: (val: number | null) => val ? {val} : '-', }, { title: 'Published', dataIndex: 'publishedAt', width: 140, + responsive: ['md'] as any, render: (date: string | null) => date ? dayjs(date).format('MMM D, YYYY') : '-', }, { @@ -282,6 +287,7 @@ export default function ImpactStoriesPage() { />
setModalOpen(false)} onOk={handleSave} - width={640} + width={isMobile ? '100%' : 640} destroyOnHidden > diff --git a/admin/src/pages/influence/StrawPollsPage.tsx b/admin/src/pages/influence/StrawPollsPage.tsx index 841f7909..ca2ba204 100644 --- a/admin/src/pages/influence/StrawPollsPage.tsx +++ b/admin/src/pages/influence/StrawPollsPage.tsx @@ -225,6 +225,7 @@ export default function StrawPollsPage() {
))} - - + + - + @@ -185,8 +185,8 @@ export default function AnalyticsDashboardPage() { {/* Additional Stats */} - - + + - + ({ diff --git a/admin/src/pages/media/PlaylistManagementPage.tsx b/admin/src/pages/media/PlaylistManagementPage.tsx index 65b11500..fb39eced 100644 --- a/admin/src/pages/media/PlaylistManagementPage.tsx +++ b/admin/src/pages/media/PlaylistManagementPage.tsx @@ -29,6 +29,7 @@ import { import dayjs from 'dayjs'; import { mediaApi } from '@/lib/media-api'; import type { AppOutletContext } from '@/types/api'; +import { useMobile } from '@/hooks/useMobile'; import CreatePlaylistModal from '@/components/media/CreatePlaylistModal'; import EditPlaylistModal from '@/components/media/EditPlaylistModal'; import axios from 'axios'; @@ -86,6 +87,7 @@ const sorterFieldMap: Record = { export default function PlaylistManagementPage() { const { setPageHeader } = useOutletContext(); + const { isMobile } = useMobile(); useEffect(() => { setPageHeader({ title: 'Playlist Management' }); @@ -311,6 +313,7 @@ export default function PlaylistManagementPage() { { title: 'Creator', key: 'creator', + responsive: ['md'] as any, render: (_: any, record: PlaylistRow) => ( {record.creator.name || record.creator.email} @@ -330,6 +333,7 @@ export default function PlaylistManagementPage() { dataIndex: 'totalDurationSeconds', key: 'duration', width: 100, + responsive: ['lg'] as any, sorter: true, render: (seconds: number) => formatDuration(seconds), }, @@ -346,6 +350,7 @@ export default function PlaylistManagementPage() { dataIndex: 'createdAt', key: 'createdAt', width: 110, + responsive: ['md'] as any, sorter: true, render: (date: string) => dayjs(date).format('MMM D, YYYY'), }, @@ -354,6 +359,7 @@ export default function PlaylistManagementPage() { dataIndex: 'isPublic', key: 'isPublic', width: 90, + responsive: ['md'] as any, render: (isPublic: boolean, record: PlaylistRow) => ( ( {record.featuredBy?.name || record.featuredBy?.email || '\u2014'} @@ -526,7 +533,7 @@ export default function PlaylistManagementPage() { setPagination((p) => ({ ...p, current: 1 })); }} allowClear - style={{ width: 300 }} + style={{ width: isMobile ? '100%' : 300 }} /> {/* Bulk actions bar */} @@ -539,7 +546,7 @@ export default function PlaylistManagementPage() { onClick={() => handleBulkAction('public')} loading={bulkLoading} > - Make Public + {!isMobile && 'Make Public'} (); + const isMobile = !Grid.useBreakpoint().md; const [donations, setDonations] = useState([]); const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 }); const [loading, setLoading] = useState(false); @@ -164,7 +165,7 @@ export default function DonationsPage() { setRefundModalOpen(true); }} > - Refund + {!isMobile && 'Refund'} )} @@ -207,6 +208,7 @@ export default function DonationsPage() { columns={columns} rowKey="id" loading={loading} + scroll={{ x: 'max-content' }} pagination={{ current: pagination.page, pageSize: pagination.limit, diff --git a/admin/src/pages/payments/PaymentsDashboardPage.tsx b/admin/src/pages/payments/PaymentsDashboardPage.tsx index 1dbbc73a..80657a96 100644 --- a/admin/src/pages/payments/PaymentsDashboardPage.tsx +++ b/admin/src/pages/payments/PaymentsDashboardPage.tsx @@ -85,6 +85,7 @@ export default function PaymentsDashboardPage() { columns={donationColumns} rowKey="id" pagination={false} + scroll={{ x: 'max-content' }} size="small" /> diff --git a/admin/src/pages/payments/PlansPage.tsx b/admin/src/pages/payments/PlansPage.tsx index f591e147..fa4d50fe 100644 --- a/admin/src/pages/payments/PlansPage.tsx +++ b/admin/src/pages/payments/PlansPage.tsx @@ -274,6 +274,7 @@ export default function PlansPage() { columns={columns} rowKey="id" loading={loading} + scroll={{ x: 'max-content' }} pagination={{ current: pagination.page, pageSize: pagination.limit, diff --git a/admin/src/pages/payments/ProductsPage.tsx b/admin/src/pages/payments/ProductsPage.tsx index 7f7250be..0061591c 100644 --- a/admin/src/pages/payments/ProductsPage.tsx +++ b/admin/src/pages/payments/ProductsPage.tsx @@ -278,6 +278,7 @@ export default function ProductsPage() { columns={columns} rowKey="id" loading={loading} + scroll={{ x: 'max-content' }} pagination={{ current: pagination.page, pageSize: pagination.limit, diff --git a/admin/src/pages/payments/SubscribersPage.tsx b/admin/src/pages/payments/SubscribersPage.tsx index 809d56cc..c1900c00 100644 --- a/admin/src/pages/payments/SubscribersPage.tsx +++ b/admin/src/pages/payments/SubscribersPage.tsx @@ -165,6 +165,7 @@ export default function SubscribersPage() { columns={columns} rowKey="id" loading={loading} + scroll={{ x: 'max-content' }} pagination={{ current: pagination.page, pageSize: pagination.limit, diff --git a/admin/src/pages/public/MeetingJoinPage.tsx b/admin/src/pages/public/MeetingJoinPage.tsx index 58827d1f..b1e1e3f7 100644 --- a/admin/src/pages/public/MeetingJoinPage.tsx +++ b/admin/src/pages/public/MeetingJoinPage.tsx @@ -59,7 +59,7 @@ export default function MeetingJoinPage() { const handleJoin = () => { if (!meeting) return; const url = `https://${meeting.domain}/${meeting.jitsiRoom}`; - window.open(url, '_blank'); + window.open(url, '_blank', 'noopener,noreferrer'); }; if (loading) { diff --git a/admin/src/pages/sms/SmsCampaignsPage.tsx b/admin/src/pages/sms/SmsCampaignsPage.tsx index 56dfa522..a914b96e 100644 --- a/admin/src/pages/sms/SmsCampaignsPage.tsx +++ b/admin/src/pages/sms/SmsCampaignsPage.tsx @@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table'; import { api } from '@/lib/api'; import type { SmsCampaign, SmsContactList, SmsPaginatedResponse } from '@/types/sms'; import type { AppOutletContext } from '@/types/api'; +import { useMobile } from '@/hooks/useMobile'; import { useOutletContext } from 'react-router-dom'; const { TextArea } = Input; @@ -21,6 +22,7 @@ const STATUS_COLORS: Record = { export default function SmsCampaignsPage() { const { setPageHeader } = useOutletContext(); const { message } = App.useApp(); + const { isMobile } = useMobile(); const [campaigns, setCampaigns] = useState([]); const [total, setTotal] = useState(0); @@ -173,6 +175,7 @@ export default function SmsCampaignsPage() { title: 'Responses', dataIndex: 'totalResponded', width: 100, + responsive: ['lg'] as any, render: (v, record) => { const rate = record.totalSent > 0 ? ((v / record.totalSent) * 100).toFixed(1) : '0'; return {v} ({rate}%); @@ -181,12 +184,14 @@ export default function SmsCampaignsPage() { { title: 'Contact List', width: 150, + responsive: ['md'] as any, render: (_, record) => record.contactList?.name || -, }, { title: 'Created', dataIndex: 'createdAt', width: 100, + responsive: ['md'] as any, render: (d) => new Date(d).toLocaleDateString(), }, { @@ -196,14 +201,14 @@ export default function SmsCampaignsPage() { {record.status === 'DRAFT' && ( handleStart(record.id)}> - + )} {record.status === 'RUNNING' && ( - + )} {record.status === 'PAUSED' && ( - + )} {record.status === 'DRAFT' && ( handleDelete(record.id)}> @@ -215,11 +220,11 @@ export default function SmsCampaignsPage() { }, ]; - const drawerWidth = 480; + const drawerWidth = isMobile ? '100%' : 480; return ( <> -
+
diff --git a/admin/src/pages/sms/SmsContactsPage.tsx b/admin/src/pages/sms/SmsContactsPage.tsx index e016fe4b..f3ef2134 100644 --- a/admin/src/pages/sms/SmsContactsPage.tsx +++ b/admin/src/pages/sms/SmsContactsPage.tsx @@ -539,6 +539,7 @@ export default function SmsContactsPage() { showSizeChanger: false, }} size="small" + scroll={{ x: 'max-content' }} /> ), }]} @@ -575,6 +576,7 @@ export default function SmsContactsPage() { columns={contactColumns} dataSource={entries} loading={entriesLoading} + scroll={{ x: 'max-content' }} rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys, diff --git a/admin/src/pages/sms/SmsTemplatesPage.tsx b/admin/src/pages/sms/SmsTemplatesPage.tsx index 55d40a62..e2c93baa 100644 --- a/admin/src/pages/sms/SmsTemplatesPage.tsx +++ b/admin/src/pages/sms/SmsTemplatesPage.tsx @@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table'; import { api } from '@/lib/api'; import type { SmsMessageTemplate, SmsPaginatedResponse } from '@/types/sms'; import type { AppOutletContext } from '@/types/api'; +import { useMobile } from '@/hooks/useMobile'; import { useOutletContext } from 'react-router-dom'; import { useDebounce } from '@/hooks/useDebounce'; @@ -55,6 +56,7 @@ function renderPreview(template: string): string { export default function SmsTemplatesPage() { const { setPageHeader } = useOutletContext(); const { message } = App.useApp(); + const { isMobile } = useMobile(); const [templates, setTemplates] = useState([]); const [total, setTotal] = useState(0); @@ -205,6 +207,7 @@ export default function SmsTemplatesPage() { { title: 'Variables', width: 200, + responsive: ['lg'] as any, render: (_, record) => ( {(record.variables || []).map((v) => ( @@ -219,11 +222,13 @@ export default function SmsTemplatesPage() { dataIndex: 'usageCount', width: 70, align: 'center', + responsive: ['md'] as any, }, { title: 'Updated', dataIndex: 'updatedAt', width: 100, + responsive: ['md'] as any, render: (d) => { const diff = Date.now() - new Date(d).getTime(); const mins = Math.floor(diff / 60000); @@ -268,11 +273,11 @@ export default function SmsTemplatesPage() { }, ]; - const drawerWidth = 480; + const drawerWidth = isMobile ? '100%' : 480; return ( <> -
+
diff --git a/admin/src/pages/social/ChallengesAdminPage.tsx b/admin/src/pages/social/ChallengesAdminPage.tsx index a14cb66b..1deec0f2 100644 --- a/admin/src/pages/social/ChallengesAdminPage.tsx +++ b/admin/src/pages/social/ChallengesAdminPage.tsx @@ -26,6 +26,7 @@ import { import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; import { api } from '@/lib/api'; +import { useMobile } from '@/hooks/useMobile'; import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard'; import type { PaginationMeta } from '@/types/api'; import axios from 'axios'; @@ -57,6 +58,7 @@ const STATUS_TABS = ['ALL', 'DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELL export default function ChallengesAdminPage() { const { message } = App.useApp(); + const { isMobile } = useMobile(); const [challenges, setChallenges] = useState([]); const [pagination, setPagination] = useState(null); const [loading, setLoading] = useState(true); @@ -166,6 +168,7 @@ export default function ChallengesAdminPage() { dataIndex: 'metric', key: 'metric', width: 160, + responsive: ['md'] as any, render: (m: string) => { const info = METRIC_MAP[m]; return info ? {info.label} : m; @@ -182,6 +185,7 @@ export default function ChallengesAdminPage() { title: 'Period', key: 'period', width: 180, + responsive: ['md'] as any, render: (_: unknown, r: ChallengeRow) => `${dayjs(r.startsAt).format('MMM D')} - ${dayjs(r.endsAt).format('MMM D')}`, }, @@ -189,6 +193,7 @@ export default function ChallengesAdminPage() { title: 'Teams', key: 'teams', width: 70, + responsive: ['lg'] as any, render: (_: unknown, r: ChallengeRow) => r._count?.teams ?? 0, }, { @@ -199,25 +204,25 @@ export default function ChallengesAdminPage() { {r.status === 'DRAFT' && ( )} {r.status === 'ACTIVE' && ( <> )} {(r.status === 'DRAFT' || r.status === 'UPCOMING') && ( - + )} {r.status !== 'COMPLETED' && r.status !== 'CANCELLED' && ( handleAction(r.id, 'cancel')}> - + )} {(r.status === 'DRAFT' || r.status === 'CANCELLED') && ( @@ -232,11 +237,12 @@ export default function ChallengesAdminPage() { return (
-
+
{ setStatusFilter(key); setPage(1); }} items={STATUS_TABS.map((s) => ({ key: s, label: s === 'ALL' ? 'All' : s }))} + size={isMobile ? 'small' : 'middle'} style={{ marginBottom: 0 }} />
Top Connected Users} size="small">
= { export default function SocialModerationPage() { const { setPageHeader } = useOutletContext(); + const { isMobile } = useMobile(); const [loading, setLoading] = useState(true); const [data, setData] = useState(null); const [blockPage, setBlockPage] = useState(1); @@ -188,6 +190,7 @@ export default function SocialModerationPage() { children: (
new Date(v).toLocaleDateString(), }, { @@ -235,7 +239,7 @@ export default function SocialModerationPage() { okType="danger" > ), @@ -260,6 +264,7 @@ export default function SocialModerationPage() {
, sorter: (a, b) => a.unlockRate - b.unlockRate, }, @@ -333,6 +339,7 @@ export default function SocialModerationPage() {
()( }, hydrate: async () => { - const { accessToken } = get(); - if (!accessToken) { - set({ isLoading: false }); - return; - } + // Always attempt to restore session via httpOnly refresh cookie. + // Access token is no longer persisted to localStorage (XSS protection). try { - await get().fetchMe(); + const { data } = await api.post('/auth/refresh'); + set({ + accessToken: data.accessToken || null, + user: data.user, + isAuthenticated: true, + isLoading: false, + }); } catch { + // No valid refresh cookie — user is not logged in set({ isLoading: false }); } }, @@ -167,9 +171,9 @@ export const useAuthStore = create()( }), { name: 'cml-auth', - partialize: (state) => ({ - accessToken: state.accessToken, - }), + // Do not persist accessToken to localStorage — XSS could steal it. + // Session is restored via httpOnly refresh cookie in hydrate(). + partialize: () => ({}), } ) ); diff --git a/api/src/config/env.ts b/api/src/config/env.ts index e5763672..909c0e0f 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -38,9 +38,9 @@ const envSchema = z.object({ // 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'), - // Gitea SSO cookie signing secret (falls back to JWT_ACCESS_SECRET if empty) + // Gitea SSO cookie signing secret — MUST be unique (key separation from JWT) GITEA_SSO_SECRET: z.string().default(''), - // Salt for deriving deterministic service passwords (Gitea, Rocket.Chat — falls back to JWT_ACCESS_SECRET if empty) + // Salt for deriving deterministic service passwords (Gitea, Rocket.Chat) — MUST be unique SERVICE_PASSWORD_SALT: z.string().default(''), // Initial Super Admin (auto-created during database seeding) @@ -259,7 +259,17 @@ function validateEnv(): Env { console.error(result.error.flatten().fieldErrors); process.exit(1); } - return result.data; + + // Warn about security-critical key separation issues + const data = result.data; + if (!data.GITEA_SSO_SECRET) { + 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'); + } + 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(); diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts index 1aea9dfb..5a8d9f29 100644 --- a/api/src/middleware/rate-limit.ts +++ b/api/src/middleware/rate-limit.ts @@ -209,7 +209,7 @@ export const paymentCheckoutRateLimit = rateLimit({ export const authRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, - max: 10, // Reduced from 20 to prevent brute force attacks + max: 15, standardHeaders: true, legacyHeaders: false, store: new RedisStore({ @@ -224,6 +224,25 @@ export const authRateLimit = rateLimit({ }, }); +// Separate stricter rate limit for login to prevent credential stuffing +// Isolated from the general auth budget so register/refresh/logout don't consume login slots +export const loginRateLimit = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + store: new RedisStore({ + sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise, + prefix: 'rl:login:', + }), + message: { + error: { + message: 'Too many login attempts, please try again later', + code: 'LOGIN_RATE_LIMIT_EXCEEDED', + }, + }, +}); + export const observabilityRateLimit = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 20, // 20 requests per minute (stricter than global 500/min) diff --git a/api/src/modules/auth/auth.routes.ts b/api/src/modules/auth/auth.routes.ts index b1dc2535..da4ba92e 100644 --- a/api/src/modules/auth/auth.routes.ts +++ b/api/src/modules/auth/auth.routes.ts @@ -7,7 +7,7 @@ import { authService } from './auth.service'; import { loginSchema, registerSchema, refreshSchema } from './auth.schemas'; import { validate } from '../../middleware/validate'; import { authenticate } from '../../middleware/auth.middleware'; -import { authRateLimit } from '../../middleware/rate-limit'; +import { authRateLimit, loginRateLimit } from '../../middleware/rate-limit'; import { prisma } from '../../config/database'; import { verificationTokenService } from '../../services/verification-token.service'; import { passwordResetTokenService } from '../../services/password-reset-token.service'; @@ -99,7 +99,7 @@ function clearSessionCookie(req: Request, res: Response) { // POST /api/auth/login router.post( '/login', - authRateLimit, + loginRateLimit, validate(loginSchema), async (req: Request, res: Response, next: NextFunction) => { try { diff --git a/api/src/modules/docs-analytics/docs-analytics.routes.ts b/api/src/modules/docs-analytics/docs-analytics.routes.ts index c7cfbafa..c2484707 100644 --- a/api/src/modules/docs-analytics/docs-analytics.routes.ts +++ b/api/src/modules/docs-analytics/docs-analytics.routes.ts @@ -12,7 +12,7 @@ export const docsAnalyticsPublicRouter = Router(); // Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain) import { env } from '../../config/env'; -const DOCS_ORIGIN = env.ADMIN_URL || `https://docs.${env.DOMAIN}`; +const DOCS_ORIGIN = env.DOMAIN ? `https://docs.${env.DOMAIN}` : (env.ADMIN_URL || 'http://localhost:4003'); docsAnalyticsPublicRouter.use((_req, res, next) => { res.setHeader('Access-Control-Allow-Origin', DOCS_ORIGIN); res.setHeader('Vary', 'Origin'); diff --git a/api/src/modules/docs/docs.routes.ts b/api/src/modules/docs/docs.routes.ts index 6c2cb2b9..b836313d 100644 --- a/api/src/modules/docs/docs.routes.ts +++ b/api/src/modules/docs/docs.routes.ts @@ -397,6 +397,15 @@ router.post( res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } }); return; } + + // Per-file access check (matches PUT/DELETE guards) + const userRoles = getUserRoles(req.user!); + const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath); + if (!canEdit) { + res.status(403).json({ error: { message: 'You do not have edit access to this directory', code: 'DOC_ACCESS_DENIED' } }); + return; + } + const { content, isDirectory } = req.body as { content?: string; isDirectory?: boolean }; await docsFilesService.createFile(filePath, content, isDirectory); res.status(201).json({ success: true, path: filePath }); diff --git a/api/src/modules/gitea-setup/gitea-setup.service.ts b/api/src/modules/gitea-setup/gitea-setup.service.ts index 75a62fd7..6b98255b 100644 --- a/api/src/modules/gitea-setup/gitea-setup.service.ts +++ b/api/src/modules/gitea-setup/gitea-setup.service.ts @@ -3,6 +3,7 @@ import { prisma } from '../../config/database'; import { logger } from '../../utils/logger'; import { encrypt } from '../../utils/crypto'; import { giteaClient } from '../../services/gitea.client'; +import { updateEnvFile } from '../../services/env-writer.service'; const SETUP_TIMEOUT = 15000; @@ -429,6 +430,17 @@ async function autoSetupIfNeeded(): Promise<{ alreadyComplete: boolean; success: for (const step of result.steps) { logger.info(` ${step.step}: ${step.success ? 'OK' : 'FAILED'}${step.data?.note ? ` (${step.data.note})` : ''}`); } + + // Clear GITEA_ADMIN_PASSWORD from .env — it's no longer needed after setup + // (the API token is now encrypted in the database) + try { + const envResult = updateEnvFile({ GITEA_ADMIN_PASSWORD: '' }); + if (envResult.success) { + logger.info('Gitea auto-setup: cleared GITEA_ADMIN_PASSWORD from .env'); + } + } catch (err) { + logger.warn(`Gitea auto-setup: could not clear GITEA_ADMIN_PASSWORD from .env: ${err instanceof Error ? err.message : err}`); + } } else { logger.warn(`Gitea auto-setup: failed — ${result.error}`); for (const step of result.steps) { diff --git a/api/src/modules/influence/effectiveness/effectiveness.service.ts b/api/src/modules/influence/effectiveness/effectiveness.service.ts index f13d8486..a8e37a17 100644 --- a/api/src/modules/influence/effectiveness/effectiveness.service.ts +++ b/api/src/modules/influence/effectiveness/effectiveness.service.ts @@ -286,7 +286,12 @@ export const effectivenessService = { } // For city/province grouping, we need to join with postal_code_cache - const groupCol = query.groupBy === 'province' ? Prisma.raw('pcc.province') : Prisma.raw('pcc.city'); + // Use pre-validated lookup map to prevent SQL injection if enum expands + const GROUP_COL_MAP: Record> = { + province: Prisma.sql`pcc.province`, + city: Prisma.sql`pcc.city`, + }; + const groupCol = GROUP_COL_MAP[query.groupBy!] ?? GROUP_COL_MAP.city; const campaignFilter = query.campaignId ? Prisma.sql`AND ce."campaignId" = ${query.campaignId}` : Prisma.sql``; diff --git a/api/src/modules/listmonk/listmonk-webhook.routes.ts b/api/src/modules/listmonk/listmonk-webhook.routes.ts index eeb80000..f05eb8a2 100644 --- a/api/src/modules/listmonk/listmonk-webhook.routes.ts +++ b/api/src/modules/listmonk/listmonk-webhook.routes.ts @@ -1,4 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; +import { timingSafeEqual } from 'crypto'; import { prisma } from '../../config/database'; import { env } from '../../config/env'; import { logger } from '../../utils/logger'; @@ -18,7 +19,14 @@ router.post( try { // Accept secret from header only (query param removed — secrets must not appear in logs) const secret = req.headers['x-webhook-secret'] as string; - if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) { + if (!env.LISTMONK_WEBHOOK_SECRET || !secret) { + res.status(403).json({ error: 'Invalid webhook secret' }); + return; + } + // Constant-time comparison to prevent timing attacks + const incoming = Buffer.from(secret); + const expected = Buffer.from(env.LISTMONK_WEBHOOK_SECRET); + if (incoming.length !== expected.length || !timingSafeEqual(incoming, expected)) { res.status(403).json({ error: 'Invalid webhook secret' }); return; } diff --git a/api/src/modules/map/canvass/canvass.routes.ts b/api/src/modules/map/canvass/canvass.routes.ts index f82c243c..9ba9d68e 100644 --- a/api/src/modules/map/canvass/canvass.routes.ts +++ b/api/src/modules/map/canvass/canvass.routes.ts @@ -18,13 +18,14 @@ import { locationsService } from '../locations/locations.service'; import { geocodingService } from '../geocoding/geocoding.service'; import { validate } from '../../../middleware/validate'; import { authenticate } from '../../../middleware/auth.middleware'; -import { requireRole } from '../../../middleware/rbac.middleware'; +import { requireRole, requireNonTemp } from '../../../middleware/rbac.middleware'; import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit'; import { MAP_ROLES } from '../../../utils/roles'; // ─── Volunteer Router ──────────────────────────────────────────────── const volunteerRouter = Router(); volunteerRouter.use(authenticate); +volunteerRouter.use(requireNonTemp); // GET /api/map/canvass/my/assignments volunteerRouter.get( diff --git a/api/src/modules/map/tracking/tracking.routes.ts b/api/src/modules/map/tracking/tracking.routes.ts index 48c9743f..c76c3fc0 100644 --- a/api/src/modules/map/tracking/tracking.routes.ts +++ b/api/src/modules/map/tracking/tracking.routes.ts @@ -73,7 +73,7 @@ volunteerRouter.post( async (req: Request, res: Response, next: NextFunction) => { try { const id = req.params.id as string; - const session = await trackingService.linkCanvassSession(id, req.body); + const session = await trackingService.linkCanvassSession(id, req.user!.id, req.body); res.json(session); } catch (err) { next(err); diff --git a/api/src/modules/map/tracking/tracking.service.ts b/api/src/modules/map/tracking/tracking.service.ts index dd5a8182..d33b2ce7 100644 --- a/api/src/modules/map/tracking/tracking.service.ts +++ b/api/src/modules/map/tracking/tracking.service.ts @@ -113,8 +113,14 @@ class TrackingService { }); } - /** Link a canvass session to an existing tracking session. */ - async linkCanvassSession(trackingSessionId: string, data: LinkCanvassInput) { + /** Link a canvass session to an existing tracking session (ownership-verified). */ + async linkCanvassSession(trackingSessionId: string, userId: string, data: LinkCanvassInput) { + const session = await prisma.trackingSession.findFirst({ + where: { id: trackingSessionId, userId, isActive: true }, + }); + if (!session) { + throw Object.assign(new Error('Active tracking session not found'), { statusCode: 404 }); + } return prisma.trackingSession.update({ where: { id: trackingSessionId }, data: { canvassSessionId: data.canvassSessionId }, diff --git a/api/src/modules/og/og.routes.ts b/api/src/modules/og/og.routes.ts index 0b0e739b..bb4abbb2 100644 --- a/api/src/modules/og/og.routes.ts +++ b/api/src/modules/og/og.routes.ts @@ -1,6 +1,7 @@ import { Router, Request, Response, NextFunction } from 'express'; import { prisma } from '../../config/database'; import { redis } from '../../config/redis'; +import { env } from '../../config/env'; import { siteSettingsService } from '../settings/settings.service'; import { escapeHtml } from '../../utils/escapeHtml'; @@ -65,8 +66,8 @@ router.get('/campaign/:slug', async (req: Request, res: Response, next: NextFunc if (!campaign) { res.status(404).send('Not found'); return; } const settings = await siteSettingsService.getPublic(); - const appDomain = req.get('host') || 'app.cmlite.org'; - const protocol = req.protocol; + const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org'; + const protocol = env.DOMAIN ? 'https' : req.protocol; const url = `${protocol}://${appDomain}/campaign/${slug}`; const html = ogHtml({ @@ -104,8 +105,8 @@ router.get('/page/:slug', async (req: Request, res: Response, next: NextFunction if (!page) { res.status(404).send('Not found'); return; } const settings = await siteSettingsService.getPublic(); - const appDomain = req.get('host') || 'app.cmlite.org'; - const protocol = req.protocol; + const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org'; + const protocol = env.DOMAIN ? 'https' : req.protocol; const url = `${protocol}://${appDomain}/p/${slug}`; const html = ogHtml({ @@ -146,8 +147,8 @@ router.get('/gallery/:id', async (req: Request, res: Response, next: NextFunctio if (!video) { res.status(404).send('Not found'); return; } const settings = await siteSettingsService.getPublic(); - const appDomain = req.get('host') || 'app.cmlite.org'; - const protocol = req.protocol; + const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org'; + const protocol = env.DOMAIN ? 'https' : req.protocol; const url = `${protocol}://${appDomain}/gallery/watch/${id}`; const html = ogHtml({ diff --git a/api/src/modules/search/search.routes.ts b/api/src/modules/search/search.routes.ts index 40fb9653..22e5b3f1 100644 --- a/api/src/modules/search/search.routes.ts +++ b/api/src/modules/search/search.routes.ts @@ -1,7 +1,9 @@ import { Router, Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; import rateLimit from 'express-rate-limit'; import RedisStore from 'rate-limit-redis'; import { redis } from '../../config/redis'; +import { env } from '../../config/env'; import { search } from './search.service'; const router = Router(); @@ -27,7 +29,16 @@ router.get('/', searchRateLimit, async (req: Request, res: Response, next: NextF res.json([]); return; } - const results = await search(q, limit); + // Lightweight auth check — shifts only returned for authenticated users + let isAuthenticated = false; + const authHeader = req.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + try { + jwt.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }); + isAuthenticated = true; + } catch { /* unauthenticated — public results only */ } + } + const results = await search(q, limit, isAuthenticated); res.json(results); } catch (err) { next(err); diff --git a/api/src/modules/search/search.service.ts b/api/src/modules/search/search.service.ts index 623cc28a..014f7dac 100644 --- a/api/src/modules/search/search.service.ts +++ b/api/src/modules/search/search.service.ts @@ -14,7 +14,7 @@ export interface SearchResult { const CACHE_PREFIX = 'search:'; const CACHE_TTL = 60; // 60 seconds -export async function search(query: string, limit = 5): Promise { +export async function search(query: string, limit = 5, isAuthenticated = false): Promise { const q = query.trim().toLowerCase(); if (!q || q.length < 2) return []; @@ -34,7 +34,7 @@ export async function search(query: string, limit = 5): Promise if (settings.enableInfluence !== false) { promises.push(searchCampaigns(q, limit)); } - if (settings.enableMap !== false) { + if (settings.enableMap !== false && isAuthenticated) { promises.push(searchShifts(q, limit)); } if (settings.enableLandingPages !== false) { diff --git a/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts b/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts index 04c13e94..0d6328ad 100644 --- a/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts +++ b/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts @@ -260,7 +260,9 @@ router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: R const crypto = await import('crypto'); const { env: envConfig } = await import('../../config/env'); const nonce = crypto.randomBytes(16); - const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY); + // Use domain-separated key for ticket HMACs (matches tickets.service.ts) + const ticketKey = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY).update('ticket-hmac-v1').digest(); + const hmac = crypto.createHmac('sha256', ticketKey); hmac.update(ticket.id); hmac.update(nonce); const token = Buffer.concat([ diff --git a/api/src/modules/ticketed-events/tickets.service.ts b/api/src/modules/ticketed-events/tickets.service.ts index 41f14ad3..968ec640 100644 --- a/api/src/modules/ticketed-events/tickets.service.ts +++ b/api/src/modules/ticketed-events/tickets.service.ts @@ -4,8 +4,9 @@ import { env } from '../../config/env'; import { AppError } from '../../middleware/error-handler'; import { logger } from '../../utils/logger'; -function getEncryptionKey(): string { - return env.ENCRYPTION_KEY; +/** Derive a domain-separated key for ticket HMACs (not the raw ENCRYPTION_KEY). */ +function getTicketHmacKey(): Buffer { + return crypto.createHmac('sha256', env.ENCRYPTION_KEY).update('ticket-hmac-v1').digest(); } /** Generate a human-readable ticket code like "ABCD-1234" */ @@ -19,7 +20,7 @@ function generateTicketCode(): string { /** Generate HMAC token for QR code validation */ function generateToken(ticketId: string): { token: string; tokenHash: string } { const nonce = crypto.randomBytes(16); - const hmac = crypto.createHmac('sha256', getEncryptionKey()); + const hmac = crypto.createHmac('sha256', getTicketHmacKey()); hmac.update(ticketId); hmac.update(nonce); const token = Buffer.concat([ diff --git a/api/src/services/password-reset-token.service.ts b/api/src/services/password-reset-token.service.ts index 3f767033..2bbdb1af 100644 --- a/api/src/services/password-reset-token.service.ts +++ b/api/src/services/password-reset-token.service.ts @@ -2,24 +2,31 @@ import crypto from 'crypto'; import { prisma } from '../config/database'; import { logger } from '../utils/logger'; +/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */ +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + export const passwordResetTokenService = { async createToken(userId: string): Promise { // Delete any existing tokens for this user await prisma.passwordResetToken.deleteMany({ where: { userId } }); - const token = crypto.randomBytes(32).toString('hex'); + const rawToken = crypto.randomBytes(32).toString('hex'); + const tokenHash = hashToken(rawToken); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour await prisma.passwordResetToken.create({ - data: { userId, token, expiresAt }, + data: { userId, token: tokenHash, expiresAt }, }); logger.info(`Password reset token created for user ${userId}`); - return token; + return rawToken; // Send raw token in email; DB stores only the hash }, async validateToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> { - const record = await prisma.passwordResetToken.findUnique({ where: { token } }); + const tokenHash = hashToken(token); + const record = await prisma.passwordResetToken.findUnique({ where: { token: tokenHash } }); // Use a generic error message for all failure cases to prevent token state enumeration const genericError = 'Invalid or expired reset token'; @@ -41,8 +48,9 @@ export const passwordResetTokenService = { }, async markTokenUsed(token: string): Promise { + const tokenHash = hashToken(token); await prisma.passwordResetToken.update({ - where: { token }, + where: { token: tokenHash }, data: { usedAt: new Date() }, }); }, diff --git a/api/src/services/verification-token.service.ts b/api/src/services/verification-token.service.ts index 5b03b15e..31c1da1b 100644 --- a/api/src/services/verification-token.service.ts +++ b/api/src/services/verification-token.service.ts @@ -2,24 +2,31 @@ import crypto from 'crypto'; import { prisma } from '../config/database'; import { logger } from '../utils/logger'; +/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */ +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + export const verificationTokenService = { async createToken(userId: string): Promise { // Delete any existing tokens for this user await prisma.emailVerificationToken.deleteMany({ where: { userId } }); - const token = crypto.randomBytes(32).toString('hex'); + const rawToken = crypto.randomBytes(32).toString('hex'); + const tokenHash = hashToken(rawToken); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours await prisma.emailVerificationToken.create({ - data: { userId, token, expiresAt }, + data: { userId, token: tokenHash, expiresAt }, }); logger.info(`Verification token created for user ${userId}`); - return token; + return rawToken; // Send raw token in email; DB stores only the hash }, async verifyToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> { - const record = await prisma.emailVerificationToken.findUnique({ where: { token } }); + const tokenHash = hashToken(token); + const record = await prisma.emailVerificationToken.findUnique({ where: { token: tokenHash } }); if (!record) { return { valid: false, error: 'Invalid or expired verification token' }; diff --git a/api/src/services/video-fetch-queue.service.ts b/api/src/services/video-fetch-queue.service.ts index c27dd775..2753a0c5 100644 --- a/api/src/services/video-fetch-queue.service.ts +++ b/api/src/services/video-fetch-queue.service.ts @@ -34,9 +34,57 @@ interface FetchJobResult { // Shell metacharacters that could enable command injection const SHELL_METACHAR_REGEX = /[`$;|&<>(){}[\]\\!#]/; +// Hostnames that resolve to internal/cloud-metadata addresses +const BLOCKED_HOSTNAMES = new Set([ + 'localhost', + 'metadata.google.internal', + 'metadata.gcp.internal', + '169.254.169.254', // AWS/GCP/Azure metadata + '169.254.170.2', // AWS ECS task metadata + 'kubernetes.default', + 'kubernetes.default.svc', +]); + +// Docker internal container names used in this project +const DOCKER_INTERNAL_SUFFIXES = [ + '-changemaker', // Our container naming convention + '-rocketchat', + '-listmonk', +]; + +/** + * Check if an IP address is in a private/reserved range (SSRF protection). + */ +function isPrivateIP(hostname: string): boolean { + // IPv6 loopback + if (hostname === '::1' || hostname === '[::1]') return true; + + // Strip IPv6 brackets + const clean = hostname.replace(/^\[|\]$/g, ''); + + // IPv4 patterns + const parts = clean.split('.').map(Number); + if (parts.length === 4 && parts.every(p => !isNaN(p) && p >= 0 && p <= 255)) { + const [a, b] = parts; + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / cloud metadata + if (a === 0) return true; // 0.0.0.0/8 + } + + // IPv6 private ranges (simplified check for common prefixes) + if (clean.startsWith('fc') || clean.startsWith('fd')) return true; // ULA + if (clean.startsWith('fe80')) return true; // Link-local + + return false; +} + /** * Sanitize and validate a URL for safe shell usage. - * Returns the URL if valid, or null if invalid or contains shell metacharacters. + * Returns the URL if valid, or null if invalid, contains shell metacharacters, + * or targets private/internal network addresses (SSRF protection). */ function sanitizeUrl(url: string): string | null { if (!url || typeof url !== 'string') return null; @@ -45,8 +93,9 @@ function sanitizeUrl(url: string): string | null { if (!trimmed) return null; // Validate URL structure + let parsed: URL; try { - const parsed = new URL(trimmed); + parsed = new URL(trimmed); if (!['http:', 'https:'].includes(parsed.protocol)) return null; } catch { return null; @@ -55,6 +104,16 @@ function sanitizeUrl(url: string): string | null { // Reject shell metacharacters if (SHELL_METACHAR_REGEX.test(trimmed)) return null; + // SSRF protection: block private/internal IPs and hostnames + const hostname = parsed.hostname.toLowerCase(); + + if (isPrivateIP(hostname)) return null; + if (BLOCKED_HOSTNAMES.has(hostname)) return null; + if (DOCKER_INTERNAL_SUFFIXES.some(suffix => hostname.endsWith(suffix))) return null; + + // Block numeric IPv6 addresses entirely (too many bypass variants) + if (hostname.includes(':')) return null; + return trimmed; } diff --git a/api/src/utils/crypto.ts b/api/src/utils/crypto.ts index d2d44a9a..c10d1c43 100644 --- a/api/src/utils/crypto.ts +++ b/api/src/utils/crypto.ts @@ -1,20 +1,36 @@ -import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto'; +import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; -const PREFIX = 'enc:'; -/** Derive a 32-byte key from an arbitrary-length secret */ -function deriveKey(secret: string): Buffer { +/** Legacy prefix (SHA-256 derived key) — read-only, never written for new data */ +const LEGACY_PREFIX = 'enc:'; +/** V2 prefix (HKDF derived key) — used for all new encryptions */ +const V2_PREFIX = 'enc2:'; + +const HKDF_SALT = 'changemaker-lite-encryption-v2'; +const HKDF_INFO = 'aes-256-gcm-data-encryption'; + +/** Derive a 32-byte key using SHA-256 (legacy, for decrypting old data) */ +function deriveLegacyKey(secret: string): Buffer { return createHash('sha256').update(secret).digest(); } +/** Derive a 32-byte key using HKDF-SHA256 (RFC 5869) */ +function deriveKey(secret: string): Buffer { + return Buffer.from( + hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32) + ); +} + let _key: Buffer | null = null; +let _legacyKey: Buffer | null = null; /** Initialize (or re-initialize) the encryption key. Call once at startup. */ export function initEncryption(secret: string): void { _key = deriveKey(secret); + _legacyKey = deriveLegacyKey(secret); } function getKey(): Buffer { @@ -24,9 +40,16 @@ function getKey(): Buffer { return _key; } +function getLegacyKey(): Buffer { + if (!_legacyKey) { + throw new Error('Encryption not initialized — call initEncryption() first'); + } + return _legacyKey; +} + /** * Encrypt a plaintext string. - * Returns format: `enc:::` (all base64). + * Returns format: `enc2:::` (all base64). */ export function encrypt(plaintext: string): string { if (!plaintext) return plaintext; @@ -37,18 +60,31 @@ export function encrypt(plaintext: string): string { const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); - return `${PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`; + return `${V2_PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`; } /** * Decrypt a value produced by encrypt(). - * If the value doesn't have the `enc:` prefix, returns it as-is (backward compat for plaintext). + * Supports both v2 (HKDF) and legacy (SHA-256) key derivation. + * If the value doesn't have a recognized prefix, returns it as-is (backward compat for plaintext). */ export function decrypt(value: string): string { - if (!value || !value.startsWith(PREFIX)) return value; + if (!value) return value; - const key = getKey(); - const parts = value.slice(PREFIX.length).split(':'); + let key: Buffer; + let payload: string; + + if (value.startsWith(V2_PREFIX)) { + key = getKey(); + payload = value.slice(V2_PREFIX.length); + } else if (value.startsWith(LEGACY_PREFIX)) { + key = getLegacyKey(); + payload = value.slice(LEGACY_PREFIX.length); + } else { + return value; // Plaintext fallback + } + + const parts = payload.split(':'); if (parts.length !== 3) return value; const iv = Buffer.from(parts[0], 'base64'); @@ -64,5 +100,5 @@ export function decrypt(value: string): string { /** Check whether a value is already encrypted */ export function isEncrypted(value: string): boolean { - return !!value && value.startsWith(PREFIX); + return !!value && (value.startsWith(V2_PREFIX) || value.startsWith(LEGACY_PREFIX)); } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bfbae89f..01687b09 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -50,7 +50,7 @@ services: - SMTP_USER=${SMTP_USER:-} - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-noreply@cmlite.org} - - EMAIL_TEST_MODE=${EMAIL_TEST_MODE:-true} + - EMAIL_TEST_MODE=${EMAIL_TEST_MODE:-false} - LISTMONK_URL=http://listmonk-app:9000 - LISTMONK_ADMIN_USER=${LISTMONK_ADMIN_USER:-admin} - LISTMONK_ADMIN_PASSWORD=${LISTMONK_ADMIN_PASSWORD:-} @@ -405,7 +405,7 @@ services: environment: LISTMONK_app__address: 0.0.0.0:9000 LISTMONK_db__user: ${LISTMONK_DB_USER:-listmonk} - LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:-listmonk} + LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env} LISTMONK_db__database: ${LISTMONK_DB_NAME:-listmonk} LISTMONK_db__host: listmonk-db LISTMONK_db__port: 5432 @@ -427,7 +427,7 @@ services: - "127.0.0.1:${LISTMONK_DB_PORT:-5434}:5432" environment: POSTGRES_USER: ${LISTMONK_DB_USER:-listmonk} - POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk} + POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env} POSTGRES_DB: ${LISTMONK_DB_NAME:-listmonk} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${LISTMONK_DB_USER:-listmonk}"] @@ -450,7 +450,7 @@ services: condition: service_started restart: "no" environment: - PGPASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk} + PGPASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env} LISTMONK_API_USER: ${LISTMONK_API_USER:-v2-api} LISTMONK_API_TOKEN: ${LISTMONK_API_TOKEN:-} LISTMONK_SMTP_HOST: ${LISTMONK_SMTP_HOST:-mailhog-changemaker} @@ -1222,7 +1222,13 @@ services: - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro - privileged: true + cap_drop: + - ALL + cap_add: + - SYS_PTRACE + - DAC_READ_SEARCH + security_opt: + - no-new-privileges:true read_only: true devices: - /dev/kmsg diff --git a/docker-compose.yml b/docker-compose.yml index 1df87971..f5e8e5be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -429,7 +429,7 @@ services: environment: LISTMONK_app__address: 0.0.0.0:9000 LISTMONK_db__user: ${LISTMONK_DB_USER:-listmonk} - LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:-listmonk} + LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env} LISTMONK_db__database: ${LISTMONK_DB_NAME:-listmonk} LISTMONK_db__host: listmonk-db LISTMONK_db__port: 5432 @@ -451,7 +451,7 @@ services: - "127.0.0.1:${LISTMONK_DB_PORT:-5434}:5432" environment: POSTGRES_USER: ${LISTMONK_DB_USER:-listmonk} - POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk} + POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env} POSTGRES_DB: ${LISTMONK_DB_NAME:-listmonk} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${LISTMONK_DB_USER:-listmonk}"] @@ -474,7 +474,7 @@ services: condition: service_started restart: "no" environment: - PGPASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk} + PGPASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env} LISTMONK_API_USER: ${LISTMONK_API_USER:-v2-api} LISTMONK_API_TOKEN: ${LISTMONK_API_TOKEN:-} LISTMONK_SMTP_HOST: ${LISTMONK_SMTP_HOST:-mailhog-changemaker} @@ -874,7 +874,7 @@ services: environment: - ROOT_URL=http://chat.${DOMAIN:-cmlite.org} - MONGO_URL=mongodb://${MONGO_ROOT_USER:-rocketchat}:${MONGO_ROOT_PASSWORD}@mongodb-rocketchat:27017/rocketchat?replicaSet=rs0&authSource=admin - - MONGO_OPLOG_URL=mongodb://mongodb-rocketchat:27017/local?replicaSet=rs0 + - MONGO_OPLOG_URL=mongodb://${MONGO_ROOT_USER:-rocketchat}:${MONGO_ROOT_PASSWORD}@mongodb-rocketchat:27017/local?replicaSet=rs0&authSource=admin - TRANSPORTER=monolith+nats://nats-rocketchat:4222 - PORT=3000 - ADMIN_USERNAME=${ROCKETCHAT_ADMIN_USER:-rcadmin} @@ -1247,7 +1247,13 @@ services: - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro - privileged: true + cap_drop: + - ALL + cap_add: + - SYS_PTRACE + - DAC_READ_SEARCH + security_opt: + - no-new-privileges:true read_only: true devices: - /dev/kmsg diff --git a/nginx/conf.d/api.conf.template b/nginx/conf.d/api.conf.template index c2e5802f..4615f395 100644 --- a/nginx/conf.d/api.conf.template +++ b/nginx/conf.d/api.conf.template @@ -26,6 +26,19 @@ server { proxy_set_header Connection "upgrade"; } + # Auth endpoints — stricter rate limit to prevent credential stuffing + location /api/auth/ { + limit_req zone=api_auth burst=10 nodelay; + set $upstream_api http://changemaker-v2-api:4000; + proxy_pass $upstream_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + # Main API (Express) location / { limit_req zone=api_global burst=60 nodelay; diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh index 70ba0c2d..41ae57c0 100755 --- a/scripts/build-and-push.sh +++ b/scripts/build-and-push.sh @@ -44,7 +44,7 @@ info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } -run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else eval "$@"; fi; } +run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else "$@"; fi; } # --- Arg parser --- while [[ $# -gt 0 ]]; do diff --git a/scripts/mirror-images.sh b/scripts/mirror-images.sh index 30d52557..d1f1dff4 100755 --- a/scripts/mirror-images.sh +++ b/scripts/mirror-images.sh @@ -42,7 +42,7 @@ info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } -run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else eval "$@"; fi; } +run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else "$@"; fi; } # --- Arg parser --- while [[ $# -gt 0 ]]; do