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 ENV STORAGE_URL=$STORAGE_URL
ARG STORAGE_TOKEN ARG STORAGE_TOKEN
ENV STORAGE_TOKEN=$STORAGE_TOKEN ENV STORAGE_TOKEN=$STORAGE_TOKEN
ARG BROWSER_WS_URL
ENV BROWSER_WS_URL=$BROWSER_WS_URL
RUN apk add --no-cache curl RUN apk add --no-cache curl

View file

@ -32,6 +32,14 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s 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: volumes:
tiles-data: tiles-data:

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,10 +1,7 @@
"use client"; "use client";
import html2canvas from "html2canvas-pro"; import { useEffect, useState } from "react";
import jsPDF from "jspdf";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useReactToPrint } from "react-to-print";
import z from "zod"; import z from "zod";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
@ -12,7 +9,7 @@ import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
import { it } from "~/i18n/it"; import { it } from "~/i18n/it";
import { handleConsegna } from "~/lib/annuncio_details"; import { handleConsegna } from "~/lib/annuncio_details";
import { replaceWithBr } from "~/lib/newlineToBr"; import { replaceWithBr } from "~/lib/newlineToBr";
import { formatCurrency } from "~/lib/utils"; import { cn, formatCurrency } from "~/lib/utils";
import type { Annunci } from "~/schemas/public/Annunci"; import type { Annunci } from "~/schemas/public/Annunci";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { IconMatrix } from "./IconComponents"; import { IconMatrix } from "./IconComponents";
@ -21,14 +18,25 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label"; import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; 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 }) { export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
const [inputEmail, setInputEmail] = useState<string>(""); const [inputEmail, setInputEmail] = useState<string>("");
const [openPopover, setOpenPopover] = useState<boolean>(false); 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({ const { mutate } = api.comunicazioni.sendSchedaAnnuncioEmail.useMutation({
onSuccess: () => { onSuccess: () => {
@ -43,119 +51,25 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
}, },
}); });
const sendEmail = useReactToPrint({ const sendPdf = async () => {
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) { if (!data || !inputEmail || !data.locatore || !data.numero) {
toast.error("Dati annuncio o indirizzo email mancanti."); toast.error("Dati annuncio mancanti.");
return; return;
} }
toast.loading("Invio email in corso...", { try {
id: "send-scheda-annuncio-email", toast.loading("Generazione PDF in corso...", {
}); id: "generate-pdf",
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 response = await fetch(`/api/generate-pdf?id=${data.id}`);
const imgData = canvas.toDataURL("image/png"); if (!response.ok) {
const pdf = new jsPDF("p", "mm", "a4"); throw new Error(
`Errore nella generazione del PDF: ${response.statusText}`,
// 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 blob = await response.blob();
const pdfBase64 = pdf.output("dataurlstring");
const pdfBase64 = await blobToBase64(blob);
mutate({ mutate({
pdf: pdfBase64, pdf: pdfBase64,
@ -166,18 +80,52 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`, indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`,
}); });
setInputEmail(""); setInputEmail("");
} catch (e) {
toast.success("PDF generato con successo!", {
id: "generate-pdf",
});
} catch (error) {
toast.error( toast.error(
`Errore durante l'invio dell'email: ${(e as Error).message}`, `Errore durante la generazione del PDF: ${(error as Error).message}`,
{ {
id: "send-scheda-annuncio-email", id: "generate-pdf",
}, },
); );
console.error("Error during print:", e); console.error("Error generating PDF:", error);
setInputEmail("");
} }
}, };
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 ( return (
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
@ -185,9 +133,8 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1> <h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button className="print:hidden" onClick={() => reactToPrintFn()}> <Button onClick={() => generatePdf()}>Genera Scheda</Button>
Stampa Scheda
</Button>
<Popover onOpenChange={setOpenPopover} open={openPopover}> <Popover onOpenChange={setOpenPopover} open={openPopover}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button className="print:hidden" variant="outline"> <Button className="print:hidden" variant="outline">
@ -204,7 +151,7 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
e.key === "Enter" && e.key === "Enter" &&
z.safeParse(z.email(), inputEmail).success z.safeParse(z.email(), inputEmail).success
) { ) {
sendEmail(); sendPdf();
} }
}} }}
type="email" type="email"
@ -213,7 +160,7 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
disabled={ disabled={
z.safeParse(z.email(), inputEmail).success === false z.safeParse(z.email(), inputEmail).success === false
} }
onClick={sendEmail} onClick={sendPdf}
> >
Invia Invia
</Button> </Button>
@ -221,15 +168,19 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
</Popover> </Popover>
</div> </div>
</div> </div>
<div ref={contentRef}>
<SchedaAnnuncio data={data} /> <SchedaAnnuncio data={data} />
</div> </div>
</div> </div>
</div>
); );
} }
function SchedaAnnuncio({ data }: { data: Annunci }) { export function SchedaAnnuncio({
data,
raw,
}: {
data: Annunci;
raw?: boolean;
}) {
const companyAddress = const companyAddress =
"Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it"; "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>( const [navigationUrl, setNavigationUrl] = useState<string | undefined>(
undefined, undefined,
); );
const [printDate, setPrintDate] = useState<string>("");
const [disponibileDate, setDisponibileDate] = useState<string>("");
useEffect(() => { 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 (!data) return;
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if ( if (
@ -260,7 +230,12 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
}, [data]); }, [data]);
return ( 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="space-y-8">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
@ -376,18 +351,7 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
{(data.disponibile_da || data.consegna) && ( {(data.disponibile_da || data.consegna) && (
<div className="rounded bg-gray-50 p-3"> <div className="rounded bg-gray-50 p-3">
<p className="font-medium text-primary">Disponibile da</p> <p className="font-medium text-primary">Disponibile da</p>
<p className="font-bold text-xl"> <p className="font-bold text-xl">{disponibileDate}</p>
{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>
</div> </div>
)} )}
</div> </div>
@ -447,9 +411,7 @@ function SchedaAnnuncio({ data }: { data: Annunci }) {
</div> </div>
<div className="pt-2 text-center text-muted-foreground text-xs"> <div className="pt-2 text-center text-muted-foreground text-xs">
<p>{footer}</p> <p>{footer}</p>
<p className="mt-1"> <p className="mt-1">Documento generato in data: {printDate}</p>
Documento generato in data: {new Date().toLocaleDateString("it-IT")}
</p>
</div> </div>
</div> </div>
</Card> </Card>

View file

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

View file

@ -511,6 +511,16 @@ const BudgetFilters = () => {
return ( 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 flex-col gap-2">
<div className="flex h-4 flex-row gap-4 px-1"> <div className="flex h-4 flex-row gap-4 px-1">
<Label <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 type { GetServerSideProps } from "next";
import Head from "next/head"; import Head from "next/head";
import { AreaRiservataLayout } from "~/components/Layout"; import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading"; import {
import { SchedaAnnuncioStampabile } from "~/components/schedaAnnuncioStampabile"; SchedaAnnuncio,
import { Status500 } from "~/components/status-page"; SchedaAnnuncioStampabile,
} from "~/components/schedaAnnuncioStampabile";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import type { AnnunciId } from "~/schemas/public/Annunci"; import type { AnnunciWithMedia } from "~/server/controllers/annunci.controller";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { zAnnuncioId } from "~/server/utils/zod_types"; import { zAnnuncioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type PageProps = { type PageProps = {
id: AnnunciId; raw: boolean;
data: StringifiedAnnunciWithMedia;
}; };
const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({ const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({
id, raw,
data,
}: PageProps) => { }: PageProps) => {
const { data, isLoading } = api.annunci.getAnnuncioById_rawImgUrls.useQuery({ const annuncioData = StringifiedToAnnuncio(data);
id, if (raw) {
}); return <SchedaAnnuncio data={annuncioData} raw />;
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
} }
return ( return (
<> <>
<Head> <Head>
<title>Scheda Annuncio {data.codice}</title> <title>Scheda Annuncio {annuncioData.codice}</title>
</Head> </Head>
<div className="mx-auto w-full p-4"> <div className="mx-auto w-full p-4">
<SchedaAnnuncioStampabile data={data} /> <SchedaAnnuncioStampabile data={annuncioData} />
</div> </div>
</> </>
); );
}; };
SchedaAnnuncioPage.getLayout = function getLayout(page) { SchedaAnnuncioPage.getLayout = function getLayout(page) {
if (page.props.raw) {
return <>{page}</>;
}
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>; return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
}; };
@ -47,6 +46,7 @@ export default SchedaAnnuncioPage;
export const getServerSideProps = (async (context) => { export const getServerSideProps = (async (context) => {
const id = context.params?.id; const id = context.params?.id;
const raw = context.query?.raw;
if (typeof id !== "string") { if (typeof id !== "string") {
console.error("Error: id is not a 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 = generateSSGHelper();
const helper = await TrpcAuthedFetchingIstance({ access_token }); const data = await helper.annunci.getAnnuncioById_rawImgUrls.fetch({
if (helper) {
await helper.trpc.annunci.getAnnuncioById_rawImgUrls.prefetch({
id: parsed.data, id: parsed.data,
}); });
if (!data) {
return {
notFound: true,
};
}
return { return {
props: { props: {
id: parsed.data, raw: raw === "true",
trpcState: helper.trpc.dehydrate(), trpcState: helper.dehydrate(),
}, data : AnnuncioToStringified(data),
};
}
return {
redirect: {
destination: "/500",
permanent: false,
}, },
}; };
}) satisfies GetServerSideProps<PageProps>; }) 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,20 +8,16 @@ const STALE_KEY = "stale-resource";
export const apisProxy: ProxyFn = async (req: NextRequest) => { export const apisProxy: ProxyFn = async (req: NextRequest) => {
const { pathname, searchParams } = req.nextUrl; const { pathname, searchParams } = req.nextUrl;
if (!pathname.startsWith("/storage-api/")) { // Handle Storage API requests
return; if (pathname.startsWith("/storage-api/")) {
}
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
const isImageRequest = params.get("media") === "image"; const isImageRequest = params.get("media") === "image";
const isVideoRequest = params.get("media") === "video"; const isVideoRequest = params.get("media") === "video";
// Check if this is a Next.js Image Optimization request
const purpose = req.headers.get("purpose"); const purpose = req.headers.get("purpose");
const isNextImageRequest = const isNextImageRequest =
purpose === "prefetch" || req.headers.get("x-vercel-id"); purpose === "prefetch" || req.headers.get("x-vercel-id");
// For regular requests (not from Next Image Optimization), check auth
if (!isNextImageRequest && !isImageRequest && !isVideoRequest) { if (!isNextImageRequest && !isImageRequest && !isVideoRequest) {
const accessToken = req.cookies.get( const accessToken = req.cookies.get(
TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
@ -44,18 +40,14 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
} }
} }
// Build the storage API URL correctly
const slug = pathname.replace("/storage-api/", ""); const slug = pathname.replace("/storage-api/", "");
params.set("token", env.STORAGE_TOKEN); params.set("token", env.STORAGE_TOKEN);
const storageUrl = `${env.STORAGE_URL}/${slug}?${params.toString()}`; const storageUrl = `${env.STORAGE_URL}/${slug}?${params.toString()}`;
try { try {
// Forward the request to storage API
const headers = new Headers(req.headers); const headers = new Headers(req.headers);
headers.delete("host"); headers.delete("host");
headers.delete("cookie"); headers.delete("cookie");
// **CRITICAL: Preserve Range header for video streaming**
const rangeHeader = req.headers.get("range"); const rangeHeader = req.headers.get("range");
const response = await fetch(storageUrl, { const response = await fetch(storageUrl, {
@ -67,17 +59,10 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
duplex: "half", duplex: "half",
}); });
// Handle failed responses
if (!response.ok) { 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 = const isStaleResource =
response.status === 404 || response.status === 410; response.status === 404 || response.status === 410;
// Return fallback for image requests
if (isImageRequest || isNextImageRequest) { if (isImageRequest || isNextImageRequest) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/fallback-image.png"; url.pathname = "/fallback-image.png";
@ -93,7 +78,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
return response; return response;
} }
// Return fallback for video requests
if (isVideoRequest) { if (isVideoRequest) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/fallback-video.png"; url.pathname = "/fallback-video.png";
@ -109,7 +93,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
return response; return response;
} }
// For non-media requests (e.g., documents), return JSON with reload hint
if (isStaleResource) { if (isStaleResource) {
return NextResponse.json( return NextResponse.json(
{ message: "Stale resource, please reload." }, { message: "Stale resource, please reload." },
@ -121,15 +104,12 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
}); });
} }
//ignore if 204 no content, is the favicon request
if (response.status === 204) { if (response.status === 204) {
return new NextResponse(null, { status: 200 }); return new NextResponse(null, { status: 200 });
} }
// Get response data
const data = await response.arrayBuffer(); const data = await response.arrayBuffer();
// Check if we got valid data
if (!data || data.byteLength === 0) { if (!data || data.byteLength === 0) {
console.error( console.error(
"Received empty response from storage API, referrer:", "Received empty response from storage API, referrer:",
@ -155,17 +135,14 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
return new NextResponse("Empty response", { status: 500 }); return new NextResponse("Empty response", { status: 500 });
} }
// **IMPORTANT: Use correct status code for partial content**
const status = const status =
rangeHeader && response.status === 206 ? 206 : response.status; rangeHeader && response.status === 206 ? 206 : response.status;
// Create response with proper headers
const proxyResponse = new NextResponse(data, { const proxyResponse = new NextResponse(data, {
status: status, status: status,
statusText: response.statusText, statusText: response.statusText,
}); });
// Copy relevant headers
const contentType = response.headers.get("Content-Type"); const contentType = response.headers.get("Content-Type");
const contentDisposition = response.headers.get("Content-Disposition"); const contentDisposition = response.headers.get("Content-Disposition");
const contentLength = response.headers.get("Content-Length"); const contentLength = response.headers.get("Content-Length");
@ -184,7 +161,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
proxyResponse.headers.set("Content-Length", contentLength); proxyResponse.headers.set("Content-Length", contentLength);
} }
// **CRITICAL: Copy Range-related headers for video streaming**
if (acceptRanges) { if (acceptRanges) {
proxyResponse.headers.set("Accept-Ranges", acceptRanges); proxyResponse.headers.set("Accept-Ranges", acceptRanges);
} else if (isVideoRequest) { } else if (isVideoRequest) {
@ -195,10 +171,8 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
proxyResponse.headers.set("Content-Range", contentRange); proxyResponse.headers.set("Content-Range", contentRange);
} }
// Set CORS and caching headers
proxyResponse.headers.set("Access-Control-Allow-Origin", "*"); proxyResponse.headers.set("Access-Control-Allow-Origin", "*");
// Different cache strategy for videos
if (isVideoRequest) { if (isVideoRequest) {
proxyResponse.headers.set( proxyResponse.headers.set(
"Cache-Control", "Cache-Control",
@ -221,7 +195,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
} catch (error) { } catch (error) {
console.error("Storage proxy error:", error); console.error("Storage proxy error:", error);
// Return fallback on error
if (isImageRequest || isNextImageRequest) { if (isImageRequest || isNextImageRequest) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/fallback-image.png"; url.pathname = "/fallback-image.png";
@ -238,4 +211,6 @@ export const apisProxy: ProxyFn = async (req: NextRequest) => {
return new NextResponse("Failed to proxy request", { status: 500 }); return new NextResponse("Failed to proxy request", { status: 500 });
} }
}
return;
}; };

View file

@ -17,7 +17,9 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
)?.value; )?.value;
const isProtectedRoute = 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"); !path.startsWith("/servizio/pre-onboard");
if (isProtectedRoute) { if (isProtectedRoute) {

View file

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