Compare commits
8 Commits
e0399ccf3b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a81ee50ed8 | |||
| 18d5ed87c8 | |||
| b76c14b780 | |||
| 63a896b017 | |||
| 673c32d0e1 | |||
| 8a98f88047 | |||
| 7c689e034e | |||
| 3e0b286f1a |
@@ -50,7 +50,10 @@ public/news/
|
|||||||
public/parts/
|
public/parts/
|
||||||
public/operations-inbox/
|
public/operations-inbox/
|
||||||
public/footage/
|
public/footage/
|
||||||
|
public/team/
|
||||||
|
public/branding/
|
||||||
|
|
||||||
# Local Claude Code / MCP config — agent-specific, not project
|
# Local Claude Code / MCP config — agent-specific, not project
|
||||||
.mcp.json
|
.mcp.json
|
||||||
.claude/
|
.claude/
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ services:
|
|||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- flux-net
|
- flux-net
|
||||||
|
# Resource caps so no single container can starve the others (the Nginx
|
||||||
|
# outage earlier was a reminder). VPS has ~11 GB; these leave headroom.
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2g
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -81,6 +87,10 @@ services:
|
|||||||
- flux-net
|
- flux-net
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1500m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
@@ -114,6 +124,46 @@ services:
|
|||||||
- app
|
- app
|
||||||
networks:
|
networks:
|
||||||
- flux-net
|
- flux-net
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256m
|
||||||
|
healthcheck:
|
||||||
|
# Nginx self-health (served directly by the default_server, no upstream).
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# ── Automated Postgres backups ──
|
||||||
|
# Nightly pg_dump -> gzip into ./backups on the host, 14-day rotation.
|
||||||
|
# NOTE: this is LOCAL to the VPS. Offsite copy (S3/rsync) is the recommended
|
||||||
|
# next step once the client provides storage credentials.
|
||||||
|
backup:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
BACKUP_DIR: /backups
|
||||||
|
RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
|
||||||
|
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
||||||
|
volumes:
|
||||||
|
- ./backups:/backups
|
||||||
|
- ./scripts/db-backup.sh:/usr/local/bin/db-backup.sh:ro
|
||||||
|
- ./scripts/backup-loop.sh:/usr/local/bin/backup-loop.sh:ro
|
||||||
|
entrypoint: ["/bin/sh", "/usr/local/bin/backup-loop.sh"]
|
||||||
|
networks:
|
||||||
|
- flux-net
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# FLUX SRL — Audit Verification & Corrected Priorities
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Method:** 10-dimension multi-agent audit (59 agents, adversarial verification) + manual re-verification of every critical/high finding against the running production system.
|
||||||
|
|
||||||
|
> **Why this file exists:** the automated audit (full report below) is thorough but produced one **false-positive CRITICAL** and overstated a few others. This top section is the corrected, ground-truth verdict. Trust this section over the raw report where they disagree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual verification results (the corrections)
|
||||||
|
|
||||||
|
### ❌ SEC-02 "Auth middleware never runs" — FALSE POSITIVE
|
||||||
|
The audit's #1 critical claimed `src/proxy.ts` is never executed because it exports `proxy` not `middleware`.
|
||||||
|
**Verified in production:** `curl https://www.rf-flux.com/hq-command/dashboard` (no cookie) → **307 redirect to `/hq-command/login`**. The middleware **is running**. Next.js 16 *does* recognize `src/proxy.ts` exporting `proxy` (the rename is real in Next 16; the code comment was correct). The empty `middleware-manifest.json` in a local build is misleading — runtime behavior disproves the claim.
|
||||||
|
**Consequence:** the HQ admin surface **is** protected by the middleware. SEC-03 (below) drops from critical to low.
|
||||||
|
|
||||||
|
### ⚠️ SEC-03 "HQ server actions lack auth" — OVERSTATED (low, not critical)
|
||||||
|
Because the middleware *does* run and its matcher covers `/hq-command/*`, POSTs to those routes (which is how server actions are invoked) are gated — no cookie → redirect before the action executes. Adding an in-action `getSession()` check is still good defense-in-depth, but it is **not** an open door.
|
||||||
|
|
||||||
|
### ✅ SEC-04 "/api/assets + /api/branding/favicon unauthenticated" — REAL (HIGH) — the actual top security issue
|
||||||
|
Verified: neither route checks a session, and the middleware matcher **excludes `/api`**, so the middleware does not protect them. Anyone can `GET/POST/PUT/DELETE` `/api/assets` — list CMS structure, upload files into public scopes, rename/delete content — and POST `/api/branding/favicon`. **This is the real #1.**
|
||||||
|
|
||||||
|
### ✅ DB-01 "ClientUser table never migrated" — REAL (HIGH), impact nuanced
|
||||||
|
Verified: the init migration creates 10 tables; **`ClientUser` is not among them**, and no later migration adds it. Code calls `prisma.clientUser` in `clientAuth.ts` (B2B register/login) and the dashboard.
|
||||||
|
**Real impact:** the **B2B client portal (register/login) is broken** at runtime. The HQ dashboard does *not* crash (its counts run inside a try/catch and silently show 0 clients), so the audit's "undeployable crash" framing was overstated — but the B2B portal genuinely doesn't work.
|
||||||
|
|
||||||
|
### ✅ SEC-05 "Operations email not HTML-escaped" — REAL (MEDIUM-HIGH)
|
||||||
|
Verified: `src/app/actions/operations.ts → generateRichEmailHtml()` interpolates `item.title`, `payload.clientName`, `clientCompany`, etc. straight into HTML with no `escapeHtml()`. Stored-XSS into the team's internal operations inbox from the CartDrawer form. Real.
|
||||||
|
|
||||||
|
### ✅ SEC-01 "Hardcoded secret fallback in proxy" — REAL but MITIGATED (MEDIUM)
|
||||||
|
`src/proxy.ts:13` still has `|| "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`. Mitigated because signing (`session.ts`) throws without the env var, so tokens can't be forged in normal operation. Should still be removed (the verifier asymmetry is real).
|
||||||
|
|
||||||
|
### ✅ INFRA-03 "Secrets in git" — REAL (known)
|
||||||
|
OpenAI key + Gmail app password are in git history. Rotate; convert tracked `env` → `.env.example`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Corrected priority list — what actually matters
|
||||||
|
|
||||||
|
### 🔴 NOW (real, small, do first)
|
||||||
|
1. **Auth on `/api/assets` + `/api/branding/favicon`** (SEC-04) — add `getAdminSession()` guard at the top of each handler. *Effort: S*
|
||||||
|
2. **Create the `ClientUser` migration** (DB-01) — additive migration; unblocks the B2B portal. *Effort: S*
|
||||||
|
3. **Escape HTML in operations emails** (SEC-05) — reuse `escapeHtml` like consultation/route already does. *Effort: S*
|
||||||
|
4. **Remove the proxy secret fallback** (SEC-01) — throw if `SESSION_SECRET` missing, mirroring `session.ts`. *Effort: S*
|
||||||
|
5. **Whitelist chat context fields** (AI-01) — sanitize `context.section/activeTab` before injecting into the system prompt (prompt-injection hygiene). *Effort: S*
|
||||||
|
6. **Rotate OpenAI key + SMTP password; untrack `env`** (INFRA-03). *Effort: S — needs the client for the key*
|
||||||
|
|
||||||
|
### 🟡 NEXT (operational resilience — all real)
|
||||||
|
- Automated nightly **DB backups** (none today) + offsite. *M*
|
||||||
|
- **Container memory/CPU limits** + Nginx healthcheck in compose. *S*
|
||||||
|
- `buildSystemPrompt()` **try/catch + static fallback** so chat degrades instead of 500 if DB is down (TEST-02). *S*
|
||||||
|
- **Validate `OPENAI_API_KEY`** at startup + wrap `streamText` in try/catch (TEST-03). *S*
|
||||||
|
- **Idempotent consultation** submission (no orphan records on SMTP fail) (TEST-04). *M*
|
||||||
|
- **Monitoring/uptime** (UptimeRobot on `/api/health`) + log aggregation. *M*
|
||||||
|
- Index `GlobalNode.application`; cache the system prompt (PERF-01/03). *S*
|
||||||
|
|
||||||
|
### 🟢 LATER (polish/scale)
|
||||||
|
- Defense-in-depth `getSession()` inside HQ actions (SEC-03). a11y pass (focus trap, labels, skip link, reduced-motion). i18n: number formatting + translate error boundaries. SEO: image/video sitemaps, FAQ/VideoObject schema. Reduce `any`, split oversized files, adopt `log.*` everywhere. Jsonb migration for hot JSON fields. Lazy-load Three.js.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's genuinely strong (verified)
|
||||||
|
CSRF on public endpoints · magic-byte upload validation · JWT signing that throws on missing secret · **the middleware DOES protect HQ** · complete 5-locale parity · the AI translation glossary · hreflang/canonical/breadcrumb SEO · Nginx edge caching + the new canonical-host guard + scanner blocking · structured logger + safe-JSON helpers + escapeHtml exist (gap is adoption consistency) · clean idempotent migrations · bcrypt password hashing · well-designed 9-tool SPIN FluxAI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dimension scorecard (audit, with my adjustments)
|
||||||
|
| Dimension | Audit | Adjusted | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Security | 3.5 | **6.5** | SEC-02 false positive removed; middleware protects HQ. Real gaps: SEC-04/05/01. |
|
||||||
|
| Performance | 6.0 | 6.0 | Solid baseline; prompt rebuilt per message. |
|
||||||
|
| Code quality | 5.5 | 5.5 | `any` usage, logging inconsistency. |
|
||||||
|
| Database | 4.5 | **5.5** | ClientUser migration missing (B2B), but not a dashboard crash. |
|
||||||
|
| FluxAI | 6.0 | 6.0 | Strong tools; add prompt-injection guard + history. |
|
||||||
|
| SEO | 6.5 | 6.5 | Good base; add media sitemaps + FAQ/Video schema. |
|
||||||
|
| i18n | 6.0 | 6.5 | Excellent infra; few hardcoded strings. |
|
||||||
|
| Infra | 4.0 | 4.5 | No backups/monitoring; secrets in git. The real weak spot. |
|
||||||
|
| Accessibility/UX | 4.5 | 5.0 | a11y gaps; dark mode now covered. |
|
||||||
|
| Testing/reliability | 3.0 | 3.5 | One suite; add API integration tests + degradation. |
|
||||||
|
|
||||||
|
**Adjusted overall: ~6.0/10** (audit said 4.8 — the false-positive critical dragged it down). The build is solid; the genuine priorities are a handful of small auth/data fixes plus operational resilience (backups + monitoring).
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# Appendix — Full automated audit report (raw, uncorrected)
|
||||||
|
|
||||||
|
> The section below is the unedited 10-dimension report. Where it conflicts with the verification above (notably SEC-02), the verification above is authoritative.
|
||||||
|
|
||||||
|
# FLUX SRL — Consolidated Audit Report
|
||||||
|
|
||||||
|
**Project:** rf-flux.com (Flux SRL) — Next.js 16 · Prisma 7 · PostgreSQL · AI SDK 6 · 5 locales · Docker + Nginx on OVH
|
||||||
|
**Audit scope:** 10 dimensions, adversarially verified (each critical/high finding independently re-checked against source)
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Auditor:** Lead consolidation pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Flux SRL is a capably-built marketing + B2B platform with genuinely good bones — multi-stage Docker, CSRF on public endpoints, magic-byte upload validation, a structured logger, a working AI translation glossary, complete 5-locale message parity, and a recently-hardened Nginx (canonical-host guard, scanner blocking). But the audit surfaced a **cluster of authorization failures that, taken together, mean the entire HQ admin surface is currently unprotected**, plus operational gaps (no backups, no monitoring, secrets in git) that make this risky to run in production as-is.
|
||||||
|
|
||||||
|
The single most important finding: the auth middleware **does not execute at all** because the file exports a function named `proxy` instead of `middleware`. Every downstream auth assumption collapses from that one fact — and because the HQ server actions and the `/api/assets` + `/api/branding` routes have *no* internal session checks, they are reachable by anyone.
|
||||||
|
|
||||||
|
### Overall health: **4.8 / 10** (weighted toward the security + infra blockers)
|
||||||
|
|
||||||
|
### Dimension scorecard
|
||||||
|
|
||||||
|
| Dimension | Score | One-line verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| Security | 3.5 | Middleware never runs; HQ actions + asset APIs fully unauthenticated. |
|
||||||
|
| Performance | 6.0 | Solid caching baseline; chat rebuilds prompt + 4 DB queries every message. |
|
||||||
|
| Code quality | 5.5 | 187 `any` usages, inconsistent logging, empty catch blocks; good helpers exist but under-used. |
|
||||||
|
| Database | 4.5 | `ClientUser` table referenced in code but never migrated — runtime crash risk. |
|
||||||
|
| FluxAI | 6.0 | Strong tool architecture; prompt injection via context, no history, plaintext message storage. |
|
||||||
|
| SEO | 6.5 | Good metadata + JSON-LD; no image/video sitemaps for a visual industrial site. |
|
||||||
|
| i18n | 6.0 | Excellent infra and parity; a few components ship hardcoded English. |
|
||||||
|
| Infra | 4.0 | No resource limits, no backups, no monitoring, secrets in tracked `env` file. |
|
||||||
|
| Accessibility/UX | 4.5 | No skip link, no Escape-to-close, no reduced-motion, broken Tailwind class. |
|
||||||
|
| Testing/reliability | 3.0 | One unit-test file; zero integration tests; multiple silent-fail paths. |
|
||||||
|
|
||||||
|
### Top 5 things to fix first
|
||||||
|
|
||||||
|
1. **Make the middleware actually run** (SEC-02). Rename/export `proxy` as `middleware` in `src/middleware.ts`. Until this lands, *nothing else in the auth layer matters* — the page-level guards are dead code.
|
||||||
|
2. **Add session checks inside HQ server actions and the asset/branding APIs** (SEC-03, SEC-04). Defense-in-depth: even with middleware fixed, these must self-verify. They are directly POST-able today.
|
||||||
|
3. **Rotate the leaked credentials and stop tracking `env`** (INFRA-03). The OpenAI key and Gmail app password are in git history. Rotate now; they're compromised regardless of what you do next.
|
||||||
|
4. **Create the missing `ClientUser` migration** (DB-01). Code calls `prisma.clientUser` in the dashboard and B2B auth; the table was never created. This is an active crash, not a hypothetical.
|
||||||
|
5. **Stand up backups + container resource limits** (INFRA-05, INFRA-01). No automated DB backup and no memory caps on an OVH single-VPS is a "lose everything on one bad day" setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Critical & High Findings (confirmed / partially-confirmed only)
|
||||||
|
|
||||||
|
> Severities below use the **adjusted** verdict from verification. Refuted findings are excluded (see §6).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
**SEC-02 — Auth middleware never executes (CRITICAL)**
|
||||||
|
`src/proxy.ts:17` — The file exports `async function proxy(...)` plus `config`, but Next.js 16 only invokes a file named `middleware.ts` exporting `middleware`. The `.next/server/middleware-manifest.json` is empty, confirming it was never compiled. A code comment even documents the rename intentionally. **Impact:** every `/hq-command/*` route has zero middleware protection; direct navigation and prefetch hit the dashboard with no auth gate. **Fix:** create `src/middleware.ts` with `export { proxy as middleware }` and re-export `config`; verify the matcher covers `/hq-command`. Then redeploy and confirm the manifest is populated.
|
||||||
|
|
||||||
|
**SEC-03 — HQ server actions lack session verification (CRITICAL)**
|
||||||
|
`src/app/hq-command/dashboard/inbox/actions.ts:11-188` — `getSignals()`, `getClients()`, `approveAccessRequest()`, `updateSignalStatus()`, `resolveAndCleanSignal()`, `deleteSignal()`, `resendSignalEmail()`, `deleteClient()` run raw Prisma mutations with no `getSession()` call. Marked `"use server"` but self-unauthenticated. **Impact:** these are invokable directly via POST (curl/fetch) — approve/delete B2B clients, mutate signals, trigger emails, all without a token. **Fix:** add a `requireAdminSession()` helper and call it at the top of every action: `const s = await getAdminSession(); if (!s) throw new Error('Unauthorized');`. Don't rely on the middleware alone.
|
||||||
|
|
||||||
|
**SEC-04 — `/api/assets` and `/api/branding/favicon` are unauthenticated (CRITICAL, raised from high)**
|
||||||
|
`src/app/api/assets/route.ts`, `src/app/api/branding/favicon/route.ts` — All methods (GET/POST/PUT/DELETE/PATCH) on `/api/assets` and POST on the favicon route have no session check. Path sanitization prevents traversal but not auth. **Impact:** anyone can enumerate CMS structure, upload SVG-with-JS into public scopes, delete branding/content, or regenerate favicons → defacement/XSS/downtime. **Fix:** at the top of each handler, `const s = await getAdminSession(); if (!s) return NextResponse.json({error:'Unauthorized'},{status:401});`.
|
||||||
|
|
||||||
|
**SEC-01 — Hardcoded session-secret fallback in proxy (HIGH, lowered from critical)**
|
||||||
|
`src/proxy.ts:13` — `process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`, used for JWT *verification* at line 35. Verifier confirmed it's mitigated in practice: `session.ts`/`clientAuth.ts` *throw* if the secret is unset at signing time, so tokens can't be forged under normal operation. The danger is the asymmetry — if env loading ever fails at runtime, the verifier would accept tokens signed with the public string. **Fix:** remove the fallback; throw loudly if `SESSION_SECRET` is missing, mirroring `session.ts`.
|
||||||
|
|
||||||
|
**SEC-05 — Missing HTML escaping in operations email templates (HIGH)**
|
||||||
|
`src/app/actions/operations.ts:119-156` — `generateRichEmailHtml()` interpolates `item.title`, `item.sku`, `payload.clientName`, `clientCompany`, `clientEmail`, `clientPhone`, `message` straight into HTML. `escapeHtml()` exists in `src/lib/escapeHtml.ts` but isn't imported here. **Impact:** HTML/script injection into internal operations emails from the CartDrawer form. **Fix:** escape every user field before interpolation, matching the pattern already correct in `src/app/api/consultation/route.ts`.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
**DB-01 — `ClientUser` table referenced but never migrated (CRITICAL)**
|
||||||
|
`prisma/schema.prisma:370-387` defines `ClientUser` and the `OperationsSignal.clientId` relation (lines 226-227), but **no migration creates the table** — not the init migration nor any of the 5 later ones. Code actively calls it: `src/app/hq-command/dashboard/page.tsx:67-68` (`prisma.clientUser.count()`), `src/app/actions/clientAuth.ts:25,30,50+` (findUnique/create/update). **Impact:** immediate runtime failure on any B2B auth or dashboard-count path; undeployable. **Fix:** generate a migration creating `ClientUser` (id, email unique, passwordHash, fullName, companyName, phone?, isApproved default false, lastLoginAt?, createdAt, updatedAt) and the FK from `OperationsSignal.clientId`. Run `prisma migrate dev` and verify against a fresh DB.
|
||||||
|
|
||||||
|
**DB-04 — JSON stored as `String` instead of `Jsonb` (HIGH)**
|
||||||
|
`prisma/schema.prisma` — 24+ JSON fields across 12 models (`translationsJson`, `galleryJson`, `sectionsJson`, `payloadJson`, etc.) are `String`. **Impact:** no DB-level JSON queries/indexing/validation; all parse/serialize is manual (and is the root cause of several CQ-03/CQ-04 parse-error findings). **Fix:** migrate query-hot fields first (`payloadJson` on AiEvent, `galleryJson`/`sectionsJson` on Application) to `Jsonb` via add-column → backfill → drop → rename.
|
||||||
|
|
||||||
|
**DB-05 — AI telemetry grows unbounded; no retention policy (HIGH)**
|
||||||
|
`prisma/schema.prisma:322-365` — `AiConversation`/`AiEvent` persist every chat indefinitely. No TTL, cleanup job, or purge anywhere in `src/`. Stores hashed IP + userAgent + full message text → GDPR Art. 5(1)(e) storage-limitation exposure. **Fix:** add a `retentionDays` setting (default 90), a daily cleanup deleting rows older than the window, and document it. Consider archival before delete.
|
||||||
|
|
||||||
|
**DB-03 — `GlobalNode.application` has no referential integrity (MEDIUM, lowered from high)**
|
||||||
|
`prisma/schema.prisma:40` — plain `String`, validated only as non-empty in `network/actions.ts:34`. Verifier downgraded: orphaned nodes persist and are simply excluded from filtered queries (deterministic, not data loss); UI dropdown prevents most bad input. **Fix:** validate `Application.findUnique({where:{slug}})` exists before create, or migrate to an `applicationId` FK.
|
||||||
|
|
||||||
|
**DB-02 — `PageContent` missing `createdAt` (MEDIUM, lowered from high)**
|
||||||
|
`prisma/schema.prisma:266` — has `updatedAt` only; every other model has both (except `SiteSetting`). **Impact:** no creation audit trail for page content. **Fix:** add `createdAt DateTime @default(now())`.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
**INFRA-01 — No container resource limits (CRITICAL)**
|
||||||
|
`docker-compose.yml:41-116` — neither `app` (with `restart: always`) nor `nginx` nor postgres define memory/CPU limits. **Impact:** a runaway/leaking process consumes all VPS RAM and OOM-kills siblings → documented downtime. **Fix:** `app` mem_limit 1G (reservation 512M), nginx 256M/128M, cpus 1.0 each; load-test.
|
||||||
|
|
||||||
|
**INFRA-03 — Secrets committed in tracked `env` file (CRITICAL)**
|
||||||
|
`env:29-40` — real `OPENAI_API_KEY` (sk-proj-…), `SMTP_USER`, and a 16-char Gmail app password, tracked since first commit `fc24313`. `.gitignore` pattern `.env*` doesn't match the bare `env` filename. **Fix (in order):** (1) **rotate the OpenAI key and Gmail app password now** — they're already compromised; (2) `git rm --cached env`, add `env` to `.gitignore`; (3) commit `.env.example` with placeholders; (4) move to GitHub Secrets / a secrets manager. History scrub is optional but the rotation is not.
|
||||||
|
|
||||||
|
**INFRA-05 — No automated database backups (CRITICAL)**
|
||||||
|
`src/app/hq-command/dashboard/health/actions.ts:31-52` — backup is a manual HTTP export of unencrypted JSON covering only 10 of 16 tables (omits HeroSlide, SiteSetting, AiConversation, AiEvent, ClientUser, TeamMember). No cron, no offsite, no retention, no WAL/PITR. **Impact:** total loss if the PG volume corrupts. **Fix:** nightly `pg_dump | gzip` to offsite (S3, encrypted), 30-day retention; enable WAL archiving for PITR; document + test RTO/RPO monthly.
|
||||||
|
|
||||||
|
**INFRA-02 — Nginx has no healthcheck; unconditional `depends_on` (HIGH, lowered from critical)**
|
||||||
|
`docker-compose.yml:93-116` — nginx lacks a healthcheck and depends on `app` with no `condition`, while `app` correctly uses `condition: service_healthy` for postgres. Verifier softened the "deadlock" claim: it's a startup race, not a hang — nginx proxies to the unready app and returns 502/503 during the ~40s start window. **Fix:** add an nginx healthcheck (tcp:80) and `depends_on: app: condition: service_healthy`.
|
||||||
|
|
||||||
|
**INFRA-04 — Deploy script has no error handling or rollback (HIGH, lowered from critical)**
|
||||||
|
`.github/workflows/deploy.yml:28-51` — the health check at line 49 uses `curl -sf … || echo`, swallowing failure so `script_stop` is bypassed; migrations (line 42) run post-live with no error handling; no snapshot, no rollback. **Impact:** a failed migration or post-deploy crash leaves prod broken with manual-only recovery. **Fix:** make the health check fail the deploy (`curl -sf … || exit 1`, retried 3× with backoff); take a pre-deploy snapshot; restore on failure.
|
||||||
|
|
||||||
|
**INFRA-06 — No monitoring, alerting, or log aggregation (HIGH, lowered from critical)**
|
||||||
|
`docker-compose.yml` — structured logger + `/api/health` exist, but nothing ships metrics/logs to any platform (no Prometheus/Grafana/Sentry/Loki). **Impact:** blind operations; degradation invisible until users complain. **Fix:** expose `/metrics` (prom-client), ship structured logs to Loki/CloudWatch, alert on mem >80% / error-rate >1% / p99 >500ms.
|
||||||
|
|
||||||
|
**INFRA-07 — Missing OCSP stapling (HIGH)**
|
||||||
|
`nginx/conf.d/flux.conf:68-82` — no `ssl_stapling`. **Impact:** slower TLS handshakes; visitor IPs leak to Let's Encrypt. **Fix:** add `ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate …/chain.pem; resolver 1.1.1.1 8.8.8.8 valid=300s;`.
|
||||||
|
|
||||||
|
**INFRA-11 — CSP allows `unsafe-inline` + `unsafe-eval` (MEDIUM, lowered from high)**
|
||||||
|
`nginx/conf.d/flux.conf:87` — present in `script-src` for Next.js hydration. Verifier noted strong compensating controls (Zod validation, `escapeHtml`, magic-byte checks, minimal `dangerouslySetInnerHTML`), so it's real technical debt rather than an active hole. **Fix:** move to nonce-based CSP; run `Report-Only` first.
|
||||||
|
|
||||||
|
**INFRA-08 — Nginx cache-poisoning surface via hidden `Set-Cookie` (MEDIUM, lowered from high)**
|
||||||
|
`nginx/conf.d/flux.conf:282-286` — `proxy_ignore_headers`/`proxy_hide_header Set-Cookie` on public pages. Verifier refuted the "session cookies lost" and "sensitive header disclosure" parts (authenticated requests bypass cache via `$cookie_flux_session`); the residual risk is poisoning public pages with a non-sensitive injected cookie. **Fix:** refine cache-bypass logic rather than blanket-hiding `Set-Cookie`.
|
||||||
|
|
||||||
|
**INFRA-09 — Large `client_max_body_size` without explicit body timeout (MEDIUM, lowered from high)**
|
||||||
|
`nginx/conf.d/flux.conf:71,135,148` — 500M limit; global block lacks `client_body_timeout`. Verifier softened: nginx's 60s default applies and upload endpoints set proxy timeouts. **Fix:** add `client_body_timeout 60s;` to the http block; reduce 500M where not genuinely needed.
|
||||||
|
|
||||||
|
**INFRA-10 — No logrotate (MEDIUM, lowered from high)**
|
||||||
|
`nginx/nginx.conf:39` — logs unbounded, but verifier noted `/var/log/nginx` isn't a mounted volume, so logs live in ephemeral container storage (purged on restart). Residual risk: log loss on restart and container-layer growth on long uptimes. **Fix:** add logrotate (daily, compress, keep 7, SIGHUP).
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
**PERF-01 — System prompt rebuilt with 4 DB queries on every chat request (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:42-281` — `buildSystemPrompt()` runs `application.findMany` + 2× `globalNode.count` + `sparePart.count` (Promise.all) on every POST, with no caching layer (the `promptCacheKey` is a no-op). **Impact:** 4 round-trips per message; 400+/min at 100 concurrent users. **Fix:** cache the built prompt in-memory with a 30–60s TTL, invalidated on CMS write.
|
||||||
|
|
||||||
|
**PERF-03 — Missing index on `GlobalNode.application` (HIGH)**
|
||||||
|
`prisma/schema.prisma` — queries filter on `(application, isActive)`, `(nodeType, isActive, application)`, and `(application, nodeType, isActive)` (applications page 136-142; chat 503, 621) but there's no `application` index. **Impact:** full table scans on the synchronous FluxAI search path. **Fix:** add `@@index([application])`, `@@index([application, isActive])`, `@@index([application, nodeType])`; migrate.
|
||||||
|
|
||||||
|
**PERF-02 — Synchronous `fs.readdirSync` in SSR paths (MEDIUM, lowered from high)**
|
||||||
|
`src/app/[locale]/page.tsx:102`, `src/app/[locale]/applications/[slug]/page.tsx:32` — real blocking calls, but verifier downgraded: they're fallback paths inside try/catch on small dirs, hit during 60s ISR regen, not per-request. **Fix:** switch to `fs.promises.readdir`, or remove the fallback once HeroSlide/CMS migration is complete.
|
||||||
|
|
||||||
|
### FluxAI
|
||||||
|
|
||||||
|
**AI-01 — Prompt injection via unvalidated context fields (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:215-216` — `context.section` and `context.activeTab` come from `req.json()` (TS types only, no runtime validation) and are interpolated into `contextNote`, concatenated to the system prompt at line 287. A manipulated frontend store can inject instructions overriding FluxAI's personality/tool limits. **Fix:** whitelist `context.section`/`activeTab` against the known section set; reject anything else (Zod).
|
||||||
|
|
||||||
|
**AI-03 — User message text stored unencrypted (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:268` — full user text (≤8000 chars) persisted as plaintext in `AiEvent.payloadJson` (`String`). Customer questions about volumes/processes sit in cleartext. **Impact:** breach exposes competitive intel; GDPR Art. 32 (encryption at rest). **Fix:** encrypt sensitive payloads (pgcrypto/KMS) or store only industry label + tool names; pair with the DB-05 retention policy.
|
||||||
|
|
||||||
|
**AI-02 — No conversation history / resume (HIGH)**
|
||||||
|
`src/components/ai/SilentObserver.tsx:76-78` — `useChat` initializes fresh with no `initialMessages`; backend persists everything but the UI never fetches it. **Impact:** multi-turn B2B sales context lost on refresh, undermining the funnel-tracking investment. **Fix:** add `/api/chat/history?sessionId`, hydrate `initialMessages` on mount, offer "continue previous conversation?".
|
||||||
|
|
||||||
|
**AI-06 — Telemetry write errors silently swallowed (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:272-274, 370-371` — telemetry writes are wrapped in try/catch with only `log.warn()`; the `onFinish` writes can fail after the response streamed, losing conversation records. **Impact:** undercounted funnel analytics. **Fix:** circuit-breaker + alert on >5 failures/hour; buffer-and-retry unsent events.
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
**SEO-02 — No image or video sitemaps (HIGH)**
|
||||||
|
`src/app/sitemap.ts:1-106` — only page URLs. Rich media (news `coverImage`/`galleryJson`, application galleries, heritage `mediaUrl` videos) is invisible to Google Image/Video Search; `robots.ts` declares only `/sitemap.xml`. **Fix:** add `sitemap-images.xml` (`<image:image>` per article cover/gallery + app hero) and `sitemap-videos.xml` (heritage videos with title/description/duration); declare both in robots.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
**I18N-01 — Hardcoded English in EnergySavingsCalculator (HIGH)**
|
||||||
|
`src/components/ai/EnergySavingsCalculator.tsx:61,118,146-149,156,164-178` — 13+ literal strings ("Annual Savings", "CO2 Reduced", "Payback", "Request Detailed Engineering Study", …); component never imports `useTranslations`, and no `EnergySavingsCalculator` namespace exists in any of the 5 locale files. **Impact:** IT/ES/DE/VEC users see English in a customer-facing calculator. **Fix:** add the namespace to `en.json`, translate to all 4 locales, wire `useTranslations("EnergySavingsCalculator")`.
|
||||||
|
|
||||||
|
**I18N-02 — Hardcoded alert in CartDrawer (HIGH)**
|
||||||
|
`src/components/layout/CartDrawer.tsx:43` — `alert("You must accept the privacy policy.")` bypasses the otherwise-used translation system; a modal blocker shown in English to all locales. **Fix:** move to a translated key and prefer the Toast component over native `alert()`.
|
||||||
|
|
||||||
|
### Accessibility / UX
|
||||||
|
|
||||||
|
**A11Y-02 — Broken Tailwind class breaks nav styling (HIGH)**
|
||||||
|
`src/components/layout/NavBar.tsx:190` — `"text-[#86868B hover:text-[#1D1D1F]"` is missing a `]`. **Impact:** the color class doesn't apply to inactive nav links in light mode. **Fix:** `"text-[#86868B] hover:text-[#1D1D1F]"`.
|
||||||
|
|
||||||
|
**A11Y-03 — Icon-only buttons missing `aria-label` (HIGH)**
|
||||||
|
`src/components/layout/NavBar.tsx:229,278,294` — theme toggle, cart, mobile-menu announce only "button". **Fix:** add descriptive `aria-label`s (e.g., `aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}`).
|
||||||
|
|
||||||
|
**A11Y-04 — Modal can't be closed with Escape (HIGH)**
|
||||||
|
`src/components/ui/CaseStudyModal.tsx:232-237` — no keydown handler; only a click-able close button (WCAG 2.1.1). **Fix:** `useEffect` keydown listener → `if (e.key==='Escape') onClose()`.
|
||||||
|
|
||||||
|
**A11Y-05 — No skip-to-main-content link (HIGH)**
|
||||||
|
`src/app/[locale]/layout.tsx:145-204` — ~13 interactive elements before main; main div has no `id` and isn't a `<main>` (WCAG 2.4.1). **Fix:** add `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>` and an `id`/`<main>` target.
|
||||||
|
|
||||||
|
**A11Y-08 — No `prefers-reduced-motion` handling (HIGH)**
|
||||||
|
`src/app/globals.css` — 260+ framer-motion animations, zero reduced-motion checks. **Impact:** vestibular-disorder risk. **Fix:** add `@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}` and gate heavy motion via `useReducedMotion()`.
|
||||||
|
|
||||||
|
**A11Y-09 — Hero videos lack captions (MEDIUM, lowered from high)**
|
||||||
|
`src/components/sections/HeroReel.tsx:71-83` — no `<track>`; verifier noted videos are muted/decorative with overlaid text, softening impact (WCAG 1.2.2). **Fix:** add captions where videos carry meaning, or mark decorative explicitly.
|
||||||
|
|
||||||
|
**A11Y-01 — Empty `alt` on cart product images (MEDIUM, lowered from high)**
|
||||||
|
`src/components/layout/CartDrawer.tsx:148` — `alt=""`; verifier noted adjacent visible title+SKU text mitigates. **Fix:** `alt={item.title}` for defense-in-depth.
|
||||||
|
|
||||||
|
### Code quality
|
||||||
|
|
||||||
|
**CQ-01 — 187 `any` usages across 48 files (HIGH)**
|
||||||
|
Concentrated in `ApplicationClient.tsx` (1208 lines), `chat/route.ts` (920 lines), `SilentObserver.tsx` (22 instances), despite `strict: true`. **Fix:** use existing `cms.ts` types (`AppFull`, `NodeFull`); add a `model-viewer.d.ts` for the web-component casts; chip away by file.
|
||||||
|
|
||||||
|
**CQ-02 — Inconsistent logging: 50 `console.error` vs 9 `log.error` (HIGH)**
|
||||||
|
A structured JSON logger exists (`src/lib/logger.ts`) but is bypassed in `sitemap.ts:75,102`, `heritage/page.tsx:210`, `mailer.ts:96`, `imageOptimizer.ts:117`, etc. **Impact:** unstructured output breaks Loki/CloudWatch parsing. **Fix:** replace `console.*` with `log.*` in server code; add a lint rule / pre-commit hook.
|
||||||
|
|
||||||
|
**CQ-04 — Empty catch blocks suppress JSON parse errors (HIGH)**
|
||||||
|
`ApplicationClient.tsx:618,826-829`, `news/[slug]/page.tsx:259`, `parts/page.tsx:55` — silent `catch(e){}` on dimensions/media/specs parsing. **Fix:** at minimum `catch(e){ log.warn('parse_failed', e) }`.
|
||||||
|
|
||||||
|
**CQ-03 — Unguarded `JSON.parse` for sections/advantages (MEDIUM, lowered from high)**
|
||||||
|
`ApplicationClient.tsx:1026-1027` — `data.sectionsJson`/`advantagesJson` parsed with no try/catch (verifier confirmed *this* pair; refuted the claims about lines 600/609/CaseStudyModal which do have guards). **Fix:** use the existing `parseJsonField` helper from `cms.ts`.
|
||||||
|
|
||||||
|
**CQ-07 — `any`-typed Prisma access in chat (MEDIUM, lowered from high)**
|
||||||
|
`chat/route.ts:413-414` — `(app: any)` access with inconsistent null-handling; verifier refuted the line-450 "undefined .score" claim (it's always initialized). Real issue is type-safety, not a live crash. **Fix:** use Prisma `select` with explicit types instead of `any`.
|
||||||
|
|
||||||
|
### Testing / reliability
|
||||||
|
|
||||||
|
**TEST-01 — No integration/e2e tests for critical API routes (HIGH)**
|
||||||
|
Only `tests/ai/golden.test.mjs` (17 unit tests). Zero coverage of `/api/consultation`, `/api/chat`, `/api/health`, `/api/public-upload`. **Fix:** add integration tests (Node test runner) with mocked Prisma/nodemailer; aim >80% on `src/app/api`.
|
||||||
|
|
||||||
|
**TEST-02 — `buildSystemPrompt()` has no DB-failure handling (HIGH)**
|
||||||
|
`chat/route.ts:42-72` — 4 parallel Prisma queries, no try/catch; called at line 281 before `streamText()`. If Postgres is down, the whole chat 500s instead of degrading. Asymmetric with the telemetry try/catch. **Fix:** wrap in try/catch → fall back to a static `DEFAULT_SYSTEM_PROMPT` (keeps personality/tools, omits dynamic counts).
|
||||||
|
|
||||||
|
**TEST-03 — OpenAI key never validated at startup or in health check (HIGH)**
|
||||||
|
`chat/route.ts`, `health/route.ts` — `openai('gpt-4o')` is called with no key validation; `/api/health` only does `SELECT 1`. **Impact:** invalid/missing key surfaces mid-stream after headers are sent. **Fix:** validate `OPENAI_API_KEY` at startup; wrap `streamText()` in try/catch returning a structured error (with `retryAfterSec` on 429).
|
||||||
|
|
||||||
|
**TEST-04 — Consultation email isn't idempotent; orphaned records on SMTP failure (HIGH)**
|
||||||
|
`api/consultation/route.ts:80-155` — signal created (line 105) before email send (line 141); on SMTP failure the client gets a 500 with no `ticketId`, retries duplicate, and the fallback route isn't re-attempted. **Fix:** add an idempotency key, return 202 with `ticketId` + `emailError` on send failure, and add a background retry with backoff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Medium / Low / Info
|
||||||
|
|
||||||
|
**Security**
|
||||||
|
- SEC-06 (med) — `operations.ts:129-132`: file links built from unvalidated `fileUrl` → validate it starts with `/`.
|
||||||
|
- SEC-07 (low, downgraded) — `parts/page.tsx`: client-side gate not SSR redirect; data isn't leaked → optional `redirect()` for cleanliness.
|
||||||
|
- SEC-08 (med) — HQ actions have no rate limiting → rate-limit by admin id + action.
|
||||||
|
- SEC-09 (low) — `rateLimit.ts:144`: trusts `X-Forwarded-For` unvalidated → validate single-IP / trust only Nginx.
|
||||||
|
- SEC-10 (low) — `chat/route.ts:225`: `SESSION_SECRET` reused as telemetry HMAC salt → use a separate `VISITOR_HASH_SECRET`.
|
||||||
|
|
||||||
|
**Database**
|
||||||
|
- DB-06 (med) — `schema.prisma:230`: `AiConversation→OperationsSignal` no `onDelete` → orphans; document or cascade.
|
||||||
|
- DB-07 (med) — add `@@index([type, status, createdAt(sort: Desc)])` on OperationsSignal.
|
||||||
|
- DB-08 (low) — Application.order non-unique → validate uniqueness per `isActive` group.
|
||||||
|
- DB-09 (low) — NewsArticle.publishedAt has no future-date guard → filter `publishedAt <= now()`.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- PERF-04 (med) — `chat/route.ts:519`: JS keyword filter post-query → use Prisma `contains`/`mode:'insensitive'`.
|
||||||
|
- PERF-05 (med) — lazy-load `GlobalOperations` (Three.js ~300KB) via `dynamic({ssr:false})`.
|
||||||
|
- PERF-06 (med) — 48 `use client` components; push pure-display ones back to server components.
|
||||||
|
- PERF-07 / INFRA-12 (med) — Prisma pool `max=10` hardcoded → make `DB_POOL_MAX` env-configurable (20–30 prod).
|
||||||
|
- PERF-08/10 (low) — ISR `revalidate=60` × 40 renders/min → consider 300–600s or on-demand revalidation.
|
||||||
|
- PERF-09 (med) — large Docker build context → audit with `--progress=plain`, exclude heavy media.
|
||||||
|
- INFRA-13 (med) — Nginx upstream `keepalive=32` → raise to 128–256.
|
||||||
|
|
||||||
|
**FluxAI**
|
||||||
|
- AI-04 (med) — no golden eval for tool selection → build 20–30 query eval set, run monthly.
|
||||||
|
- AI-05 (low) — `stepCountIs(5)` may truncate → bump to 7–8, monitor traces.
|
||||||
|
- AI-07 (low) — no per-tool cost tracking → log `(conversationId, toolName, tokensIn/Out)`.
|
||||||
|
- AI-08 (med) — `SilentObserver.tsx:232,237`: tool failures silently omitted → render "no results" card.
|
||||||
|
- AI-09 (low) — duplicate of PERF-01 (prompt caching).
|
||||||
|
- AI-10 (med) — `useChat` has no `onError` → add error state + Retry button.
|
||||||
|
|
||||||
|
**SEO**
|
||||||
|
- SEO-01 (med) — OG images lack width/height/type → add `{width:1200,height:630,type:'image/jpeg'}`.
|
||||||
|
- SEO-03 (med) — no FAQPage schema → add `faqPageSchema()`.
|
||||||
|
- SEO-04 (med) — no VideoObject schema on heritage videos → add `videoObjectSchema()`.
|
||||||
|
- SEO-05/06 (low) — Product schema lacks AggregateRating/Offer; Article lacks keywords/commentCount.
|
||||||
|
- SEO-07 (med) — markdown parser allows empty `alt` → fallback `alt || 'Article image'` + CMS validation.
|
||||||
|
- SEO-08 (low) — add breadcrumb schema to home/team/heritage/news-hub.
|
||||||
|
- SEO-09 (low) — LocalBusiness hours lack timezone → add `Europe/Rome`.
|
||||||
|
- SEO-10 (med) — GlobalNode case studies lack structured data → add `caseStudySchema()`.
|
||||||
|
- SEO-11 (low) — add Twitter `creator`/`site` handles.
|
||||||
|
- SEO-12 (info) — no Core Web Vitals monitoring → add `web-vitals` + dashboard.
|
||||||
|
|
||||||
|
**i18n**
|
||||||
|
- I18N-03 (med) — error boundaries hardcoded English → translate `[locale]/error.tsx` at minimum.
|
||||||
|
- I18N-04 (med) — `formatNumber()` calls `toLocaleString()` without locale → pass active locale / `Intl.NumberFormat`.
|
||||||
|
- I18N-05 (med) — register `EnergySavingsCalculator` namespace in all 5 locale files (pairs with I18N-01).
|
||||||
|
- I18N-08 (low) — `getLocalizedData` fallback is English-only → optional locale-chain fallback.
|
||||||
|
|
||||||
|
**Accessibility / UX**
|
||||||
|
- A11Y-06 (med) — `ConsultationScheduler.tsx:394`: errors lack `aria-live`/`role=alert`.
|
||||||
|
- A11Y-07 (med) — form inputs lack `<label htmlFor>` associations.
|
||||||
|
- A11Y-10 (med) — 71 buttons default to `type=submit` → add `type=button` to non-submit buttons.
|
||||||
|
- A11Y-11 (med) — AI chat modal has no focus trap.
|
||||||
|
- A11Y-12 (med) — language dropdown lacks arrow-key navigation.
|
||||||
|
- A11Y-13 (med) — opacity-reduced nav text may fail 4.5:1 contrast → test + use solid colors.
|
||||||
|
- A11Y-14 (med) — success message lacks `role=status`/`aria-live`.
|
||||||
|
- A11Y-15 (low) — verify dark-mode coverage on newer pages + HQ panel.
|
||||||
|
|
||||||
|
**Code quality**
|
||||||
|
- CQ-05 (med) — oversized files (ApplicationClient 1208, chat/route 920, AssetBucketBrowser 874) → extract sub-components/tools.
|
||||||
|
- CQ-06 (med) — model-viewer `as any` casts → proper `.d.ts` type.
|
||||||
|
- CQ-08 (med) — inconsistent error-handling pattern across layers → standardize log-then-return/throw.
|
||||||
|
- CQ-09 (low) — `privacy/page.tsx:12`: hardcoded `privacy@rf-flux.com` (TODO unconfirmed) → env/SiteSettings + confirm with FLUX legal.
|
||||||
|
- CQ-10 (low) — repeated `as unknown as` casts on AI SDK responses → define interfaces.
|
||||||
|
- CQ-11 (low) — `i18nHelper.ts:27`: `console.error` → `log.error`.
|
||||||
|
|
||||||
|
**Infra / testing**
|
||||||
|
- INFRA-14 (med) — backup/restore lacks HMAC integrity → sign exports, verify on restore.
|
||||||
|
- INFRA-15 (med) — deploy doesn't verify commit signatures → `git verify-commit HEAD`.
|
||||||
|
- TEST-05 (med) — no i18n parity/fallback tests.
|
||||||
|
- TEST-06 (med) — error boundaries log to console, not the structured logger.
|
||||||
|
- TEST-07 (med) — server-component DB errors bubble ungracefully → per-query try/catch + degraded UI.
|
||||||
|
- TEST-08 (med) — no retry/backoff for transient email/Prisma/OpenAI failures.
|
||||||
|
- TEST-09 (med) — in-memory rate limit multiplies across replicas → require Redis for multi-instance.
|
||||||
|
- TEST-10 (med) — health check doesn't verify migrations/SMTP/OpenAI/env.
|
||||||
|
- TEST-11/12/13 (low) — telemetry truncation unlogged; no polyglot-file upload tests; `restoreDatabase()` partial-failure handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What's Already Strong
|
||||||
|
|
||||||
|
Credit where due — several things are done well and should be preserved:
|
||||||
|
|
||||||
|
- **Public-endpoint security baseline.** CSRF is correctly implemented on public endpoints, rate limiting covers chat, file uploads use magic-byte validation, and the consultation route already escapes HTML correctly (the model SEC-05 should copy). Recent commits added a **canonical-host guard and scanner-probe blocking** in Nginx — good hardening.
|
||||||
|
- **JWT signing discipline.** `session.ts`/`clientAuth.ts` correctly *throw* when `SESSION_SECRET` is missing rather than falling back — this is what saved SEC-01 from being critical.
|
||||||
|
- **i18n infrastructure.** Complete top-level key parity across all 5 locales (18 sections), correct next-intl integration, preserved `{count}`/`{app}` placeholders, and a well-built **AI translation glossary** that masks/restores protected brand/technical terms (FLUX, Radio Frequency, solid-state, RF, MHz/kHz/GHz, kW/kWh/MW). Pluralization (`componentFound`/`componentsFound`) is handled correctly.
|
||||||
|
- **SEO foundations.** Correct hreflang alternates, canonical URLs, breadcrumb JSON-LD on detail pages, and core schemas (Organization, LocalBusiness, WebSite, Article, Product). robots.txt disallow rules are correct; sitemap covers all public routes.
|
||||||
|
- **Caching & DB pooling.** Nginx edge caching, ISR, image optimization, and a real Prisma connection pool are all in place — a solid performance baseline.
|
||||||
|
- **Architecture awareness.** A structured JSON logger, safe-JSON helpers (`parseJsonField`), `escapeHtml`, and proper `cms.ts` types all exist — the gaps are *adoption consistency*, not missing infrastructure. bcrypt is used for password hashing; migrations are clean and idempotent (`IF NOT EXISTS` guards). FluxAI's 9-tool SPIN-funnel architecture is well-designed. Semantic HTML (`main`/`nav`/`header`/`footer`) and dark-mode coverage are largely in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Prioritized Remediation Roadmap
|
||||||
|
|
||||||
|
### NOW — blockers; do before any further production traffic
|
||||||
|
|
||||||
|
| Item | Finding | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| Rename/export middleware so auth actually runs | SEC-02 | **S** |
|
||||||
|
| Add session checks inside HQ server actions | SEC-03 | **M** |
|
||||||
|
| Add auth to `/api/assets` + `/api/branding` | SEC-04 | **S** |
|
||||||
|
| Rotate leaked OpenAI key + Gmail password; untrack `env` | INFRA-03 | **S** |
|
||||||
|
| Create the missing `ClientUser` migration | DB-01 | **S** |
|
||||||
|
| Escape HTML in operations email templates | SEC-05 | **S** |
|
||||||
|
| Whitelist chat context fields (prompt injection) | AI-01 | **S** |
|
||||||
|
| Remove the proxy session-secret fallback | SEC-01 | **S** |
|
||||||
|
|
||||||
|
### NEXT — operational resilience + data integrity
|
||||||
|
|
||||||
|
| Item | Finding | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| Automated nightly offsite encrypted backups + WAL/PITR | INFRA-05 | **M** |
|
||||||
|
| Container memory/CPU limits | INFRA-01 | **S** |
|
||||||
|
| Nginx healthcheck + `condition: service_healthy` | INFRA-02 | **S** |
|
||||||
|
| Deploy: failing health check + snapshot/rollback | INFRA-04 | **M** |
|
||||||
|
| AI telemetry retention policy + cleanup job | DB-05 | **M** |
|
||||||
|
| Encrypt / minimize stored chat message text | AI-03 | **M** |
|
||||||
|
| `buildSystemPrompt()` try/catch + fallback prompt | TEST-02 | **S** |
|
||||||
|
| Validate OpenAI key at startup + wrap `streamText` | TEST-03 | **S** |
|
||||||
|
| Idempotent consultation submission | TEST-04 | **M** |
|
||||||
|
| Cache system prompt (TTL) | PERF-01 | **S** |
|
||||||
|
| Add `GlobalNode.application` indexes | PERF-03 | **S** |
|
||||||
|
| Integration tests for the 4 critical API routes | TEST-01 | **L** |
|
||||||
|
| Monitoring + log aggregation + alerts | INFRA-06 | **M** |
|
||||||
|
| Fix `aria-label`s, skip link, Escape-to-close, reduced-motion, broken nav class | A11Y-02/03/04/05/08 | **M** |
|
||||||
|
| i18n: EnergySavingsCalculator + CartDrawer alert | I18N-01/02/05 | **M** |
|
||||||
|
|
||||||
|
### LATER — hardening, polish, scale
|
||||||
|
|
||||||
|
| Item | Finding | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| Migrate hot JSON `String` fields → `Jsonb` | DB-04 | **L** |
|
||||||
|
| Nonce-based CSP (drop unsafe-inline/eval) | INFRA-11 | **M** |
|
||||||
|
| OCSP stapling, logrotate, body timeout, keepalive tuning | INFRA-07/09/10/13 | **S** |
|
||||||
|
| Image + video sitemaps; VideoObject/FAQ/CaseStudy schema | SEO-02/03/04/10 | **M** |
|
||||||
|
| Reduce `any` usage; extract oversized files; standardize logging | CQ-01/02/04/05 | **L** |
|
||||||
|
| Lazy-load Three.js; trim `use client`; configurable pool | PERF-05/06/07 | **M** |
|
||||||
|
| Conversation history/resume; tool eval set; useChat onError | AI-02/04/10 | **M** |
|
||||||
|
| Remaining a11y (focus trap, labels, contrast, button types) | A11Y-06/07/10/11/12/13/14 | **M** |
|
||||||
|
| Locale-aware number formatting; translate error boundaries | I18N-03/04 | **S** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. False Positives Considered (refuted / materially downgraded)
|
||||||
|
|
||||||
|
These were checked and either refuted or substantially softened during verification — listed so you know they were examined, not missed:
|
||||||
|
|
||||||
|
- **SEC-07 (B2B `/parts` not redirecting)** — *refuted as a security issue.* The page returns 200 but never exposes parts data (empty array), and shows a proper "Access Restricted" locked-state UI. A style/pattern preference, not a leak. Downgraded high → low.
|
||||||
|
- **CQ-07 line-450 "undefined `.score`"** — *refuted.* The `scored` array always initializes `score` (line 415, returned 443); `.score` can't be undefined there. The surrounding `any`-typing concern remains valid; the crash claim does not.
|
||||||
|
- **CQ-03 (lines 600/609 + CaseStudyModal "silent failure")** — *partially refuted.* Lines 600/609 are inside a try/catch with `.w/.h/.d` validation, and CaseStudyModal:189-197 has real try/catch. Only the `sectionsJson`/`advantagesJson` parse at lines 1026-1027 is genuinely unguarded.
|
||||||
|
- **INFRA-08 (cache poisoning)** — *partially refuted.* "Session cookies lost for users" and "sensitive header disclosure" are false (authenticated requests bypass cache via `$cookie_flux_session`). Residual risk is limited to poisoning public pages with non-sensitive cookies.
|
||||||
|
- **INFRA-02 / INFRA-04 (nginx + deploy)** — *softened, not refuted.* The "deadlock"/"no-recovery" framing was overstated (startup race + manual recovery, not hangs); both remain real high-severity gaps.
|
||||||
|
- **INFRA-09 / INFRA-10 (body timeout / logrotate)** — *softened.* nginx's 60s default timeout and ephemeral (unmounted) log storage reduce the blast radius; both downgraded high → medium.
|
||||||
|
- **DB-02 / DB-03 / PERF-02 / A11Y-01 / A11Y-09 / INFRA-11** — *confirmed but downgraded* (see §2) where verification found mitigating context (other models' parity, dropdown-constrained input, ISR-only timing, adjacent visible text, muted decorative video, compensating input-validation controls).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Bottom line, David: the build quality is real, but the auth layer is currently a no-op and the platform has no backups, no monitoring, and live secrets in git. The "NOW" block is mostly small, surgical changes — SEC-02 alone is a one-file fix that re-activates a security layer you already wrote. Land that block first, then the operational resilience in "NEXT," and this moves from "risky to run" to "production-ready" quickly.*
|
||||||
@@ -8,6 +8,46 @@ upstream nextjs {
|
|||||||
keepalive 32;
|
keepalive 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# CANONICAL-HOST GUARD (default_server for ports 80 + 443)
|
||||||
|
# Catches every request NOT addressed to rf-flux.com / www.rf-flux.com —
|
||||||
|
# raw-IP access (135.125.53.234), SSRF probes (Host: 169.254.169.254,
|
||||||
|
# localhost, metadata.google.internal) and the bulk of bot scans that hit
|
||||||
|
# the bare IP. Returns 444 (drop the connection, send nothing).
|
||||||
|
#
|
||||||
|
# Legitimate traffic is unaffected: the rf-flux.com server blocks below win
|
||||||
|
# because an exact server_name match always beats default_server.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Nginx self-health endpoint (served directly, no upstream) — used by the
|
||||||
|
# docker-compose healthcheck. Reachable on 127.0.0.1 inside the container
|
||||||
|
# (no Host match needed, so it lands here on the default_server).
|
||||||
|
location = /nginx-health { return 200 "ok\n"; access_log off; }
|
||||||
|
|
||||||
|
# Keep ACME HTTP-01 working so certbot can still renew on any host.
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||||
|
|
||||||
|
location / { return 444; }
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# A cert is required to complete the TLS handshake before the Host is
|
||||||
|
# known; reuse the rf-flux.com cert, then drop. Bots hitting the IP get
|
||||||
|
# a cert-name mismatch and a closed connection — nothing is proxied.
|
||||||
|
ssl_certificate /etc/letsencrypt/live/rf-flux.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/rf-flux.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
return 444;
|
||||||
|
}
|
||||||
|
|
||||||
# Legacy domain redirect — anyone landing on lethepowerflux.com lands on
|
# Legacy domain redirect — anyone landing on lethepowerflux.com lands on
|
||||||
# the canonical https://www.rf-flux.com host instead. SEO-safe 301.
|
# the canonical https://www.rf-flux.com host instead. SEO-safe 301.
|
||||||
server {
|
server {
|
||||||
@@ -55,6 +95,17 @@ server {
|
|||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
# ── Scanner / exploit-probe blocking ────────────────────────────────
|
||||||
|
# This is a Next.js app — it has no PHP, no .env/.git served over HTTP,
|
||||||
|
# no wp-admin. Any request for those paths is a bot probing for secrets
|
||||||
|
# or exploits. Drop them cheaply with 444 before they touch the app.
|
||||||
|
# The patterns are scanner-specific and never match real assets
|
||||||
|
# (.jpg/.png/.webp/.mp4/.glb/.pdf/.svg) or app routes.
|
||||||
|
location ~* (?:\.(?:php|phtml|asp|aspx|jsp|cgi|env|sql|bak|ini|sh|yml|yaml|conf)$|/\.(?:git|env|aws|ssh|svn|hg|idea|vscode)|/(?:wp-admin|wp-login|wordpress|phpmyadmin|xmlrpc)) {
|
||||||
|
return 444;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
# Next.js bundles use content hashing — safe to cache forever
|
# Next.js bundles use content hashing — safe to cache forever
|
||||||
location /_next/static/ {
|
location /_next/static/ {
|
||||||
proxy_pass http://nextjs;
|
proxy_pass http://nextjs;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — creates the ClientUser table (B2B client portal) and
|
||||||
|
-- wires OperationsSignal.clientId to it. The Prisma schema has defined this
|
||||||
|
-- model + relation for a while, but no migration ever created the table, so
|
||||||
|
-- the B2B register/login flow (src/app/actions/clientAuth.ts) and the
|
||||||
|
-- dashboard client counts were failing at runtime. This backfills it.
|
||||||
|
--
|
||||||
|
-- Idempotent: IF NOT EXISTS / duplicate-object guards make it safe to re-run
|
||||||
|
-- and safe for `migrate deploy` against the existing production DB.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "ClientUser" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"isApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ClientUser_email_key" ON "ClientUser" ("email");
|
||||||
|
|
||||||
|
-- OperationsSignal.clientId — the FK column the schema references. Add it if
|
||||||
|
-- a prior schema state never created it (nullable, so existing rows are fine).
|
||||||
|
ALTER TABLE "OperationsSignal" ADD COLUMN IF NOT EXISTS "clientId" TEXT;
|
||||||
|
|
||||||
|
-- Foreign key OperationsSignal.clientId -> ClientUser.id
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "OperationsSignal"
|
||||||
|
ADD CONSTRAINT "OperationsSignal_clientId_fkey"
|
||||||
|
FOREIGN KEY ("clientId") REFERENCES "ClientUser"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — index GlobalNode(application, isActive).
|
||||||
|
-- The application detail page queries case studies by application slug +
|
||||||
|
-- isActive (the GlobalNode.application -> Application.slug join). Without an
|
||||||
|
-- index this is a full table scan on every application page render.
|
||||||
|
-- Idempotent. Safe for `migrate deploy`.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "GlobalNode_application_isActive_idx"
|
||||||
|
ON "GlobalNode" ("application", "isActive");
|
||||||
@@ -64,6 +64,9 @@ model GlobalNode {
|
|||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
@@index([nodeType])
|
@@index([nodeType])
|
||||||
@@index([nodeType, isActive])
|
@@index([nodeType, isActive])
|
||||||
|
// Case studies on an application page filter by application slug + isActive
|
||||||
|
// (src/app/[locale]/applications/[slug]/page.tsx). Back this join with an index.
|
||||||
|
@@index([application, isActive])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
|
|||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Backup service entrypoint. Runs one backup immediately on start, then loops
|
||||||
|
# every BACKUP_INTERVAL_SECONDS (default 24h). A loop (vs cron) inherits the
|
||||||
|
# container environment cleanly and survives restarts without lost schedules.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}"
|
||||||
|
echo "[backup] service started; interval=${INTERVAL}s, retention=${RETENTION_DAYS:-14}d"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
/usr/local/bin/db-backup.sh || echo "[backup] cycle failed; will retry next interval"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Single Postgres backup: pg_dump -> gzip -> N-day rotation.
|
||||||
|
# Run by scripts/backup-loop.sh inside the `backup` compose service.
|
||||||
|
# Env: DB_USER, DB_PASSWORD, DB_NAME, BACKUP_DIR, RETENTION_DAYS
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-14}"
|
||||||
|
TS=$(date -u +%Y%m%d_%H%M%S)
|
||||||
|
OUT="${BACKUP_DIR}/flux_db_${TS}.sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
export PGPASSWORD="$DB_PASSWORD"
|
||||||
|
|
||||||
|
echo "[backup] $(date -u +%Y-%m-%dT%H:%M:%SZ) starting pg_dump -> ${OUT}"
|
||||||
|
|
||||||
|
# --no-owner/--no-privileges keep the dump portable across roles on restore.
|
||||||
|
if pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges | gzip -9 > "$OUT"; then
|
||||||
|
SIZE=$(du -h "$OUT" | cut -f1)
|
||||||
|
echo "[backup] OK: ${OUT} (${SIZE})"
|
||||||
|
else
|
||||||
|
echo "[backup] FAILED: pg_dump returned non-zero; removing partial file"
|
||||||
|
rm -f "$OUT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rotation — drop dumps older than RETENTION_DAYS.
|
||||||
|
DELETED=$(find "$BACKUP_DIR" -name 'flux_db_*.sql.gz' -mtime +"$RETENTION_DAYS" -print -delete 2>/dev/null | wc -l || echo 0)
|
||||||
|
echo "[backup] rotation: kept last ${RETENTION_DAYS} days, pruned ${DELETED} old dump(s)"
|
||||||
@@ -40,26 +40,26 @@ export default async function PrivacyPage({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative w-full min-h-screen bg-[#F5F5F7]">
|
<main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505]">
|
||||||
<div className="max-w-3xl mx-auto px-6 pt-28 md:pt-36 pb-24">
|
<div className="max-w-3xl mx-auto px-6 pt-28 md:pt-36 pb-24">
|
||||||
<Breadcrumbs items={crumbs} />
|
<Breadcrumbs items={crumbs} />
|
||||||
|
|
||||||
<header className="mt-6 mb-10">
|
<header className="mt-6 mb-10">
|
||||||
<h1 className="text-3xl md:text-5xl font-light text-[#1D1D1F] tracking-tight">
|
<h1 className="text-3xl md:text-5xl font-light text-[#1D1D1F] dark:text-white tracking-tight">
|
||||||
Privacy & Cookie <span className="font-medium">Policy</span>
|
Privacy & Cookie <span className="font-medium">Policy</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-sm text-[#86868B]">Last updated: {LAST_UPDATED}</p>
|
<p className="mt-3 text-sm text-[#86868B] dark:text-[#A1A1A6]">Last updated: {LAST_UPDATED}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Template disclaimer — remove once reviewed by legal counsel */}
|
{/* Template disclaimer — remove once reviewed by legal counsel */}
|
||||||
<div className="mb-10 rounded-2xl border border-amber-300/50 bg-amber-50 p-4 text-sm text-amber-900">
|
<div className="mb-10 rounded-2xl border border-amber-300/50 dark:border-amber-400/30 bg-amber-50 dark:bg-amber-400/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||||
<strong>Template notice:</strong> this is a standard GDPR-compliant
|
<strong>Template notice:</strong> this is a standard GDPR-compliant
|
||||||
template provided as a starting point. Please have it reviewed and
|
template provided as a starting point. Please have it reviewed and
|
||||||
adapted by your legal counsel before relying on it, and confirm the
|
adapted by your legal counsel before relying on it, and confirm the
|
||||||
contact details below.
|
contact details below.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8 text-[#1D1D1F]">
|
<div className="space-y-8 text-[#1D1D1F] dark:text-[#F5F5F7]">
|
||||||
<Section title="1. Who we are">
|
<Section title="1. Who we are">
|
||||||
<P>
|
<P>
|
||||||
{COMPANY} (“we”, “us”, “our”)
|
{COMPANY} (“we”, “us”, “our”)
|
||||||
@@ -69,7 +69,7 @@ export default async function PrivacyPage({
|
|||||||
</P>
|
</P>
|
||||||
<P>
|
<P>
|
||||||
For any privacy-related request you can contact us at{" "}
|
For any privacy-related request you can contact us at{" "}
|
||||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] underline underline-offset-2">
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
|
||||||
{CONTACT_EMAIL}
|
{CONTACT_EMAIL}
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
@@ -155,7 +155,7 @@ export default async function PrivacyPage({
|
|||||||
</ul>
|
</ul>
|
||||||
<P>
|
<P>
|
||||||
To exercise any of these rights, contact us at{" "}
|
To exercise any of these rights, contact us at{" "}
|
||||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] underline underline-offset-2">
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
|
||||||
{CONTACT_EMAIL}
|
{CONTACT_EMAIL}
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
@@ -187,12 +187,12 @@ const SITE = "rf-flux.com";
|
|||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg md:text-xl font-semibold text-[#1D1D1F] mb-3">{title}</h2>
|
<h2 className="text-lg md:text-xl font-semibold text-[#1D1D1F] dark:text-white mb-3">{title}</h2>
|
||||||
<div className="space-y-3 text-[15px] leading-relaxed">{children}</div>
|
<div className="space-y-3 text-[15px] leading-relaxed">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function P({ children }: { children: React.ReactNode }) {
|
function P({ children }: { children: React.ReactNode }) {
|
||||||
return <p className="text-[#3A3A3C]">{children}</p>;
|
return <p className="text-[#3A3A3C] dark:text-[#A1A1A6]">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export default function TeamGrid({ members }: { members: TeamCard[] }) {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-60px" }}
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="group relative flex flex-col rounded-3xl bg-white border border-black/[0.06] shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] transition-all duration-500 overflow-hidden"
|
className="group relative flex flex-col rounded-3xl bg-white dark:bg-[#111] border border-black/[0.06] dark:border-white/10 shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.4)] transition-all duration-500 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Portrait */}
|
{/* Portrait */}
|
||||||
<div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED]">
|
<div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED] dark:from-[#1A1A1C] dark:to-[#0E0E10]">
|
||||||
{m.photoUrl ? (
|
{m.photoUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={m.photoUrl}
|
src={m.photoUrl}
|
||||||
@@ -50,13 +50,13 @@ export default function TeamGrid({ members }: { members: TeamCard[] }) {
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex flex-col flex-1 p-6">
|
<div className="flex flex-col flex-1 p-6">
|
||||||
<h3 className="text-lg font-semibold text-[#1D1D1F] tracking-tight">{m.name}</h3>
|
<h3 className="text-lg font-semibold text-[#1D1D1F] dark:text-white tracking-tight">{m.name}</h3>
|
||||||
<p className="text-[#0066CC] text-xs font-medium uppercase tracking-[0.12em] mt-1">
|
<p className="text-[#0066CC] dark:text-[#00F0FF] text-xs font-medium uppercase tracking-[0.12em] mt-1">
|
||||||
{m.role}
|
{m.role}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{m.bio && (
|
{m.bio && (
|
||||||
<p className="mt-4 text-sm leading-relaxed text-[#6E6E73] line-clamp-5">{m.bio}</p>
|
<p className="mt-4 text-sm leading-relaxed text-[#6E6E73] dark:text-[#A1A1A6] line-clamp-5">{m.bio}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Social links — only the ones that exist */}
|
{/* Social links — only the ones that exist */}
|
||||||
@@ -106,7 +106,7 @@ function SocialLink({
|
|||||||
title={label}
|
title={label}
|
||||||
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
onClick={() => trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })}
|
onClick={() => trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })}
|
||||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] text-[#6E6E73] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] transition-colors"
|
className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] dark:border-white/15 text-[#6E6E73] dark:text-[#A1A1A6] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] dark:hover:bg-[#00F0FF] dark:hover:text-black dark:hover:border-[#00F0FF] transition-colors"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default async function TeamPage({ params }: { params: Promise<{ locale: s
|
|||||||
<>
|
<>
|
||||||
{personSchemas.length > 0 && <JsonLd data={personSchemas} />}
|
{personSchemas.length > 0 && <JsonLd data={personSchemas} />}
|
||||||
|
|
||||||
<main className="relative w-full min-h-screen bg-[#F5F5F7] overflow-hidden">
|
<main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505] overflow-hidden">
|
||||||
{/* Ambient visual, consistent with the News / Heritage hubs */}
|
{/* Ambient visual, consistent with the News / Heritage hubs */}
|
||||||
<div className="absolute inset-0 opacity-60 pointer-events-none">
|
<div className="absolute inset-0 opacity-60 pointer-events-none">
|
||||||
<BreathingField />
|
<BreathingField />
|
||||||
@@ -86,20 +86,20 @@ export default async function TeamPage({ params }: { params: Promise<{ locale: s
|
|||||||
<Breadcrumbs items={crumbs} />
|
<Breadcrumbs items={crumbs} />
|
||||||
|
|
||||||
<header className="max-w-3xl mt-6 mb-16 md:mb-24">
|
<header className="max-w-3xl mt-6 mb-16 md:mb-24">
|
||||||
<p className="text-[#0066CC] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4">
|
<p className="text-[#0066CC] dark:text-[#00F0FF] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4">
|
||||||
{t("eyebrow")}
|
{t("eyebrow")}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] tracking-tight leading-[1.05]">
|
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] dark:text-white tracking-tight leading-[1.05]">
|
||||||
{t("title1")}{" "}
|
{t("title1")}{" "}
|
||||||
<span className="font-medium">{t("title2")}</span>
|
<span className="font-medium">{t("title2")}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-base md:text-lg text-[#6E6E73] leading-relaxed max-w-2xl">
|
<p className="mt-6 text-base md:text-lg text-[#6E6E73] dark:text-[#A1A1A6] leading-relaxed max-w-2xl">
|
||||||
{t("description")}
|
{t("description")}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<div className="text-center py-24 text-[#86868B]">
|
<div className="text-center py-24 text-[#86868B] dark:text-[#A1A1A6]">
|
||||||
<p>{t("empty")}</p>
|
<p>{t("empty")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { sendEmail } from "@/lib/mailer";
|
||||||
|
import { escapeHtml, escapeAttr, safeMailto } from "@/lib/escapeHtml";
|
||||||
|
|
||||||
// ── Helper: Generate sequential ticket ID ──
|
// ── Helper: Generate sequential ticket ID ──
|
||||||
async function generateTicketId(type: string): Promise<string> {
|
async function generateTicketId(type: string): Promise<string> {
|
||||||
@@ -91,7 +92,10 @@ export async function submitOperationsSignal(payload: {
|
|||||||
replyTo: payload.clientEmail,
|
replyTo: payload.clientEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track email delivery in DB
|
// Track email delivery — best-effort. The signal (lead) is already saved,
|
||||||
|
// so a telemetry-update hiccup must NOT fail the request and make the
|
||||||
|
// client retry into a duplicate.
|
||||||
|
try {
|
||||||
await prisma.operationsSignal.update({
|
await prisma.operationsSignal.update({
|
||||||
where: { id: signal.id },
|
where: { id: signal.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -100,8 +104,11 @@ export async function submitOperationsSignal(payload: {
|
|||||||
emailError: emailResult.error,
|
emailError: emailResult.error,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (trackErr) {
|
||||||
|
console.warn("[operations] email tracking update failed (lead already saved):", trackErr);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, ticketId, emailSent: emailResult.success };
|
return { success: true, ticketId, emailSent: emailResult.success, emailError: emailResult.error };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting signal:", error);
|
console.error("Error submitting signal:", error);
|
||||||
return { error: "Failed to submit request. Please try again." };
|
return { error: "Failed to submit request. Please try again." };
|
||||||
@@ -119,16 +126,20 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
|
|||||||
const cartRows = cartItems.map((item: any) => `
|
const cartRows = cartItems.map((item: any) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;">
|
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;">
|
||||||
<strong style="color: #1D1D1F; font-size: 14px;">${item.title}</strong><br/>
|
<strong style="color: #1D1D1F; font-size: 14px;">${escapeHtml(item.title)}</strong><br/>
|
||||||
<span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${item.sku}</span>
|
<span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${escapeHtml(item.sku)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${item.quantity}</td>
|
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${escapeHtml(item.quantity)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
const fileLinks = files.map((fileUrl: string) => {
|
const fileLinks = files
|
||||||
|
// Only accept internal paths (start with a single "/"); ignore anything
|
||||||
|
// that could point off-site or break out of the href attribute.
|
||||||
|
.filter((fileUrl: string) => typeof fileUrl === 'string' && /^\/[^/]/.test(fileUrl))
|
||||||
|
.map((fileUrl: string) => {
|
||||||
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov');
|
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov');
|
||||||
return `<a href="${appUrl}${fileUrl}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`;
|
return `<a href="${escapeAttr(appUrl + fileUrl)}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -138,21 +149,21 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
|
|||||||
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
|
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
|
||||||
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
|
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
|
||||||
<p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p>
|
<p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p>
|
||||||
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${payload.type} Signal</h1>
|
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${escapeHtml(payload.type)} Signal</h1>
|
||||||
<span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${ticketId}</span>
|
<span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${escapeHtml(ticketId)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 32px; color: #1D1D1F;">
|
<div style="padding: 32px; color: #1D1D1F;">
|
||||||
<div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;">
|
<div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;">
|
||||||
<p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${payload.clientName}</strong> · ${payload.clientCompany}</p>
|
<p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${escapeHtml(payload.clientName)}</strong> · ${escapeHtml(payload.clientCompany)}</p>
|
||||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="mailto:${payload.clientEmail}" style="color: #0066CC; text-decoration: none;">${payload.clientEmail}</a></p>
|
<p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="${escapeAttr(safeMailto(payload.clientEmail))}" style="color: #0066CC; text-decoration: none;">${escapeHtml(payload.clientEmail)}</a></p>
|
||||||
<p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone || 'N/A'}</p>
|
<p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone ? escapeHtml(payload.clientPhone) : 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${payload.message ? `
|
${payload.message ? `
|
||||||
<div style="margin-bottom: 32px;">
|
<div style="margin-bottom: 32px;">
|
||||||
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3>
|
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3>
|
||||||
<div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${payload.message}</div>
|
<div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${escapeHtml(payload.message)}</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
|
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
|
||||||
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
|
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
|
||||||
|
import { getAdminSession } from "@/lib/session";
|
||||||
|
|
||||||
|
// All asset operations are admin-only. The middleware (src/proxy.ts) does NOT
|
||||||
|
// cover /api, so each handler must verify the admin session itself.
|
||||||
|
async function requireAdmin(): Promise<NextResponse | null> {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const SCOPE_ROOTS: Record<string, string> = {
|
const SCOPE_ROOTS: Record<string, string> = {
|
||||||
applications: path.join(process.cwd(), "public", "applications"),
|
applications: path.join(process.cwd(), "public", "applications"),
|
||||||
@@ -123,6 +132,7 @@ function buildBreadcrumbs(subPath: string) {
|
|||||||
|
|
||||||
// GET — List files and folders
|
// GET — List files and folders
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const scope = searchParams.get("scope") || "applications";
|
const scope = searchParams.get("scope") || "applications";
|
||||||
@@ -198,6 +208,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// produces a different hash, so the browser cache invalidates instantly
|
// produces a different hash, so the browser cache invalidates instantly
|
||||||
// without any header trickery.
|
// without any header trickery.
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const scope = (formData.get("scope") as string) || "applications";
|
const scope = (formData.get("scope") as string) || "applications";
|
||||||
@@ -280,6 +291,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// PUT — Create a new folder
|
// PUT — Create a new folder
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
|
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
|
||||||
@@ -315,6 +327,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
// { scope, slug, filePath: "..." } single delete
|
// { scope, slug, filePath: "..." } single delete
|
||||||
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
|
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { scope = "applications", slug = "", filePath, filePaths } = body;
|
const { scope = "applications", slug = "", filePath, filePaths } = body;
|
||||||
@@ -371,6 +384,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
// Cannot overwrite an existing file (returns 409). Sanitises target name
|
// Cannot overwrite an existing file (returns 409). Sanitises target name
|
||||||
// the same way upload does, and creates intermediate folders if needed.
|
// the same way upload does, and creates intermediate folders if needed.
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { scope = "applications", slug = "", fromPath, toPath } = body;
|
const { scope = "applications", slug = "", fromPath, toPath } = body;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import sharp from "sharp";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { revalidateContent } from "@/lib/revalidate";
|
import { revalidateContent } from "@/lib/revalidate";
|
||||||
|
import { getAdminSession } from "@/lib/session";
|
||||||
|
|
||||||
interface VariantSpec {
|
interface VariantSpec {
|
||||||
size: number;
|
size: number;
|
||||||
@@ -48,6 +49,9 @@ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB cap
|
|||||||
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
// Admin-only: the middleware does not cover /api, so guard here.
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
|
|||||||
@@ -39,9 +39,24 @@ const COMPARISON_DATA: Record<string, { rf: number; traditional: number; unit: s
|
|||||||
// ─── DYNAMIC SYSTEM PROMPT BUILDER ──────────────────────────────
|
// ─── DYNAMIC SYSTEM PROMPT BUILDER ──────────────────────────────
|
||||||
// Injects real-time database context so the AI knows what exists
|
// Injects real-time database context so the AI knows what exists
|
||||||
|
|
||||||
|
// Cache the built prompt briefly so we don't run 4 DB queries on every single
|
||||||
|
// chat message. CMS changes appear within the TTL. Only healthy builds are
|
||||||
|
// cached, so a transient DB outage retries on the next message.
|
||||||
|
let _promptCache: { value: string; at: number } | null = null;
|
||||||
|
const SYSTEM_PROMPT_TTL_MS = 60_000;
|
||||||
|
|
||||||
async function buildSystemPrompt(): Promise<string> {
|
async function buildSystemPrompt(): Promise<string> {
|
||||||
// Query real data from Prisma
|
if (_promptCache && Date.now() - _promptCache.at < SYSTEM_PROMPT_TTL_MS) {
|
||||||
const [activeApps, installationCount, eventCount, partsCount] = await Promise.all([
|
return _promptCache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live DB context. If Postgres is unreachable, fall back to safe defaults so
|
||||||
|
// the assistant still answers (degraded) instead of 500-ing the whole chat.
|
||||||
|
let activeApps: Array<{ slug: string; title: string; shortDescription: string; category: string }> = [];
|
||||||
|
let installationCount = 0, eventCount = 0, partsCount = 0;
|
||||||
|
let dbOk = true;
|
||||||
|
try {
|
||||||
|
[activeApps, installationCount, eventCount, partsCount] = await Promise.all([
|
||||||
prisma.application.findMany({
|
prisma.application.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
select: { slug: true, title: true, shortDescription: true, category: true },
|
select: { slug: true, title: true, shortDescription: true, category: true },
|
||||||
@@ -51,10 +66,16 @@ async function buildSystemPrompt(): Promise<string> {
|
|||||||
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
|
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
|
||||||
prisma.sparePart.count({ where: { isActive: true } }),
|
prisma.sparePart.count({ where: { isActive: true } }),
|
||||||
]);
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
dbOk = false;
|
||||||
|
log.warn('chat.system_prompt_db_unavailable', { err: String(e) });
|
||||||
|
}
|
||||||
|
|
||||||
const appList = activeApps.map((a: any) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n');
|
const appList = activeApps.length
|
||||||
|
? activeApps.map((a) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n')
|
||||||
|
: ' (live catalog temporarily unavailable — describe FLUX applications from general RF knowledge)';
|
||||||
|
|
||||||
return `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy.
|
const prompt = `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy.
|
||||||
|
|
||||||
PERSONALITY:
|
PERSONALITY:
|
||||||
- Senior RF engineer who also understands business ROI.
|
- Senior RF engineer who also understands business ROI.
|
||||||
@@ -143,6 +164,10 @@ PROACTIVE NEXT STEPS (always suggest the next logical action):
|
|||||||
comparison → "Let me quantify the difference for your specific operation..." → energy_savings_calculator
|
comparison → "Let me quantify the difference for your specific operation..." → energy_savings_calculator
|
||||||
|
|
||||||
LANGUAGE: Respond in the exact same language the user writes in.`;
|
LANGUAGE: Respond in the exact same language the user writes in.`;
|
||||||
|
|
||||||
|
// Only cache a healthy build so a transient DB outage retries next message.
|
||||||
|
if (dbOk) _promptCache = { value: prompt, at: Date.now() };
|
||||||
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── HELPER: Parse JSON safely ──────────────────────────────────
|
// ─── HELPER: Parse JSON safely ──────────────────────────────────
|
||||||
@@ -198,6 +223,17 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Fail fast if the AI provider isn't configured ─────────────
|
||||||
|
// Without this, a missing/invalid key surfaces mid-stream after headers
|
||||||
|
// are already sent, producing a confusing broken response.
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
log.error("chat.openai_key_missing", new Error("OPENAI_API_KEY is not set"));
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "The AI assistant is temporarily unavailable. Please try again later." }),
|
||||||
|
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
context,
|
context,
|
||||||
@@ -287,6 +323,20 @@ export async function POST(req: Request) {
|
|||||||
system: systemPrompt + contextNote,
|
system: systemPrompt + contextNote,
|
||||||
messages: coreMessages,
|
messages: coreMessages,
|
||||||
providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
|
providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
|
||||||
|
// Surface streaming/provider errors (OpenAI 429/500, bad key) in the logs
|
||||||
|
// and, when possible, persist them to the conversation timeline.
|
||||||
|
onError: ({ error }) => {
|
||||||
|
log.error("chat.stream_error", error, { conversationId: conversationId ?? undefined });
|
||||||
|
if (conversationId) {
|
||||||
|
prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: "error",
|
||||||
|
payloadJson: JSON.stringify({ message: error instanceof Error ? error.message : String(error) }).slice(0, 2000),
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
onFinish: async ({ usage, toolCalls, toolResults }) => {
|
onFinish: async ({ usage, toolCalls, toolResults }) => {
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ export async function POST(request: NextRequest) {
|
|||||||
replyTo: contact.email,
|
replyTo: contact.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Best-effort email tracking — the lead is already saved; never fail the
|
||||||
|
// request (and risk a client retry / duplicate) over a telemetry update.
|
||||||
|
try {
|
||||||
await prisma.operationsSignal.update({
|
await prisma.operationsSignal.update({
|
||||||
where: { id: signal.id },
|
where: { id: signal.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -153,6 +156,9 @@ export async function POST(request: NextRequest) {
|
|||||||
emailError: emailResult.error,
|
emailError: emailResult.error,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (trackErr) {
|
||||||
|
log.warn("consultation.email_tracking_failed", { ticketId, err: String(trackErr) });
|
||||||
|
}
|
||||||
|
|
||||||
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
|
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors">
|
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors">
|
||||||
Consultation Requested
|
Request Sent
|
||||||
</p>
|
</p>
|
||||||
{ticketId && (
|
{ticketId && (
|
||||||
<p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p>
|
<p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p>
|
||||||
@@ -188,7 +188,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{[
|
{[
|
||||||
"Engineer reviews your AI-prepared brief",
|
"Our team reviews your AI-prepared brief",
|
||||||
`Custom RF analysis for your ${data.process} process`,
|
`Custom RF analysis for your ${data.process} process`,
|
||||||
"Proposal with ROI projections and timeline",
|
"Proposal with ROI projections and timeline",
|
||||||
].map((step, i) => (
|
].map((step, i) => (
|
||||||
@@ -315,11 +315,11 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
<Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
<Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
|
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
|
||||||
Engineering Consultation
|
Get in Touch
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed">
|
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed">
|
||||||
Your conversation details are pre-loaded. Just add your contact info.
|
Your conversation details are pre-loaded. Just add your contact info and our team will get back to you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<InsightsCard data={data} />
|
<InsightsCard data={data} />
|
||||||
@@ -416,7 +416,7 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
Sending...
|
Sending...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Request Consultation <ArrowRight size={14} /></>
|
<>Send Request <ArrowRight size={14} /></>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,22 @@
|
|||||||
|
|
||||||
import { ArrowUpRight } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
|
||||||
// 1. El botón principal (Contact Engineering)
|
// 1. El botón principal (Contact FLUX Team)
|
||||||
|
// Broadened from "Contact FLUX Engineering" so it reads as an invitation for
|
||||||
|
// ANY enquiry — pricing, general questions, a demo, partnerships — not only
|
||||||
|
// technical/engineering consultations.
|
||||||
export function AiContactButton() {
|
export function AiContactButton() {
|
||||||
const handleContactEngineering = () => {
|
const handleContact = () => {
|
||||||
const prompt = "I am ready to optimize my production. I would like to schedule a technical consultation with FLUX Engineering to explore custom RF solutions and calculate my ROI.";
|
const prompt = "I'd like to get in touch with the FLUX team. I have a few questions and would like to explore how FLUX can help — whether that's energy savings, a custom solution, pricing, availability, or just learning more.";
|
||||||
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleContactEngineering}
|
onClick={handleContact}
|
||||||
className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300"
|
className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300"
|
||||||
>
|
>
|
||||||
Contact FLUX Engineering <ArrowUpRight size={18} />
|
Contact FLUX Team <ArrowUpRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-4
@@ -4,14 +4,22 @@ import { cookies } from "next/headers";
|
|||||||
// SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
|
// SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
|
||||||
// forge a 7-day admin JWT if the env var ever fails to load in production.
|
// forge a 7-day admin JWT if the env var ever fails to load in production.
|
||||||
// Generate a strong value with: openssl rand -base64 48
|
// Generate a strong value with: openssl rand -base64 48
|
||||||
const secretKey = process.env.SESSION_SECRET;
|
//
|
||||||
if (!secretKey || secretKey.length < 32) {
|
// Validated LAZILY (inside getEncodedKey, not at module load). `next build`
|
||||||
|
// imports this module during page-data collection without runtime env vars,
|
||||||
|
// so a top-level throw would break the build. At runtime, any attempt to
|
||||||
|
// create a session without a valid secret still throws loudly — the secret
|
||||||
|
// is never defaulted, so admin JWTs can't be forged.
|
||||||
|
function getEncodedKey(): Uint8Array {
|
||||||
|
const secretKey = process.env.SESSION_SECRET;
|
||||||
|
if (!secretKey || secretKey.length < 32) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"SESSION_SECRET environment variable is required (min 32 chars). " +
|
"SESSION_SECRET environment variable is required (min 32 chars). " +
|
||||||
"Generate one with: openssl rand -base64 48"
|
"Generate one with: openssl rand -base64 48"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(secretKey);
|
||||||
}
|
}
|
||||||
const encodedKey = new TextEncoder().encode(secretKey);
|
|
||||||
|
|
||||||
export async function createSession(userId: string, username: string) {
|
export async function createSession(userId: string, username: string) {
|
||||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días de duración
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días de duración
|
||||||
@@ -21,7 +29,7 @@ export async function createSession(userId: string, username: string) {
|
|||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime("7d")
|
.setExpirationTime("7d")
|
||||||
.sign(encodedKey);
|
.sign(getEncodedKey());
|
||||||
|
|
||||||
// En Next.js 15+, cookies() es una Promesa
|
// En Next.js 15+, cookies() es una Promesa
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -38,3 +46,19 @@ export async function deleteSession() {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete("flux_session");
|
cookieStore.delete("flux_session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the current admin session (the "flux_session" JWT set at HQ login).
|
||||||
|
// Returns the payload, or null when there is no valid session. Use this to
|
||||||
|
// guard API route handlers under /api that the middleware does NOT cover
|
||||||
|
// (the proxy matcher excludes /api).
|
||||||
|
export async function getAdminSession(): Promise<{ userId: string; username: string } | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get("flux_session")?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, getEncodedKey());
|
||||||
|
return payload as { userId: string; username: string };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export const DEFAULT_FOOTER: FooterSettings = {
|
|||||||
ctaTitle1: "Ready to optimize",
|
ctaTitle1: "Ready to optimize",
|
||||||
ctaTitle2: "your production?",
|
ctaTitle2: "your production?",
|
||||||
ctaSubtitle:
|
ctaSubtitle:
|
||||||
"Connect with our engineering team to calculate your ROI and explore custom RF solutions.",
|
"Connect with our team to calculate your ROI, explore solutions, or simply ask a question.",
|
||||||
hqAddress: "Via Benedetto Marcello 32",
|
hqAddress: "Via Benedetto Marcello 32",
|
||||||
hqCity: "36060 Romano d'Ezzelino",
|
hqCity: "36060 Romano d'Ezzelino",
|
||||||
hqRegion: "Vicenza",
|
hqRegion: "Vicenza",
|
||||||
|
|||||||
+12
-4
@@ -9,9 +9,17 @@ import { routing } from './i18n/routing';
|
|||||||
// Configuramos el proxy de next-intl
|
// Configuramos el proxy de next-intl
|
||||||
const handleI18nRouting = createIntlMiddleware(routing);
|
const handleI18nRouting = createIntlMiddleware(routing);
|
||||||
|
|
||||||
// 🔒 2. Llave de seguridad del CMS
|
// 🔒 2. Llave de seguridad del CMS.
|
||||||
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
|
// No fallback: a public default would let an attacker forge admin JWTs if the
|
||||||
const encodedKey = new TextEncoder().encode(secretKey);
|
// env var ever failed to load. Validated lazily (the middleware runs at
|
||||||
|
// runtime where SESSION_SECRET is always present), mirroring src/lib/session.ts.
|
||||||
|
function getEncodedKey(): Uint8Array {
|
||||||
|
const secretKey = process.env.SESSION_SECRET;
|
||||||
|
if (!secretKey || secretKey.length < 32) {
|
||||||
|
throw new Error("SESSION_SECRET environment variable is required (min 32 chars).");
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥
|
// 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
@@ -32,7 +40,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
// Verificamos el pase
|
// Verificamos el pase
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
try {
|
try {
|
||||||
await jwtVerify(cookie, encodedKey);
|
await jwtVerify(cookie, getEncodedKey());
|
||||||
// Si tiene pase y está en el login, lo mandamos al dashboard
|
// Si tiene pase y está en el login, lo mandamos al dashboard
|
||||||
if (isPublicHQRoute) {
|
if (isPublicHQRoute) {
|
||||||
return NextResponse.redirect(new URL('/hq-command/dashboard', request.url));
|
return NextResponse.redirect(new URL('/hq-command/dashboard', request.url));
|
||||||
|
|||||||
Reference in New Issue
Block a user