feat: integrate PDF generation using Puppeteer

- Removed dependencies on html2canvas-pro and jsPDF.
- Added puppeteer-core for PDF generation.
- Created a new API endpoint for generating PDFs.
- Updated SchedaAnnuncioStampabile component to use the new PDF generation API.
- Enhanced error handling and user feedback during PDF generation.
- Added browserless service to docker-compose for Puppeteer.
- Updated environment variables to include BROWSER_WS_URL.
- Refactored code to improve readability and maintainability.
This commit is contained in:
Marco Pedone 2026-02-03 11:20:26 +01:00
parent 217d6c7982
commit 42c15dfe96
12 changed files with 1226 additions and 651 deletions

View file

@ -85,6 +85,8 @@ ARG STORAGE_URL
ENV STORAGE_URL=$STORAGE_URL
ARG STORAGE_TOKEN
ENV STORAGE_TOKEN=$STORAGE_TOKEN
ARG BROWSER_WS_URL
ENV BROWSER_WS_URL=$BROWSER_WS_URL
RUN apk add --no-cache curl

View file

@ -32,6 +32,14 @@ services:
timeout: 5s
retries: 5
start_period: 10s
browserless:
image: browserless/chrome:latest
ports:
- "8081:3000" # Optional: Access the debug dashboard at localhost:8080
environment:
- MAX_CONCURRENT_SESSIONS=5
- CONNECTION_TIMEOUT=30000
restart: always
volumes:
tiles-data:

File diff suppressed because it is too large Load diff

View file

@ -61,10 +61,8 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.27.5",
"frimousse": "^0.3.0",
"html2canvas-pro": "^1.6.5",
"ioredis": "^5.9.2",
"jose": "^6.0.12",
"jspdf": "^4.0.0",
"kysely": "^0.28.10",
"kysely-plugin-serialize": "^0.8.2",
"leaflet": "^1.9.4",
@ -78,6 +76,7 @@
"nuqs": "^2.8.6",
"pg": "^8.17.1",
"postcss": "^8.5.6",
"puppeteer-core": "^24.36.1",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-colorful": "^5.6.1",

View file

@ -1,10 +1,7 @@
"use client";
import html2canvas from "html2canvas-pro";
import jsPDF from "jspdf";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useReactToPrint } from "react-to-print";
import z from "zod";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
@ -12,7 +9,7 @@ import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
import { it } from "~/i18n/it";
import { handleConsegna } from "~/lib/annuncio_details";
import { replaceWithBr } from "~/lib/newlineToBr";
import { formatCurrency } from "~/lib/utils";
import { cn, formatCurrency } from "~/lib/utils";
import type { Annunci } from "~/schemas/public/Annunci";
import { api } from "~/utils/api";
import { IconMatrix } from "./IconComponents";
@ -21,14 +18,25 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
const blobToBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => {
if (typeof reader.result === "string") {
// Remove the Data-URL prefix (e.g. "data:application/pdf;base64,") to get just the base64
resolve(reader.result.split(",")[1] || "");
} else {
reject(new Error("Failed to convert blob to text"));
}
};
reader.readAsDataURL(blob);
});
};
export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
const [inputEmail, setInputEmail] = useState<string>("");
const [openPopover, setOpenPopover] = useState<boolean>(false);
const contentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({
contentRef,
//print: async (target) => {console.log("Printing...", target.contentDocument);},
});
const { mutate } = api.comunicazioni.sendSchedaAnnuncioEmail.useMutation({
onSuccess: () => {
@ -43,141 +51,81 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
},
});
const sendEmail = useReactToPrint({
contentRef,
print: async (target) => {
try {
const htmlContent = target.contentDocument?.documentElement;
if (!htmlContent) {
toast.error("Impossibile generare il contenuto per la stampa.");
return;
}
if (!data || !inputEmail || !data.locatore || !data.numero) {
toast.error("Dati annuncio o indirizzo email mancanti.");
return;
}
toast.loading("Invio email in corso...", {
id: "send-scheda-annuncio-email",
});
setOpenPopover(false);
htmlContent.querySelectorAll(".print\\:border-none").forEach((el) => {
el.classList.remove("border");
});
htmlContent.querySelectorAll(".print\\:shadow-none").forEach((el) => {
el.classList.remove("shadow-sm");
});
htmlContent.querySelectorAll("#navigationLink").forEach((el) => {
el.classList.remove("flex");
el.classList.add("hidden");
});
htmlContent.querySelectorAll("#mainLogoSvg text").forEach((el) => {
el.classList.remove("font-sans");
el.classList.add("font-[system-ui]");
});
const canvas = await html2canvas(htmlContent, {
//scale: 2,
useCORS: true,
logging: false,
backgroundColor: "#ffffff",
});
const sendPdf = async () => {
if (!data || !inputEmail || !data.locatore || !data.numero) {
toast.error("Dati annuncio mancanti.");
return;
}
try {
toast.loading("Generazione PDF in corso...", {
id: "generate-pdf",
});
const response = await fetch(`/api/generate-pdf?id=${data.id}`);
const imgData = canvas.toDataURL("image/png");
const pdf = new jsPDF("p", "mm", "a4");
// A4 dimensions in mm
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
// Define margins (in mm)
const marginTop = 5;
const marginRight = 5;
const marginBottom = 5;
const marginLeft = 5;
// Calculate available space
const availableWidth = pdfWidth - marginLeft - marginRight;
const availableHeight = pdfHeight - marginTop - marginBottom;
// Calculate image dimensions
const imgProps = pdf.getImageProperties(imgData);
const imgWidth = imgProps.width;
const imgHeight = imgProps.height;
// Calculate how the image should fit with margins
const ratio = availableWidth / imgWidth;
const scaledHeight = imgHeight * ratio;
// Check if content needs multiple pages
if (scaledHeight <= availableHeight) {
// Single page - content fits
pdf.addImage(
imgData,
"PNG",
marginLeft,
marginTop,
availableWidth,
scaledHeight,
undefined,
"FAST",
);
} else {
// Multiple pages needed
let heightLeft = scaledHeight;
let position = marginTop;
// Add first page
pdf.addImage(
imgData,
"PNG",
marginLeft,
position,
availableWidth,
scaledHeight,
undefined,
"FAST",
);
heightLeft -= availableHeight;
// Add remaining pages
while (heightLeft > 0) {
position = marginTop - (scaledHeight - heightLeft);
pdf.addPage();
pdf.addImage(
imgData,
"PNG",
marginLeft,
position,
availableWidth,
scaledHeight,
undefined,
"FAST",
);
heightLeft -= availableHeight;
}
}
const pdfBase64 = pdf.output("dataurlstring");
mutate({
pdf: pdfBase64,
to: inputEmail,
codice: data.codice,
numero: data.numero,
nominativo: data.locatore,
indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`,
});
setInputEmail("");
} catch (e) {
toast.error(
`Errore durante l'invio dell'email: ${(e as Error).message}`,
{
id: "send-scheda-annuncio-email",
},
if (!response.ok) {
throw new Error(
`Errore nella generazione del PDF: ${response.statusText}`,
);
console.error("Error during print:", e);
setInputEmail("");
}
},
});
const blob = await response.blob();
const pdfBase64 = await blobToBase64(blob);
mutate({
pdf: pdfBase64,
to: inputEmail,
codice: data.codice,
numero: data.numero,
nominativo: data.locatore,
indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`,
});
setInputEmail("");
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
} catch (error) {
toast.error(
`Errore durante la generazione del PDF: ${(error as Error).message}`,
{
id: "generate-pdf",
},
);
console.error("Error generating PDF:", error);
}
};
const generatePdf = async () => {
if (!data) {
toast.error("Dati annuncio mancanti.");
return;
}
try {
toast.loading("Generazione PDF in corso...", {
id: "generate-pdf",
});
const response = await fetch(`/api/generate-pdf?id=${data.id}`);
if (!response.ok) {
throw new Error(
`Errore nella generazione del PDF: ${response.statusText}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
window.open(url, "_blank");
} catch (error) {
toast.error(
`Errore durante la generazione del PDF: ${(error as Error).message}`,
{
id: "generate-pdf",
},
);
console.error("Error generating PDF:", error);
}
};
return (
<div className="mx-auto max-w-4xl">
@ -185,9 +133,8 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
<div className="flex items-center justify-between">
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
<div className="flex gap-2">
<Button className="print:hidden" onClick={() => reactToPrintFn()}>
Stampa Scheda
</Button>
<Button onClick={() => generatePdf()}>Genera Scheda</Button>
<Popover onOpenChange={setOpenPopover} open={openPopover}>
<PopoverTrigger asChild>
<Button className="print:hidden" variant="outline">
@ -204,7 +151,7 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
e.key === "Enter" &&
z.safeParse(z.email(), inputEmail).success
) {
sendEmail();
sendPdf();
}
}}
type="email"
@ -213,7 +160,7 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
disabled={
z.safeParse(z.email(), inputEmail).success === false
}
onClick={sendEmail}
onClick={sendPdf}
>
Invia
</Button>
@ -221,15 +168,19 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
</Popover>
</div>
</div>
<div ref={contentRef}>
<SchedaAnnuncio data={data} />
</div>
<SchedaAnnuncio data={data} />
</div>
</div>
);
}
function SchedaAnnuncio({ data }: { data: Annunci }) {
export function SchedaAnnuncio({
data,
raw,
}: {
data: Annunci;
raw?: boolean;
}) {
const companyAddress =
"Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it";
@ -239,7 +190,26 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
const [navigationUrl, setNavigationUrl] = useState<string | undefined>(
undefined,
);
const [printDate, setPrintDate] = useState<string>("");
const [disponibileDate, setDisponibileDate] = useState<string>("");
useEffect(() => {
setPrintDate(new Date().toLocaleDateString("it-IT"));
if (data.disponibile_da) {
setDisponibileDate(new Date(data.disponibile_da).toLocaleDateString());
} else if (data.consegna) {
setDisponibileDate(
handleConsegna({
aggiornamento: it.card.in_aggiornamento,
consegna: data.consegna,
consegna_da: it.card.consegna_da,
mesi: it.parametri.mesi,
subito: it.card.consegna_subito,
}),
);
} else {
setDisponibileDate("Non specificata");
}
if (!data) return;
if (typeof window === "undefined") return;
if (
@ -260,7 +230,12 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
}, [data]);
return (
<Card className="p-4 print:border-none print:shadow-none">
<Card
className={cn(
"p-4 print:border-none print:shadow-none",
raw && "border-none shadow-none",
)}
>
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
@ -376,18 +351,7 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
{(data.disponibile_da || data.consegna) && (
<div className="rounded bg-gray-50 p-3">
<p className="font-medium text-primary">Disponibile da</p>
<p className="font-bold text-xl">
{data.disponibile_da
? new Date(data.disponibile_da).toLocaleDateString()
: data.consegna &&
handleConsegna({
aggiornamento: it.card.in_aggiornamento,
consegna: data.consegna,
consegna_da: it.card.consegna_da,
mesi: it.parametri.mesi,
subito: it.card.consegna_subito,
})}
</p>
<p className="font-bold text-xl">{disponibileDate}</p>
</div>
)}
</div>
@ -447,9 +411,7 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
</div>
<div className="pt-2 text-center text-muted-foreground text-xs">
<p>{footer}</p>
<p className="mt-1">
Documento generato in data: {new Date().toLocaleDateString("it-IT")}
</p>
<p className="mt-1">Documento generato in data: {printDate}</p>
</div>
</div>
</Card>

View file

@ -30,6 +30,7 @@ export const env = createEnv({
REVALIDATION_SECRET: z.string(),
STORAGE_URL: z.string(),
STORAGE_TOKEN: z.string(),
BROWSER_WS_URL: z.string(),
},
client: {
NEXT_PUBLIC_BASE_URL: z.string(),

View file

@ -511,6 +511,16 @@ const BudgetFilters = () => {
return (
<>
{/*
<div className="flex w-full max-w-md flex-col gap-2">
<Label htmlFor="slider">Price Range</Label>
<Slider id="slider" max={1000} min={0} onValueChange={setValue} value={value} />
<div className="flex items-center justify-between text-muted-foreground text-sm">
<span>${value[0]}</span>
<span>${value[1]}</span>
</div>
</div>
*/}
<div className="flex flex-col gap-2">
<div className="flex h-4 flex-row gap-4 px-1">
<Label

View file

@ -0,0 +1,54 @@
import type { NextApiRequest, NextApiResponse } from "next";
import puppeteer from "puppeteer-core";
import { env } from "~/env";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
let browser = null;
try {
browser = await puppeteer.connect({
browserWSEndpoint: env.BROWSER_WS_URL,
});
const page = await browser.newPage();
if (req.cookies && Object.keys(req.cookies).length > 0) {
const productionDomain = new URL(env.INTERNAL_BASE_URL).hostname;
const cookies = Object.entries(req.cookies).map(([name, value]) => ({
name,
value: value || "",
domain:
env.NODE_ENV === "production"
? productionDomain
: "host.docker.internal",
path: "/",
}));
await browser.setCookie(...cookies);
}
const baseUrl =
env.NODE_ENV === "production"
? env.INTERNAL_BASE_URL
: `http://host.docker.internal:3000`;
const targetUrl = `${baseUrl}/area-riservata/admin/scheda-annuncio-stampa/${req.query.id}?raw=true`;
await page.goto(targetUrl, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
printBackground: true,
});
res.setHeader("Content-Type", "application/pdf");
res.send(Buffer.from(pdf));
} catch (error) {
console.error("PDF Generation Error:", error);
res.status(500).json({ error: "Failed to generate PDF" });
} finally {
// 'disconnect' tells browserless you're done with this tab
if (browser) await browser.disconnect();
}
}

View file

@ -1,45 +1,44 @@
import type { GetServerSideProps } from "next";
import Head from "next/head";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { SchedaAnnuncioStampabile } from "~/components/schedaAnnuncioStampabile";
import { Status500 } from "~/components/status-page";
import {
SchedaAnnuncio,
SchedaAnnuncioStampabile,
} from "~/components/schedaAnnuncioStampabile";
import type { NextPageWithLayout } from "~/pages/_app";
import type { AnnunciId } from "~/schemas/public/Annunci";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import type { AnnunciWithMedia } from "~/server/controllers/annunci.controller";
import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { zAnnuncioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type PageProps = {
id: AnnunciId;
raw: boolean;
data: StringifiedAnnunciWithMedia;
};
const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({
id,
raw,
data,
}: PageProps) => {
const { data, isLoading } = api.annunci.getAnnuncioById_rawImgUrls.useQuery({
id,
});
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
const annuncioData = StringifiedToAnnuncio(data);
if (raw) {
return <SchedaAnnuncio data={annuncioData} raw />;
}
return (
<>
<Head>
<title>Scheda Annuncio {data.codice}</title>
<title>Scheda Annuncio {annuncioData.codice}</title>
</Head>
<div className="mx-auto w-full p-4">
<SchedaAnnuncioStampabile data={data} />
<SchedaAnnuncioStampabile data={annuncioData} />
</div>
</>
);
};
SchedaAnnuncioPage.getLayout = function getLayout(page) {
if (page.props.raw) {
return <>{page}</>;
}
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
};
@ -47,6 +46,7 @@ export default SchedaAnnuncioPage;
export const getServerSideProps = (async (context) => {
const id = context.params?.id;
const raw = context.query?.raw;
if (typeof id !== "string") {
console.error("Error: id is not a string");
@ -69,25 +69,58 @@ export const getServerSideProps = (async (context) => {
};
}
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
await helper.trpc.annunci.getAnnuncioById_rawImgUrls.prefetch({
id: parsed.data,
});
const helper = generateSSGHelper();
const data = await helper.annunci.getAnnuncioById_rawImgUrls.fetch({
id: parsed.data,
});
if (!data) {
return {
props: {
id: parsed.data,
trpcState: helper.trpc.dehydrate(),
},
notFound: true,
};
}
return {
redirect: {
destination: "/500",
permanent: false,
props: {
raw: raw === "true",
trpcState: helper.dehydrate(),
data : AnnuncioToStringified(data),
},
};
}) satisfies GetServerSideProps<PageProps>;
type StringifiedAnnunciWithMedia = Omit<
AnnunciWithMedia,
"creato_il" | "disponibile_da" | "media_updated_at" | "modificato_il"
> & {
creato_il: string | undefined;
disponibile_da: string | undefined;
media_updated_at: string | undefined;
modificato_il: string | undefined;
};
function AnnuncioToStringified(
data: AnnunciWithMedia,
): StringifiedAnnunciWithMedia {
return {
...data,
creato_il: data?.creato_il?.toISOString(),
disponibile_da: data?.disponibile_da?.toISOString(),
media_updated_at: data?.media_updated_at?.toISOString(),
modificato_il: data?.modificato_il?.toISOString(),
};
}
function StringifiedToAnnuncio(
data: StringifiedAnnunciWithMedia,
): AnnunciWithMedia {
return {
...data,
creato_il: data?.creato_il ? new Date(data.creato_il) : null,
disponibile_da: data?.disponibile_da ? new Date(data.disponibile_da) : null,
media_updated_at: data?.media_updated_at
? new Date(data.media_updated_at)
: null,
modificato_il: data?.modificato_il ? new Date(data.modificato_il) : null,
};
}

View file

@ -8,136 +8,193 @@ const STALE_KEY = "stale-resource";
export const apisProxy: ProxyFn = async (req: NextRequest) => {
const { pathname, searchParams } = req.nextUrl;
if (!pathname.startsWith("/storage-api/")) {
return;
}
// Handle Storage API requests
if (pathname.startsWith("/storage-api/")) {
const params = new URLSearchParams(searchParams);
const isImageRequest = params.get("media") === "image";
const isVideoRequest = params.get("media") === "video";
const params = new URLSearchParams(searchParams);
const isImageRequest = params.get("media") === "image";
const isVideoRequest = params.get("media") === "video";
const purpose = req.headers.get("purpose");
const isNextImageRequest =
purpose === "prefetch" || req.headers.get("x-vercel-id");
// Check if this is a Next.js Image Optimization request
const purpose = req.headers.get("purpose");
const isNextImageRequest =
purpose === "prefetch" || req.headers.get("x-vercel-id");
if (!isNextImageRequest && !isImageRequest && !isVideoRequest) {
const accessToken = req.cookies.get(
TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
)?.value;
// For regular requests (not from Next Image Optimization), check auth
if (!isNextImageRequest && !isImageRequest && !isVideoRequest) {
const accessToken = req.cookies.get(
TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
)?.value;
if (!accessToken) {
console.log(
"No access token found, referrer:",
req.referrer,
" url:",
req.url,
);
return new NextResponse("Unauthorized", { status: 401 });
}
const payload = await verifyToken(accessToken);
if (!payload) {
console.log("Invalid token, referrer:", req.referrer, " url:", req.url);
return new NextResponse("Unauthorized", { status: 401 });
}
}
// Build the storage API URL correctly
const slug = pathname.replace("/storage-api/", "");
params.set("token", env.STORAGE_TOKEN);
const storageUrl = `${env.STORAGE_URL}/${slug}?${params.toString()}`;
try {
// Forward the request to storage API
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
// **CRITICAL: Preserve Range header for video streaming**
const rangeHeader = req.headers.get("range");
const response = await fetch(storageUrl, {
method: req.method,
headers: headers,
body:
req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
// @ts-expect-error - duplex needed for request streaming
duplex: "half",
});
// Handle failed responses
if (!response.ok) {
// console.error(
// `Storage API error: ${response.status} for ${storageUrl}, referrer: ${req.referrer}, url: ${req.url}`,
// );
// 404 = File not found (likely stale URL after media update)
// 410 = Gone (explicitly removed)
const isStaleResource =
response.status === 404 || response.status === 410;
// Return fallback for image requests
if (isImageRequest || isNextImageRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-image.png";
url.search = "";
const response = NextResponse.rewrite(url);
if (isStaleResource) {
response.cookies.set(STALE_KEY, "1", {
maxAge: 5,
path: "/",
});
}
return response;
if (!accessToken) {
console.log(
"No access token found, referrer:",
req.referrer,
" url:",
req.url,
);
return new NextResponse("Unauthorized", { status: 401 });
}
// Return fallback for video requests
const payload = await verifyToken(accessToken);
if (!payload) {
console.log("Invalid token, referrer:", req.referrer, " url:", req.url);
return new NextResponse("Unauthorized", { status: 401 });
}
}
const slug = pathname.replace("/storage-api/", "");
params.set("token", env.STORAGE_TOKEN);
const storageUrl = `${env.STORAGE_URL}/${slug}?${params.toString()}`;
try {
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
const rangeHeader = req.headers.get("range");
const response = await fetch(storageUrl, {
method: req.method,
headers: headers,
body:
req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
// @ts-expect-error - duplex needed for request streaming
duplex: "half",
});
if (!response.ok) {
const isStaleResource =
response.status === 404 || response.status === 410;
if (isImageRequest || isNextImageRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-image.png";
url.search = "";
const response = NextResponse.rewrite(url);
if (isStaleResource) {
response.cookies.set(STALE_KEY, "1", {
maxAge: 5,
path: "/",
});
}
return response;
}
if (isVideoRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-video.png";
url.search = "";
const response = NextResponse.rewrite(url);
if (isStaleResource) {
response.cookies.set(STALE_KEY, "1", {
maxAge: 5,
path: "/",
});
}
return response;
}
if (isStaleResource) {
return NextResponse.json(
{ message: "Stale resource, please reload." },
{ status: 410 },
);
}
return new NextResponse(`Storage API error: ${response.status}`, {
status: response.status,
});
}
if (response.status === 204) {
return new NextResponse(null, { status: 200 });
}
const data = await response.arrayBuffer();
if (!data || data.byteLength === 0) {
console.error(
"Received empty response from storage API, referrer:",
req.referrer,
" url:",
req.url,
);
if (isImageRequest || isNextImageRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-image.png";
url.search = "";
return NextResponse.rewrite(url);
}
if (isVideoRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-video.png";
url.search = "";
return NextResponse.rewrite(url);
}
return new NextResponse("Empty response", { status: 500 });
}
const status =
rangeHeader && response.status === 206 ? 206 : response.status;
const proxyResponse = new NextResponse(data, {
status: status,
statusText: response.statusText,
});
const contentType = response.headers.get("Content-Type");
const contentDisposition = response.headers.get("Content-Disposition");
const contentLength = response.headers.get("Content-Length");
const acceptRanges = response.headers.get("Accept-Ranges");
const contentRange = response.headers.get("Content-Range");
if (contentType) {
proxyResponse.headers.set("Content-Type", contentType);
}
if (contentDisposition) {
proxyResponse.headers.set("Content-Disposition", contentDisposition);
}
if (contentLength) {
proxyResponse.headers.set("Content-Length", contentLength);
}
if (acceptRanges) {
proxyResponse.headers.set("Accept-Ranges", acceptRanges);
} else if (isVideoRequest) {
proxyResponse.headers.set("Accept-Ranges", "bytes");
}
if (contentRange) {
proxyResponse.headers.set("Content-Range", contentRange);
}
proxyResponse.headers.set("Access-Control-Allow-Origin", "*");
if (isVideoRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-video.png";
url.search = "";
const response = NextResponse.rewrite(url);
if (isStaleResource) {
response.cookies.set(STALE_KEY, "1", {
maxAge: 5,
path: "/",
});
}
return response;
}
// For non-media requests (e.g., documents), return JSON with reload hint
if (isStaleResource) {
return NextResponse.json(
{ message: "Stale resource, please reload." },
{ status: 410 },
proxyResponse.headers.set(
"Cache-Control",
"public, max-age=31536000, immutable",
);
} else {
proxyResponse.headers.set(
"Cache-Control",
"public, max-age=31536000, immutable",
);
}
return new NextResponse(`Storage API error: ${response.status}`, {
status: response.status,
});
}
//ignore if 204 no content, is the favicon request
if (response.status === 204) {
return new NextResponse(null, { status: 200 });
}
// Get response data
const data = await response.arrayBuffer();
// Check if we got valid data
if (!data || data.byteLength === 0) {
console.error(
"Received empty response from storage API, referrer:",
req.referrer,
" url:",
req.url,
proxyResponse.headers.delete("X-Frame-Options");
proxyResponse.headers.set(
"Content-Security-Policy",
"frame-ancestors 'self'",
);
return proxyResponse;
} catch (error) {
console.error("Storage proxy error:", error);
if (isImageRequest || isNextImageRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-image.png";
@ -152,90 +209,8 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
return NextResponse.rewrite(url);
}
return new NextResponse("Empty response", { status: 500 });
return new NextResponse("Failed to proxy request", { status: 500 });
}
// **IMPORTANT: Use correct status code for partial content**
const status =
rangeHeader && response.status === 206 ? 206 : response.status;
// Create response with proper headers
const proxyResponse = new NextResponse(data, {
status: status,
statusText: response.statusText,
});
// Copy relevant headers
const contentType = response.headers.get("Content-Type");
const contentDisposition = response.headers.get("Content-Disposition");
const contentLength = response.headers.get("Content-Length");
const acceptRanges = response.headers.get("Accept-Ranges");
const contentRange = response.headers.get("Content-Range");
if (contentType) {
proxyResponse.headers.set("Content-Type", contentType);
}
if (contentDisposition) {
proxyResponse.headers.set("Content-Disposition", contentDisposition);
}
if (contentLength) {
proxyResponse.headers.set("Content-Length", contentLength);
}
// **CRITICAL: Copy Range-related headers for video streaming**
if (acceptRanges) {
proxyResponse.headers.set("Accept-Ranges", acceptRanges);
} else if (isVideoRequest) {
proxyResponse.headers.set("Accept-Ranges", "bytes");
}
if (contentRange) {
proxyResponse.headers.set("Content-Range", contentRange);
}
// Set CORS and caching headers
proxyResponse.headers.set("Access-Control-Allow-Origin", "*");
// Different cache strategy for videos
if (isVideoRequest) {
proxyResponse.headers.set(
"Cache-Control",
"public, max-age=31536000, immutable",
);
} else {
proxyResponse.headers.set(
"Cache-Control",
"public, max-age=31536000, immutable",
);
}
proxyResponse.headers.delete("X-Frame-Options");
proxyResponse.headers.set(
"Content-Security-Policy",
"frame-ancestors 'self'",
);
return proxyResponse;
} catch (error) {
console.error("Storage proxy error:", error);
// Return fallback on error
if (isImageRequest || isNextImageRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-image.png";
url.search = "";
return NextResponse.rewrite(url);
}
if (isVideoRequest) {
const url = req.nextUrl.clone();
url.pathname = "/fallback-video.png";
url.search = "";
return NextResponse.rewrite(url);
}
return new NextResponse("Failed to proxy request", { status: 500 });
}
return;
};

View file

@ -17,7 +17,9 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
)?.value;
const isProtectedRoute =
(path.startsWith("/area-riservata") || path.startsWith("/servizio")) &&
(path.startsWith("/area-riservata") ||
path.startsWith("/servizio") ||
path.startsWith("/api/generate-pdf")) &&
!path.startsWith("/servizio/pre-onboard");
if (isProtectedRoute) {

View file

@ -96,6 +96,17 @@ services:
limits:
cpus: "0.5"
browserless:
image: browserless/chrome:latest
networks:
- dokploy-network
expose:
- "3000"
environment:
- MAX_CONCURRENT_SESSIONS=5
- CONNECTION_TIMEOUT=30000
restart: always
backend:
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/${PREFIX:-infoalloggi}-backend:${TAG:-latest}"
networks:
@ -161,6 +172,7 @@ services:
SKEBBY_PASS: ${SKEBBY_PASS}
STORAGE_URL: http://storage:8080
STORAGE_TOKEN: ${STORAGE_TOKEN}
BROWSER_WS_URL: "ws://browserless:3000"
networks:
- dokploy-network
ports: