v0.1.14 — direct PDF download via puppeteer + chromium, fix logo on cover
This commit is contained in:
parent
cb70fbacf5
commit
4bd76c9eda
11 changed files with 1021 additions and 77 deletions
|
|
@ -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>
|
||||
|
|
|
|||
84
app/api/analytics/report.pdf/route.ts
Normal file
84
app/api/analytics/report.pdf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
@ -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
27
lib/pdf-token.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
845
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue