From 6e46808c27c77653a5cc3f1dc383025e6734f378 Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Mon, 4 May 2026 09:27:46 -0500 Subject: [PATCH] fix: instant CMS uploads + heritage dark/light + ISR caching Eliminates the need to run "docker compose build" after uploading images via HQ Command. Heritage page now respects light/dark mode. CACHE INVALIDATION - New helper src/lib/revalidate.ts called from /api/assets and /api/public-upload after every upload, delete, folder create - Pages switch from force-dynamic to ISR with revalidate=60 (regenerated on demand whenever content changes, plus 60s safety) - Nginx now sends "max-age=300, must-revalidate" instead of "expires 30d" on /cases/, /applications/, /news/, /parts/, /footage/, /operations-inbox/ so browsers revalidate via If-Modified-Since (304s on unchanged files) - Next.js Image Optimizer aligned with same TTL via minimumCacheTTL=300 and adds /_next/image location block in Nginx for correct headers HERITAGE DARK/LIGHT FIX (Bug #8) - Replaces hardcoded #0A0A0C / #00F0FF / text-white with proper light + dark variants throughout markdown renderer (tables, lists, headings, blockquotes, paragraphs, images) - Hero section, navigation pill, and CMS-driven sections now switch with the global theme toggle SECURITY HARDENING - Server actions bodySizeLimit reduced from 500MB to 50MB (large uploads still go through /api/assets which uses Nginx 500MB cap) DEPLOY NOTES - Run on VPS: git pull docker compose up -d --build app docker compose exec nginx nginx -s reload - No DB schema changes in this commit. Existing 2FA users / data untouched. --- .gitignore | 4 + next.config.ts | 10 +- nginx/conf.d/flux.conf | 40 ++++--- src/app/[locale]/applications/[slug]/page.tsx | 5 +- src/app/[locale]/heritage/page.tsx | 58 +++++----- src/app/[locale]/news/[slug]/page.tsx | 3 +- src/app/[locale]/news/page.tsx | 7 +- src/app/[locale]/page.tsx | 4 +- src/app/[locale]/parts/page.tsx | 7 +- src/app/api/assets/route.ts | 8 ++ src/app/api/public-upload/route.ts | 10 +- src/lib/revalidate.ts | 102 ++++++++++++++++++ 12 files changed, 198 insertions(+), 60 deletions(-) create mode 100644 src/lib/revalidate.ts diff --git a/.gitignore b/.gitignore index a31ee31..10fc9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,7 @@ public/news/ public/parts/ public/operations-inbox/ public/footage/ + +# Local Claude Code / MCP config — agent-specific, not project +.mcp.json +.claude/ diff --git a/next.config.ts b/next.config.ts index 10d5b70..03bf14d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,14 +4,20 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig = { output: "standalone" as const, - images: { + images: { qualities: [75, 90, 100], + // Image Optimizer cache TTL — keeps optimized variants for 5 min, + // matching Nginx max-age. Picks up replaced source files quickly. + minimumCacheTTL: 300, + formats: ['image/avif', 'image/webp'] as ('image/avif' | 'image/webp')[], }, reactStrictMode: true, serverExternalPackages: ['nodemailer'], experimental: { serverActions: { - bodySizeLimit: '500mb' as const, + // 50MB cap — large enough for hero images and CMS uploads, + // small enough to limit DoS surface. Use /api/assets for big files. + bodySizeLimit: '50mb' as const, }, }, }; diff --git a/nginx/conf.d/flux.conf b/nginx/conf.d/flux.conf index 7a352ab..83bf6c7 100644 --- a/nginx/conf.d/flux.conf +++ b/nginx/conf.d/flux.conf @@ -36,6 +36,7 @@ server { add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + # Next.js bundles use content hashing — safe to cache forever location /_next/static/ { proxy_pass http://nextjs; expires 365d; @@ -43,6 +44,17 @@ server { access_log off; } + # Next.js image optimizer — short cache, browser revalidates + location /_next/image { + proxy_pass http://nextjs; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + add_header Cache-Control "public, max-age=300, must-revalidate" always; + } + location /hq-command/login { limit_req zone=login burst=10 nodelay; proxy_pass http://nextjs; @@ -104,46 +116,50 @@ server { access_log off; } - # Serve uploaded assets directly from disk (bypass Next.js) + # ───────────────────────────────────────────────────────────────── + # User-uploaded assets — served directly from disk (bypass Next.js) + # + # Cache strategy: short max-age + must-revalidate. + # Browser caches for 5 minutes, then asks Nginx "did this change?" + # via If-Modified-Since. Nginx auto-replies 304 (Not Modified) if the + # file's mtime is unchanged, or serves the new file if it changed. + # This means new CMS uploads appear within ~5 min without rebuild + # AND saved bandwidth on unchanged files. + # ───────────────────────────────────────────────────────────────── location /cases/ { alias /srv/cases/; - expires 30d; - add_header Cache-Control "public"; + add_header Cache-Control "public, max-age=300, must-revalidate" always; access_log off; } location /applications/ { alias /srv/applications/; - expires 30d; - add_header Cache-Control "public"; + add_header Cache-Control "public, max-age=300, must-revalidate" always; access_log off; } location /news/ { alias /srv/news/; - expires 30d; - add_header Cache-Control "public"; + add_header Cache-Control "public, max-age=300, must-revalidate" always; access_log off; } location /parts/ { alias /srv/parts/; - expires 30d; - add_header Cache-Control "public"; + add_header Cache-Control "public, max-age=300, must-revalidate" always; access_log off; } location /operations-inbox/ { alias /srv/operations-inbox/; - expires 7d; + add_header Cache-Control "private, max-age=60, must-revalidate" always; access_log off; } location /footage/ { alias /srv/footage/; - expires 30d; - add_header Cache-Control "public"; + add_header Cache-Control "public, max-age=300, must-revalidate" always; access_log off; } diff --git a/src/app/[locale]/applications/[slug]/page.tsx b/src/app/[locale]/applications/[slug]/page.tsx index 9b80035..c3cbf36 100644 --- a/src/app/[locale]/applications/[slug]/page.tsx +++ b/src/app/[locale]/applications/[slug]/page.tsx @@ -1,4 +1,5 @@ -export const dynamic = "force-dynamic"; +// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. +export const revalidate = 60; import Link from "next/link"; import fs from "fs"; @@ -46,8 +47,6 @@ export async function generateStaticParams() { } } -export const revalidate = 60; - // 🔥 AHORA RECIBIMOS EL LOCALE DESDE LA URL export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) { const resolvedParams = await params; diff --git a/src/app/[locale]/heritage/page.tsx b/src/app/[locale]/heritage/page.tsx index 6d35fd1..b79b262 100644 --- a/src/app/[locale]/heritage/page.tsx +++ b/src/app/[locale]/heritage/page.tsx @@ -1,4 +1,5 @@ -export const dynamic = "force-dynamic"; +// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. +export const revalidate = 60; import Link from "next/link"; import Image from "next/image"; @@ -28,21 +29,21 @@ const renderMarkdown = (text: string) => { if (inTable) { elements.push(
- +
- + {tableHeaders.map((th, i) => ( - ))} - + {tableRows.map((row, rIdx) => ( - + {row.map((cell, cIdx) => ( - ))} @@ -62,11 +63,11 @@ const renderMarkdown = (text: string) => { if (listItems.length > 0) { elements.push( isOrderedList ? ( -
    +
      {listItems}
    ) : ( -
      +
        {listItems}
      ) @@ -81,11 +82,11 @@ const renderMarkdown = (text: string) => { let parts = str.split(boldRegex); return parts.map((part, i) => { - if (i % 2 === 1) return {part}; - + if (i % 2 === 1) return {part}; + let subParts = part.split(italicRegex); return subParts.map((subPart, j) => { - if (j % 2 === 1) return {subPart}; + if (j % 2 === 1) return {subPart}; return subPart; }); }); @@ -119,7 +120,7 @@ const renderMarkdown = (text: string) => { if (imgMatch) { pushList(); pushTable(); elements.push( -
      +
      {imgMatch[1]}
      ); @@ -127,19 +128,19 @@ const renderMarkdown = (text: string) => { } const h3Match = trimmed.match(/^###\s*(.*)/); - if (h3Match) { pushList(); pushTable(); elements.push(

      {parseInline(h3Match[1])}

      ); return; } + if (h3Match) { pushList(); pushTable(); elements.push(

      {parseInline(h3Match[1])}

      ); return; } const h2Match = trimmed.match(/^##\s*(.*)/); - if (h2Match) { pushList(); pushTable(); elements.push(

      {parseInline(h2Match[1])}

      ); return; } + if (h2Match) { pushList(); pushTable(); elements.push(

      {parseInline(h2Match[1])}

      ); return; } const h1Match = trimmed.match(/^#\s*(.*)/); - if (h1Match) { pushList(); pushTable(); elements.push(

      {parseInline(h1Match[1])}

      ); return; } + if (h1Match) { pushList(); pushTable(); elements.push(

      {parseInline(h1Match[1])}

      ); return; } const quoteMatch = trimmed.match(/^>\s*(.*)/); if (quoteMatch) { pushList(); pushTable(); elements.push( -
      +
      {parseInline(quoteMatch[1])}
      ); @@ -162,7 +163,7 @@ const renderMarkdown = (text: string) => { pushList(); elements.push( -

      +

      {parseInline(trimmed)}

      ); @@ -195,12 +196,12 @@ export default async function HeritagePage({ params }: { params: Promise<{ local const sections = rawSections.map(sec => getLocalizedData(sec, locale)); return ( -
      +
      {/* NAVEGACIÓN FLOTANTE */}
      - + {t("backToOverview")}
      @@ -209,10 +210,10 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
      - + {t("subtitle")} -

      +

      {t("title1")}
      {t("title2")}

      @@ -225,27 +226,24 @@ export default async function HeritagePage({ params }: { params: Promise<{ local ) : ( sections.map((sec) => (
      - + {/* El título ya viene traducido */} - {sec.title &&

      {sec.title}

      } - - {/* 🔥 BLOQUE DE TEXTO CON SÚPER MARKDOWN 🔥 */} + {sec.title &&

      {sec.title}

      } + {sec.type === 'text' && (
      {renderMarkdown(sec.content || "")}
      )} - {/* 🔥 BLOQUE DE IMAGEN GIGANTE 🔥 */} {sec.type === 'image' && sec.mediaUrl && ( -
      +
      )} - {/* 🔥 BLOQUE DE VIDEO NATIVO (MP4 LOCAL) — AUTOPLAY ON SCROLL 🔥 */} {sec.type === 'video' && sec.mediaUrl && ( -
      +
      }) { const resolvedParams = await params; diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index d7bd18a..cbc17cc 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -15,7 +15,9 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep"; import HeroReel from "@/components/sections/HeroReel"; import WhatWeDo from "@/components/sections/WhatWeDo"; -export const dynamic = "force-dynamic"; +// ISR: page is statically generated, but revalidates on demand via +// revalidatePath() after CMS uploads, plus a 60s safety window. +export const revalidate = 60; // ✅ Next.js 16: params es Promise y DEBE ser awaiteado export default async function Home({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/[locale]/parts/page.tsx b/src/app/[locale]/parts/page.tsx index f7c5cc3..ce2f3fa 100644 --- a/src/app/[locale]/parts/page.tsx +++ b/src/app/[locale]/parts/page.tsx @@ -1,14 +1,13 @@ -export const dynamic = "force-dynamic"; - import { prisma } from "@/lib/prisma"; import { getLocalizedData } from "@/lib/i18nHelper"; import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; -import ComponentGrid from "./_components/ComponentGrid"; +import ComponentGrid from "./_components/ComponentGrid"; import { Metadata } from "next"; import { getClientSession } from "@/app/actions/clientAuth"; -export const revalidate = 0; +// B2B portal — auth-gated, never cached. +export const revalidate = 0; export const metadata: Metadata = { title: "Component Matrix | FLUX", diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 4e8fc3e..21c9451 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -30,6 +30,7 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; +import { revalidateContent, type RevalidateScope } from "@/lib/revalidate"; const SCOPE_ROOTS: Record = { applications: path.join(process.cwd(), "public", "applications"), @@ -193,6 +194,9 @@ export async function POST(request: NextRequest) { const rel = subPath ? `${subPath}/${safeName}` : safeName; + // 🔥 Invalida caché para que la imagen aparezca sin recompilar + revalidateContent({ scope: scope as RevalidateScope, slug }); + return NextResponse.json({ success: true, file: { @@ -229,6 +233,8 @@ export async function PUT(request: NextRequest) { fs.mkdirSync(targetPath, { recursive: true }); + revalidateContent({ scope: scope as RevalidateScope, slug }); + return NextResponse.json({ success: true, folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe } @@ -256,6 +262,8 @@ export async function DELETE(request: NextRequest) { fs.unlinkSync(targetPath); + revalidateContent({ scope: scope as RevalidateScope, slug }); + return NextResponse.json({ success: true, deleted: filePath }); } catch (error) { console.error("Asset DELETE error:", error); diff --git a/src/app/api/public-upload/route.ts b/src/app/api/public-upload/route.ts index bc70dbd..f9e4214 100644 --- a/src/app/api/public-upload/route.ts +++ b/src/app/api/public-upload/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; +import { revalidateContent } from "@/lib/revalidate"; // 1. REGLAS DE SEGURIDAD ESTRICTAS const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov']; @@ -56,9 +57,12 @@ export async function POST(request: NextRequest) { // 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`; - return NextResponse.json({ - success: true, - url: publicUrl, + // Invalida caché del operations-inbox / dashboard + revalidateContent({ scope: "operations-inbox", slug: folderName }); + + return NextResponse.json({ + success: true, + url: publicUrl, fileName: safeFileName, type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image' }); diff --git a/src/lib/revalidate.ts b/src/lib/revalidate.ts new file mode 100644 index 0000000..b43e67c --- /dev/null +++ b/src/lib/revalidate.ts @@ -0,0 +1,102 @@ +// src/lib/revalidate.ts +// ───────────────────────────────────────────────────────────────────────────── +// Cache invalidation helper. +// Called after every CMS mutation (upload, edit, delete) so newly uploaded +// images/text appear without rebuilding Docker. +// ───────────────────────────────────────────────────────────────────────────── + +import { revalidatePath } from "next/cache"; + +const LOCALES = ["en", "it", "vec", "es", "de"] as const; + +export type RevalidateScope = + | "applications" + | "cases" + | "news" + | "parts" + | "heritage" + | "operations-inbox" + | "footage" + | "hero" + | "timeline" + | "all"; + +export interface RevalidateOptions { + scope: RevalidateScope; + slug?: string; +} + +function safeRevalidate(path: string, type: "page" | "layout" = "page") { + try { + revalidatePath(path, type); + } catch (err) { + console.warn(`[revalidate] Failed to revalidate path "${path}":`, err); + } +} + +export function revalidateContent({ scope, slug }: RevalidateOptions) { + safeRevalidate("/", "layout"); + + for (const locale of LOCALES) { + safeRevalidate(`/${locale}`); + + switch (scope) { + case "applications": + safeRevalidate(`/${locale}/applications`); + if (slug) safeRevalidate(`/${locale}/applications/${slug}`); + break; + case "news": + safeRevalidate(`/${locale}/news`); + if (slug) safeRevalidate(`/${locale}/news/${slug}`); + break; + case "parts": + safeRevalidate(`/${locale}/parts`); + break; + case "heritage": + safeRevalidate(`/${locale}/heritage`); + break; + case "cases": + case "timeline": + case "hero": + case "footage": + safeRevalidate(`/${locale}`); + break; + case "operations-inbox": + break; + case "all": + safeRevalidate(`/${locale}/applications`); + safeRevalidate(`/${locale}/news`); + safeRevalidate(`/${locale}/parts`); + safeRevalidate(`/${locale}/heritage`); + if (slug) { + safeRevalidate(`/${locale}/applications/${slug}`); + safeRevalidate(`/${locale}/news/${slug}`); + } + break; + } + } +} + +const SCOPE_FROM_PATH: Record = { + applications: "applications", + cases: "cases", + news: "news", + parts: "parts", + heritage: "heritage", + "operations-inbox": "operations-inbox", + footage: "footage", +}; + +export function revalidateFromPublicPath(publicUrl: string) { + const segments = publicUrl.replace(/^\/+/, "").split("/"); + const top = segments[0]; + const slug = segments[1]; + + const scope = SCOPE_FROM_PATH[top]; + if (!scope) { + safeRevalidate("/", "layout"); + return; + } + + revalidateContent({ scope, slug }); +}
+ {parseInline(th)}
+ {parseInline(cell)}