Commit Graph

14 Commits

Author SHA1 Message Date
davidherran 673c32d0e1 feat(nginx): canonical-host guard + scanner-probe blocking
Deploy to VPS / deploy (push) Has been cancelled
Hardens the edge against the bot noise and IP-based access seen in the
production logs (raw-IP hits, SSRF probes to 169.254.169.254 / localhost /
metadata.google.internal, scans for /config/database.php, /.git-credentials,
wp-admin, etc.).

1. Canonical-host guard — default_server blocks on 80 and 443 that catch
   any Host that is NOT rf-flux.com/www.rf-flux.com and return 444 (drop).
   - Kills the redirect-to-raw-IP bug at the edge: IP requests never reach
     Next.js, so the middleware can't build an IP-based redirect.
   - Blocks SSRF probes and most bot scans before they touch the app.
   - ACME HTTP-01 still works (acme-challenge location kept on :80).
   - Legitimate traffic is unaffected: exact server_name beats
     default_server, so the rf-flux.com blocks always win.

2. Scanner-probe blocking — a regex location in the rf-flux.com server
   that returns 444 for .php/.env/.git/wp-admin/etc. This is a Next.js app
   so none of those are real; the patterns never match real assets
   (.jpg/.png/.webp/.mp4/.glb/.pdf) or app routes.

Apply with `nginx -t` then `nginx -s reload` — no rebuild, no downtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:29:39 -05:00
davidherran 148aefc68f feat(team): public Team page + HQ CMS panel
New "Team" section — a LinkedIn-style minimal profile page for the FLUX
team, fully editable from the HQ Command Center.

Data model:
- New TeamMember model (name, role, bio, photoUrl, optional social links:
  email/linkedin/x/website, order, isActive, translationsJson).
- Additive migration 20260602120000_add_team_member (IF NOT EXISTS guards).
- Name stays as written; role + bio are translatable via the AI engine.

HQ panel (/hq-command/dashboard/team):
- Drag-to-reorder (same HTML5 pattern as the Hero panel).
- Inline auto-save for name/role/visibility; expandable editor for photo
  upload, bio, social links, and AI auto-translate to IT/VEC/ES/DE.
- Photo upload reuses /api/assets with a new flat "team" scope -> /public/team/.
- Dashboard tile added.

Public page (/[locale]/team):
- Responsive card grid (framer-motion stagger), portrait + name + role +
  bio + social icons (only the links that exist render).
- Per-member Person JSON-LD + breadcrumb for SEO.
- Localized via getLocalizedData; new TeamPage namespace in all 5 locales.
- NavBar item "Team" inserted before "Spare Parts" (translated 5 locales).
- Added to sitemap.

Infra:
- "team" scope registered in /api/assets (SCOPE_ROOTS + FLAT_SCOPES +
  buildPublicUrl) and revalidate.ts (RevalidateScope + path).
- Nginx serves /team/ from disk; docker-compose mounts public/team in both
  app and nginx (rw + ro).

Verified: production build compiles, all 5 /[locale]/team routes + the HQ
panel render; TypeScript clean; 5 message files valid JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:17:09 -05:00
davidherran 1ee8288c7e feat(analytics): GA4 with GDPR Consent Mode v2
Google Analytics integration, off by default and GDPR-compliant for EU:

- src/lib/analytics/gtag.ts: typed event helpers + consent control. Every
  function is a safe no-op when NEXT_PUBLIC_GA_ID is unset.
- GoogleAnalytics.tsx: loads gtag.js with Consent Mode v2, all storage
  defaulting to "denied". anonymize_ip on, send_page_view off.
- ConsentBanner.tsx: on-brand cookie banner, localized to all 5 locales,
  persists choice for one year, flips analytics_storage to granted on accept.
- PageViewTracker.tsx: fires page_view on App Router client navigation
  (inside Suspense for useSearchParams).
- Key conversion events wired: ai_consultation_submitted (primary funnel
  goal) and ai_chat_opened.
- Consent strings added to messages/{en,it,vec,es,de}.json.

Build plumbing:
- NEXT_PUBLIC_GA_ID inlined at build time via Dockerfile ARG +
  docker-compose build.args (NEXT_PUBLIC_* must exist during next build,
  not just runtime).
- Nginx CSP extended to allow googletagmanager.com + google-analytics.com.
- env template documents NEXT_PUBLIC_GA_ID (empty = analytics disabled).

Verified: production build inlines the Measurement ID into the client
bundle; site builds cleanly both with and without the ID set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 06:53:04 -05:00
davidherran 3a94e7c003 feat(security+ai): security hardening + FluxAI conversation analytics
Security (critical):
- SESSION_SECRET fail-fast: refuse to boot without a 32+ char secret
  (src/lib/session.ts, src/app/actions/clientAuth.ts)
- Rate limit with pluggable backend: in-memory by default, auto-promotes
  to Upstash Redis when REDIS_URL is set (src/lib/rateLimit.ts)
- CSRF (double-submit HMAC) + Zod validation on /api/consultation;
  new /api/csrf endpoint mints tokens (src/lib/csrf.ts)
- escapeHtml + safeMailto helpers; consultation email template now
  fully escapes user-controlled fields (src/lib/escapeHtml.ts)
- Magic-byte validation for /api/public-upload — rejects HTML/JS
  payloads renamed to .png/.mp4 (src/lib/fileType.ts)
- Nginx: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy + 5r/m upload zone for /api/public-upload and
  /api/assets (nginx/conf.d/flux.conf)

Quality:
- Delete GlobalOperations_old.tsx dead code (310 LOC)
- NavBar: replace 2s session polling with CustomEvent("flux:session-
  changed") + visibilitychange listener (no more interval leaks)
- Type-safe CMS shapes via src/types/cms.ts (replaces any[] in
  ApplicationsDashboard + GlobalOperations)
- /api/health now pings Postgres; docker-compose healthcheck added
- Structured JSON logger (src/lib/logger.ts) — drop-in replacement
  for console.error across API routes
- Prisma indices on isActive/category/nodeType filters

FluxAI persistence + analytics:
- New models AiConversation + AiEvent with funnel stage detection
  (DISCOVERY -> QUALIFY -> RECOMMEND -> HANDOFF) and OperationsSignal
  back-ref so converted chats link to their consultation ticket
- /api/chat persists every user msg, ai msg, tool call, tool result;
  IP is sha256-hashed with SESSION_SECRET salt; promptCacheKey wired
  for when @ai-sdk/openai lands the feature
- New HQ dashboard at /hq-command/dashboard/conversations: 4 KPIs
  (total, conversion rate, avg messages, avg tools), funnel + industry
  breakdowns, last-50 table, per-id transcript with tool timeline
- SilentObserver sends sessionId/locale/pageUrl in transport body so
  the route can stitch messages into the same conversation
- src/lib/aiSessionId.ts: localStorage UUID with sessionStorage +
  in-memory fallbacks for privacy mode
- Golden tests via node --test (13 cases, no new deps);
  npm run test:ai

Migration:
- prisma/migrations/20260526180000_add_indexes_and_ai_telemetry —
  additive only, IF NOT EXISTS guards, safe for migrate deploy

env template hardened: SESSION_SECRET documented as required + how
to generate; REDIS_URL/REDIS_TOKEN documented as opt-in for multi-
instance deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:10:19 -05:00
davidherran aebcabd767 fix: nginx cache ignores Set-Cookie + adds explicit valid 60s
Deploy to VPS / deploy (push) Has been cancelled
The HTTP cache was always reporting MISS on /en even on consecutive
hits. Two reasons converging:

1) next-intl writes a NEXT_LOCALE cookie on response, so every
   upstream reply included a Set-Cookie header. Nginx refuses to
   cache responses with Set-Cookie by default — that's a safe
   default to avoid leaking session cookies, but it's the wrong
   default for our public marketing pages, where the cookie just
   records a locale preference and the HTML body is identical for
   every visitor on the same URL.

2) proxy_cache_valid wasn't set, so even when Cache-Control would
   have authorised caching, Nginx fell back to its conservative
   no-cache stance.

Fix:
- proxy_ignore_headers Set-Cookie X-Accel-Expires Expires;
- proxy_hide_header Set-Cookie;
- proxy_cache_valid 200 60s;

Net result: marketing pages now actually cache. The Set-Cookie is
still emitted by Next.js (the upstream is unchanged), Nginx just
strips it before storing/relaying — locale detection still works
because next-intl persists locale through the URL prefix anyway.

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose restart nginx
  docker compose exec nginx nginx -s reload

Then verify:
  curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'
  curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'

Second hit should show: x-cache-status: HIT
2026-05-05 13:48:43 -05:00
davidherran 1a4abfc7f2 nginx: include lethepowerflux.com → rf-flux.com 301 redirect
Deploy to VPS / deploy (push) Has been cancelled
The VPS already had a server block redirecting lethepowerflux.com and
www.lethepowerflux.com to https://www.rf-flux.com, but it lived only on
the live config — not in git. That's why the latest pull complained
about local changes that would be overwritten.

Adding it here so the repo is the single source of truth for the Nginx
config again. Behaviour is unchanged on the VPS (redirect was already
in place) — this commit just lets future git pulls flow without
manual intervention.
2026-05-05 13:17:34 -05:00
davidherran 7fe5108f66 feat: HTTP shared cache for public marketing pages
Deploy to VPS / deploy (push) Has been cancelled
Pages got fast again. Public marketing routes are still rendered
per-request by Next.js (force-dynamic, until the ISR bug gets isolated),
but their HTML is now cached at the Nginx layer for 60s with a 5-minute
stale-while-revalidate window. Result: only the first hit on a URL
inside a 60s window pays the SSR cost; every other visitor in that
window gets a sub-10ms cached response. While a cached entry is
revalidating, peers keep getting the stale copy — no cold starts, no
thundering herds.

NEXT.JS MIDDLEWARE (src/proxy.ts)
- isCacheablePublicPath() identifies routes safe to share-cache:
  /, /<locale>, /<locale>/applications, /<locale>/news,
  /<locale>/heritage. Excludes /<locale>/parts (auth-gated B2B portal)
  and /hq-command/*, /api/*, /_next/*.
- hasAuthCookie() short-circuits caching when the request carries a
  flux_session (admin CMS) or flux_b2b_session (client portal) cookie.
  Authenticated users always get a fresh per-account render.
- When both checks pass, the response gets:
    Cache-Control: public, s-maxage=60, stale-while-revalidate=300

NGINX (nginx/nginx.conf)
- New shared zone:
    proxy_cache_path /var/cache/nginx/flux levels=1:2
                     keys_zone=flux_html:50m max_size=1g inactive=24h
                     use_temp_path=off;
- Access log gets a `cache=$upstream_cache_status` field so we can
  audit hit/miss ratios in the live logs.

NGINX (nginx/conf.d/flux.conf — location /)
- proxy_cache flux_html + proxy_cache_revalidate on
- proxy_cache_use_stale: serves stale on backend errors / timeout /
  during update, so 502s during a Next.js restart never reach users.
- proxy_cache_background_update + proxy_cache_lock: only one upstream
  request fires when a cached entry expires; others keep getting stale.
- proxy_cache_bypass / proxy_no_cache wired to flux_session +
  flux_b2b_session cookies — admin and B2B traffic skips the shared
  cache entirely.
- X-Cache-Status response header (HIT/MISS/EXPIRED/STALE/UPDATING/BYPASS)
  for live debugging — open dev tools, refresh, watch the value flip.

WHAT YOU'LL FEEL
- First visitor on /en within a 60s window: ~150-300ms (SSR + DB).
- Second through Nth visitors in the same window: <10ms.
- Editor publishes a change in HQ Command → revalidatePath() inside
  the existing actions invalidates the Next.js cache; the next
  marketing-page request rebuilds and primes Nginx fresh. The 60s
  TTL bounds how long stale content can linger if revalidation is
  ever skipped.

NO BREAKING CHANGES
- Auth flows untouched (cookies bypass cache).
- HQ Command + API endpoints untouched (separate Nginx locations).
- Static assets (cases/, applications/, /branding/, /_next/static)
  unaffected — they had their own cache headers already.
- Server-side cache invalidation via revalidatePath() still works.

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose up -d --build app
  docker compose exec nginx nginx -t
  docker compose exec nginx nginx -s reload
2026-05-05 12:20:39 -05:00
davidherran 62506f10b4 fix: strip internal container port from redirect URLs
Deploy to VPS / deploy (push) Has been cancelled
The site was redirecting / -> https://rf-flux.com:3000/en, where :3000
is the container's internal port (only "expose"d, not published) — so
the browser saw ERR_CONNECTION_REFUSED.

Root cause: when running behind Nginx in standalone mode, Next.js (via
next-intl in this case) can build absolute redirect URLs that leak the
container's internal PORT/HOSTNAME env into the Location header.

TWO LAYERS OF DEFENCE
1. Nginx (nginx/conf.d/flux.conf)
   - Adds X-Forwarded-Host + X-Forwarded-Port so the upstream knows
     the public port (443) and host
   - proxy_redirect rewrites any Location header that still slips
     through with :3000 back to the public https://$host

2. Middleware (src/proxy.ts)
   - sanitizeRedirectLocation() runs after handleI18nRouting and
     scrubs Location headers that point at internal hostnames (app /
     localhost / 0.0.0.0) or the container port :3000, replacing them
     with the public host derived from x-forwarded-host / host header.

Either layer alone would fix the immediate symptom; together they
also prevent the same class of bug from showing up in any future
redirect path.
2026-05-04 16:32:45 -05:00
davidherran f8606a45ff feat: branding asset serving + footer email/phone fields
Deploy to VPS / deploy (push) Has been cancelled
Two changes that together make Site Settings actually work end-to-end.

BRANDING ASSET SERVING (the broken thumbnails fix)
The favicon/logo previews were broken because uploaded files in
/public/branding had no path to reach the browser:
  1. The folder wasn't mounted into the app container, so uploads
     vanished on next deploy
  2. Nginx had no location block, so /branding/foo.png returned 404
     (everything not in cases/applications/news/parts/footage was a
     proxy_pass to Next.js, which doesn't serve from /public/branding
     in standalone mode)

Fix:
- docker-compose.yml: ./public/branding mounted to /app/public/branding
  (write side) AND /srv/branding (read-only side for Nginx)
- nginx/conf.d/flux.conf: new "location /branding/" block, same
  cache strategy as the other asset locations (max-age=300, must-revalidate)

FOOTER EMAIL + PHONE (David's request)
- siteSettingsTypes.ts: hqEmail and hqPhone fields added to FooterSettings,
  pre-filled with sales@lethepowerflux.com and +39 0424 287 492
- Footer.tsx: clickable mailto: and tel: links with Mail / Phone icons
  shown right under the HQ address. Hidden when fields are empty so the
  layout stays clean for editors who want to suppress contact info.
- /hq-command/dashboard/settings: new "Headquarters contact" group in
  the Footer tab with the two fields (auto-translate ignores them, since
  emails and phone numbers don't need translation).

DEPLOY (David)
  cd /opt/flux-srl
  mkdir -p public/branding   # one-time, creates the folder if missing
  git pull
  docker compose up -d --build app
  docker compose exec nginx nginx -t
  docker compose exec nginx nginx -s reload
2026-05-04 15:24:06 -05:00
davidherran 6e46808c27 fix: instant CMS uploads + heritage dark/light + ISR caching
Eliminates the need to run "docker compose build" after uploading
images via HQ Command. Heritage page now respects light/dark mode.

CACHE INVALIDATION
- New helper src/lib/revalidate.ts called from /api/assets and
  /api/public-upload after every upload, delete, folder create
- Pages switch from force-dynamic to ISR with revalidate=60
  (regenerated on demand whenever content changes, plus 60s safety)
- Nginx now sends "max-age=300, must-revalidate" instead of "expires 30d"
  on /cases/, /applications/, /news/, /parts/, /footage/, /operations-inbox/
  so browsers revalidate via If-Modified-Since (304s on unchanged files)
- Next.js Image Optimizer aligned with same TTL via minimumCacheTTL=300
  and adds /_next/image location block in Nginx for correct headers

HERITAGE DARK/LIGHT FIX (Bug #8)
- Replaces hardcoded #0A0A0C / #00F0FF / text-white with proper
  light + dark variants throughout markdown renderer (tables, lists,
  headings, blockquotes, paragraphs, images)
- Hero section, navigation pill, and CMS-driven sections now switch
  with the global theme toggle

SECURITY HARDENING
- Server actions bodySizeLimit reduced from 500MB to 50MB
  (large uploads still go through /api/assets which uses Nginx 500MB cap)

DEPLOY NOTES
- Run on VPS:
    git pull
    docker compose up -d --build app
    docker compose exec nginx nginx -s reload
- No DB schema changes in this commit. Existing 2FA users / data untouched.
2026-05-04 09:27:46 -05:00
davidherran c5b78c539e fix: nginx serves uploaded assets directly, docker volumes for all public dirs
Deploy to VPS / deploy (push) Has been cancelled
2026-04-08 17:07:49 +00:00
davidherran 3c20ce5c37 fix: upload limit 500MB, mount all public dirs in docker
Deploy to VPS / deploy (push) Has been cancelled
2026-04-08 15:56:56 +00:00
davidherran 0118774e31 production: docker fixes, nginx SSL config, generateStaticParams fallback
Deploy to VPS / deploy (push) Has been cancelled
2026-04-01 16:57:22 +00:00
davidherran fc24313f15 production: docker + nginx config for rf-flux.com
Deploy to VPS / deploy (push) Has been cancelled
2026-03-20 13:46:05 -05:00