v0.1.14 — direct PDF download via puppeteer + chromium, fix logo on cover

This commit is contained in:
Hendrik 2026-05-01 19:34:08 +02:00
parent cb70fbacf5
commit 4bd76c9eda
11 changed files with 1021 additions and 77 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { FileDown } from "lucide-react";
import { FileDown, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -27,6 +27,8 @@ export function ExportPdfButton() {
const [days, setDays] = useState(30);
const [title, setTitle] = useState("Domain-Redirect-Report");
const [sel, setSel] = useState<Record<string, boolean>>(PRESETS.basic);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState("");
function applyPreset(name: keyof typeof PRESETS) {
setSel(PRESETS[name]);
@ -36,11 +38,10 @@ export function ExportPdfButton() {
const params = new URLSearchParams();
params.set("days", String(days));
params.set("title", title);
params.set("print", "1");
for (const s of SECTIONS) {
params.set(s.key, sel[s.key] ? "1" : "0");
}
return `/analytics/report?${params.toString()}`;
return `/api/analytics/report.pdf?${params.toString()}`;
}
return (
@ -94,10 +95,36 @@ export function ExportPdfButton() {
</div>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button asChild>
<a href={build()} target="_blank" rel="noreferrer">Vorschau & PDF</a>
<Button variant="outline" onClick={() => setOpen(false)} disabled={downloading}>Abbrechen</Button>
<Button onClick={async () => {
setDownloading(true);
setError("");
try {
const r = await fetch(build());
if (!r.ok) {
const d = await r.json().catch(() => ({}));
setError(d.hint || d.error || `Fehler ${r.status}`);
return;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `nexredirect-report-${new Date().toISOString().slice(0, 10)}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDownloading(false);
}
}} disabled={downloading}>
{downloading ? <><Loader2 className="mr-2 h-3 w-3 animate-spin" />Generiere...</> : "PDF herunterladen"}
</Button>
</div>
</div>

View file

@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { createPdfToken } from "@/lib/pdf-token";
const CHROME_PATHS = [
process.env.NEXREDIRECT_CHROME_PATH,
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
].filter(Boolean) as string[];
async function findChrome(): Promise<string | null> {
const fs = await import("fs/promises");
for (const p of CHROME_PATHS) {
try {
await fs.access(p);
return p;
} catch {}
}
return null;
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const url = new URL(req.url);
const params: Record<string, string> = {};
for (const k of ["days", "title", "summary", "daily", "top", "country", "perDomain", "dead", "hits"]) {
const v = url.searchParams.get(k);
if (v !== null) params[k] = v;
}
const chromePath = await findChrome();
if (!chromePath) {
return NextResponse.json({
error: "chrome_not_found",
hint: "Chromium nicht installiert. Auf dem Server: sudo apt install -y chromium",
}, { status: 500 });
}
const token = createPdfToken(params, 90);
const port = process.env.PORT || "3000";
const reportUrl = `http://127.0.0.1:${port}/r/${token}`;
try {
const puppeteer = await import("puppeteer-core");
const browser = await puppeteer.default.launch({
executablePath: chromePath,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
headless: true,
});
try {
const page = await browser.newPage();
await page.goto(reportUrl, { waitUntil: "networkidle0", timeout: 30_000 });
// Wait until ReportClient signals readiness (charts mounted)
await page.waitForFunction(() => document.body.dataset.pdfReady === "1", { timeout: 10_000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 500));
const buf = await page.pdf({
format: "A4",
printBackground: true,
preferCSSPageSize: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
});
const filename = `nexredirect-report-${new Date().toISOString().slice(0, 10)}.pdf`;
return new NextResponse(buf as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
} finally {
await browser.close().catch(() => {});
}
} catch (e) {
return NextResponse.json({ error: "pdf_render_failed", detail: e instanceof Error ? e.message : String(e) }, { status: 500 });
}
}

View file

@ -1,7 +1,5 @@
"use client";
import { useEffect } from "react";
import { Printer, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { HitsLineChart } from "@/components/charts/HitsLineChart";
import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart";
import { CountryPie } from "@/components/charts/CountryPie";
@ -41,21 +39,22 @@ const NR_LOGO = (
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs>
<rect width="80" height="80" rx="16" fill="#fff" stroke="#e5e7eb" />
<text x="40" y="56" textAnchor="middle" fontFamily="Georgia,'Times New Roman',serif" fontSize="44" fontWeight="400">
<text x="40" y="58" textAnchor="middle" fontFamily="Georgia,'Times New Roman',serif" fontSize="56" fontWeight="400" letterSpacing="-3">
<tspan fill="#09090b">n</tspan>
<tspan fill="url(#rep-grad)">r</tspan>
</text>
<line x1="22" y1="64" x2="58" y2="64" stroke="url(#rep-grad)" strokeWidth="1" opacity="0.5" />
<line x1="18" y1="66" x2="62" y2="66" stroke="url(#rep-grad)" strokeWidth="1.2" opacity="0.5" />
</svg>
);
export function ReportClient({ data }: { data: ReportData; logo?: React.ReactNode }) {
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.get("print") === "1") {
setTimeout(() => window.print(), 1200);
}
// Signal puppeteer that the page is ready (after charts mount)
const id = setTimeout(() => {
(window as unknown as { __pdfReady?: boolean }).__pdfReady = true;
document.body.setAttribute("data-pdf-ready", "1");
}, 1200);
return () => clearTimeout(id);
}, []);
const s = data.sections;
@ -294,15 +293,6 @@ export function ReportClient({ data }: { data: ReportData; logo?: React.ReactNod
`}</style>
<div className="report-shell">
<div className="toolbar no-print flex items-center justify-between border-b border-zinc-800/70 bg-zinc-950/95 px-6 py-3 text-zinc-100 backdrop-blur">
<Link href="/analytics" className="flex items-center gap-2 text-sm text-zinc-300 hover:text-white">
<ArrowLeft className="h-4 w-4" /> Zurück
</Link>
<button onClick={() => window.print()} className="flex items-center gap-2 rounded-md bg-cyan-500 px-4 py-2 text-sm font-medium text-zinc-950 hover:bg-cyan-400">
<Printer className="h-4 w-4" /> Als PDF speichern
</button>
</div>
{/* COVER */}
<div className="report-page">
<div className="rep-hdr">

View file

@ -1,40 +1,28 @@
import { notFound } from "next/navigation";
import { getDb } from "@/lib/db";
import { verifyPdfToken } from "@/lib/pdf-token";
import { ReportClient } from "./ReportClient";
export const dynamic = "force-dynamic";
type SP = { [k: string]: string | string[] | undefined };
export default async function PublicReportPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const p = verifyPdfToken(token);
if (!p) notFound();
function parseDays(sp: SP): number {
const v = sp.days;
const s = Array.isArray(v) ? v[0] : v;
const n = Number(s || 30);
return Number.isFinite(n) && n > 0 ? Math.min(365, n) : 30;
}
function flag(sp: SP, key: string, def = true): boolean {
const v = sp[key];
const s = Array.isArray(v) ? v[0] : v;
if (s === undefined) return def;
return s === "1" || s === "true";
}
export default async function ReportPage({ searchParams }: { searchParams: Promise<SP> }) {
const sp = await searchParams;
const days = parseDays(sp);
const days = Math.min(365, Math.max(1, Number(p.days || 30)));
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDb();
const sections = {
summary: flag(sp, "summary"),
daily: flag(sp, "daily"),
top: flag(sp, "top"),
country: flag(sp, "country"),
perDomain: flag(sp, "perDomain", false),
dead: flag(sp, "dead"),
hits: flag(sp, "hits", false),
title: typeof sp.title === "string" ? sp.title : "Domain-Redirect-Report",
summary: p.summary === "1",
daily: p.daily === "1",
top: p.top === "1",
country: p.country === "1",
perDomain: p.perDomain === "1",
dead: p.dead === "1",
hits: p.hits === "1",
title: typeof p.title === "string" ? p.title : "Domain-Redirect-Report",
};
const totalDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains").get() as { n: number }).n;
@ -45,7 +33,7 @@ export default async function ReportPage({ searchParams }: { searchParams: Promi
const daily = db.prepare(`SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits FROM hits WHERE ts > ? GROUP BY day ORDER BY day`).all(since) as { day: string; hits: number }[];
const top = db.prepare(`SELECT d.domain, COUNT(h.id) AS hits FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.ts > ? GROUP BY d.domain ORDER BY hits DESC`).all(since) as { domain: string; hits: number }[];
const country = db.prepare(`SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits FROM hits WHERE ts > ? GROUP BY country ORDER BY hits DESC`).all(since) as { country: string; hits: number }[];
const dead = db.prepare(`SELECT d.id, d.domain, d.target_url, d.created_at FROM domains d WHERE d.status='active' AND NOT EXISTS (SELECT 1 FROM hits h WHERE h.domain_id = d.id AND h.ts > ?) ORDER BY d.created_at`).all(Date.now() - 90*24*60*60*1000) as { id: number; domain: string; target_url: string | null; created_at: number }[];
const dead = db.prepare(`SELECT d.id, d.domain, d.target_url, d.created_at FROM domains d WHERE d.status='active' AND NOT EXISTS (SELECT 1 FROM hits h WHERE h.domain_id = d.id AND h.ts > ?) ORDER BY d.created_at`).all(Date.now() - 90 * 24 * 60 * 60 * 1000) as { id: number; domain: string; target_url: string | null; created_at: number }[];
const perDomain = sections.perDomain
? (db.prepare(`SELECT d.id, d.domain, d.target_url, d.redirect_code, d.status,
@ -59,23 +47,12 @@ export default async function ReportPage({ searchParams }: { searchParams: Promi
? (db.prepare(`SELECT h.ts, d.domain, h.country, h.path FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.ts > ? ORDER BY h.ts DESC LIMIT 200`).all(since) as { ts: number; domain: string; country: string | null; path: string | null }[])
: [];
if (!totalDomains) notFound();
return (
<ReportClient
data={{
days,
sections,
totalDomains,
activeDomains,
totalHits,
uniqueIps,
daily,
top,
country,
dead,
perDomain,
recentHits,
days, sections,
totalDomains, activeDomains, totalHits, uniqueIps,
daily, top, country, dead, perDomain, recentHits,
generatedAt: Date.now(),
}}
/>

27
lib/pdf-token.ts Normal file
View file

@ -0,0 +1,27 @@
import crypto from "crypto";
const SECRET = process.env.NEXTAUTH_SECRET || "nexredirect-dev-secret-please-change";
function sign(payload: string): string {
return crypto.createHmac("sha256", SECRET).update(payload).digest("hex");
}
export function createPdfToken(params: Record<string, string | number | boolean>, ttlSec = 60): string {
const payload = JSON.stringify({ p: params, exp: Date.now() + ttlSec * 1000 });
const b64 = Buffer.from(payload).toString("base64url");
const sig = sign(b64);
return `${b64}.${sig}`;
}
export function verifyPdfToken(token: string): Record<string, string> | null {
const [b64, sig] = token.split(".");
if (!b64 || !sig) return null;
if (sign(b64) !== sig) return null;
try {
const decoded = JSON.parse(Buffer.from(b64, "base64url").toString("utf8")) as { p: Record<string, string>; exp: number };
if (Date.now() > decoded.exp) return null;
return decoded.p;
} catch {
return null;
}
}

View file

@ -1,7 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
const PUBLIC_PATHS = ["/login", "/setup", "/api/auth", "/api/setup", "/api/v1"];
const PUBLIC_PATHS = ["/login", "/setup", "/api/auth", "/api/setup", "/api/v1", "/r/"];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;

View file

@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ["better-sqlite3", "maxmind"],
serverExternalPackages: ["better-sqlite3", "maxmind", "puppeteer-core"],
};
module.exports = nextConfig;

845
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "corex-nexredirect",
"version": "0.1.13",
"version": "0.1.14",
"license": "MIT",
"overrides": {
"postcss": "^8.5.13",
@ -26,6 +26,7 @@
"maxmind": "^4.3.20",
"next": "^15.5.15",
"next-auth": "^4.24.14",
"puppeteer-core": "^24.42.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.13.3",

View file

@ -29,7 +29,7 @@ echo " Data: $DATA_DIR"
echo "==> Pakete installieren"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates gnupg git debian-keyring debian-archive-keyring apt-transport-https sudo
apt-get install -y -qq curl ca-certificates gnupg git debian-keyring debian-archive-keyring apt-transport-https sudo sqlite3 chromium
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v 2>/dev/null | cut -c2-3)" != "${NODE_MAJOR}" ]]; then
echo "==> Node.js ${NODE_MAJOR} installieren"

View file

@ -15,9 +15,12 @@ cd "$INSTALL_DIR"
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
# Sicherstellen dass sqlite3 für die CLI da ist (idempotent, keine Fehler wenn schon da)
if ! command -v sqlite3 >/dev/null 2>&1; then
apt-get install -y -qq sqlite3 >/dev/null 2>&1 || true
# Sicherstellen dass sqlite3 + chromium für PDF-Export installiert sind (idempotent)
NEED_INSTALL=()
command -v sqlite3 >/dev/null 2>&1 || NEED_INSTALL+=(sqlite3)
[[ -x /usr/bin/chromium || -x /usr/bin/chromium-browser ]] || NEED_INSTALL+=(chromium)
if [[ ${#NEED_INSTALL[@]} -gt 0 ]]; then
apt-get install -y -qq "${NEED_INSTALL[@]}" >/dev/null 2>&1 || true
fi
# Caddyfile-Permissions reparieren (App muss schreiben können)