diff --git a/app/(app)/analytics/page.tsx b/app/(app)/analytics/page.tsx index a47e761..4eb4b64 100644 --- a/app/(app)/analytics/page.tsx +++ b/app/(app)/analytics/page.tsx @@ -7,7 +7,7 @@ import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart"; import { CountryPie } from "@/components/charts/CountryPie"; import { ExportPdfButton } from "./ExportPdfButton"; import { Button } from "@/components/ui/button"; -import { FileDown } from "lucide-react"; +import { FileDown, Users, MousePointer } from "lucide-react"; export const dynamic = "force-dynamic"; @@ -21,6 +21,11 @@ function getStats() { GROUP BY day ORDER BY day `).all(since) as { day: string; hits: number }[]; + const summary = db.prepare(` + SELECT COUNT(*) AS total_hits, COUNT(DISTINCT ip_hash) AS unique_visitors + FROM hits WHERE ts > ? + `).get(since) as { total_hits: number; unique_visitors: 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 @@ -34,6 +39,15 @@ function getStats() { GROUP BY country ORDER BY hits DESC LIMIT 8 `).all(since) as { country: string; hits: number }[]; + const topReferers = db.prepare(` + SELECT referer, COUNT(*) AS n + FROM hits + WHERE ts > ? AND referer IS NOT NULL AND referer != '' + GROUP BY referer + ORDER BY n DESC + LIMIT 15 + `).all(since) as { referer: string; n: number }[]; + const dead = db.prepare(` SELECT d.id, d.domain, d.target_url, d.created_at FROM domains d @@ -42,7 +56,7 @@ function getStats() { 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 }[]; - return { daily, top, byCountry, dead }; + return { daily, summary, top, byCountry, topReferers, dead }; } export default function AnalyticsPage() { @@ -64,6 +78,27 @@ export default function AnalyticsPage() { />
+
+ + + +
+

{s.summary.total_hits.toLocaleString("de-DE")}

+

Hits (30 Tage)

+
+
+
+ + + +
+

{s.summary.unique_visitors.toLocaleString("de-DE")}

+

Unique Visitors (30 Tage)

+
+
+
+
+
Hits pro Tag @@ -78,6 +113,26 @@ export default function AnalyticsPage() { + + Top Referer + Quellen der letzten 30 Tage + + + {s.topReferers.length === 0 ? ( +

Keine Referer-Daten.

+ ) : ( +
    + {s.topReferers.map((r, i) => ( +
  • + {r.referer} + {r.n.toLocaleString("de-DE")} +
  • + ))} +
+ )} +
+
+ Tote Domains Aktive Domains ohne Hits in den letzten 90 Tagen — kandidaten zum Kündigen diff --git a/app/(app)/domains/BulkSunsetClient.tsx b/app/(app)/domains/BulkSunsetClient.tsx index 45a2e71..2d4ce4f 100644 --- a/app/(app)/domains/BulkSunsetClient.tsx +++ b/app/(app)/domains/BulkSunsetClient.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { CheckCircle2, Clock, AlertCircle, ArrowRight, Loader2, Sunset, Trash2 } from "lucide-react"; +import { CheckCircle2, Clock, AlertCircle, ArrowRight, Loader2, Sunset, Trash2, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; @@ -36,10 +36,16 @@ function timeAgo(ts: number | null): string { return `vor ${d} d`; } +type ImportResult = { imported: number; errors: { row: number; domain: string; error: string }[] }; + export function DomainsListClient({ domains }: { domains: DomainListRow[] }) { const router = useRouter(); const [selected, setSelected] = useState>(new Set()); const [bulkOpen, setBulkOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [importResult, setImportResult] = useState(null); + const [importing, setImporting] = useState(false); + const importFileRef = useRef(null); const [enabled, setEnabled] = useState(true); const [title, setTitle] = useState("Diese Domain wird abgeschaltet"); const [message, setMessage] = useState(""); @@ -47,6 +53,26 @@ export function DomainsListClient({ domains }: { domains: DomainListRow[] }) { const [sunsetDate, setSunsetDate] = useState(""); const [saving, setSaving] = useState(false); + async function handleImport() { + const file = importFileRef.current?.files?.[0]; + if (!file) return; + setImporting(true); + setImportResult(null); + try { + const text = await file.text(); + const r = await fetch("/api/domains/import.csv", { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: text, + }); + const d = await r.json(); + setImportResult(d); + if (d.imported > 0) router.refresh(); + } finally { + setImporting(false); + } + } + function toggle(id: number) { setSelected((cur) => { const next = new Set(cur); @@ -103,6 +129,12 @@ export function DomainsListClient({ domains }: { domains: DomainListRow[] }) { return (
+
+ +
+ {selected.size > 0 && (
{selected.size} ausgewählt @@ -186,6 +218,46 @@ export function DomainsListClient({ domains }: { domains: DomainListRow[] }) { )} + { if (!v) setImportOpen(false); }}> + + + Domains importieren (CSV) + + Spalten: domain, target_url (Pflicht) — optional: redirect_code, preserve_path, include_www, group + + +
+ + {importResult && ( +
+

+ {importResult.imported} importiert + {importResult.errors.length > 0 && {importResult.errors.length} Fehler} +

+ {importResult.errors.length > 0 && ( +
    + {importResult.errors.map((e, i) => ( +
  • + Z.{e.row} + {e.domain} + {e.error} +
  • + ))} +
+ )} +
+ )} +
+ + + + +
+
+ diff --git a/app/(app)/domains/[id]/DomainActions.tsx b/app/(app)/domains/[id]/DomainActions.tsx index 49e98a8..118935f 100644 --- a/app/(app)/domains/[id]/DomainActions.tsx +++ b/app/(app)/domains/[id]/DomainActions.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { RefreshCcw, Trash2, Loader2, ExternalLink, FileDown } from "lucide-react"; +import { RefreshCcw, Trash2, Loader2, ExternalLink, FileDown, QrCode } from "lucide-react"; import { Button } from "@/components/ui/button"; export function DomainActions({ id, status, hitsTotal = 0, domainName = "" }: { id: number; status: string; hitsTotal?: number; domainName?: string }) { @@ -52,6 +52,11 @@ export function DomainActions({ id, status, hitsTotal = 0, domainName = "" }: { PDF-Report + + {chain && ( +
+ {chain.is_chain + ? + : } + + {chain.is_chain + ? `Redirect-Kette: ${chain.hops} Hop${chain.hops !== 1 ? "s" : ""} → ${chain.final_url ?? "?"}` + : chain.error + ? `Prüfung fehlgeschlagen: ${chain.error}` + : "Kein Redirect — Ziel antwortet direkt."} + +
+ )} + +
+ + +
{msg &&

{msg}

}
); diff --git a/app/(app)/domains/[id]/page.tsx b/app/(app)/domains/[id]/page.tsx index 4f22e5f..080d967 100644 --- a/app/(app)/domains/[id]/page.tsx +++ b/app/(app)/domains/[id]/page.tsx @@ -32,6 +32,12 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i const visitors30d = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(domain.id, since30d) as { n: number }).n; const visitorsTotal = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE domain_id = ?").get(domain.id) as { n: number }).n; + const topReferers = db.prepare(` + SELECT referer, COUNT(*) AS n + FROM hits WHERE domain_id = ? AND ts > ? AND referer IS NOT NULL AND referer != '' + GROUP BY referer ORDER BY n DESC LIMIT 10 + `).all(domain.id, since30d) as { referer: string; n: number }[]; + const dailyRows = db.prepare(` SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits FROM hits WHERE domain_id = ? AND ts > ? @@ -73,6 +79,7 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i redirect_code: domain.redirect_code, preserve_path: domain.preserve_path, include_www: domain.include_www, + catchall_url: domain.catchall_url, }} /> @@ -114,6 +121,27 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i + + + Top Referer + Quellen der letzten 30 Tage + + + {topReferers.length === 0 ? ( +

Keine Referer-Daten.

+ ) : ( +
    + {topReferers.map((r, i) => ( +
  • + {r.referer} + {r.n.toLocaleString("de-DE")} +
  • + ))} +
+ )} +
+
+ Hits letzte 30 Tage diff --git a/app/(app)/domains/new/page.tsx b/app/(app)/domains/new/page.tsx index ec2025f..5a29c84 100644 --- a/app/(app)/domains/new/page.tsx +++ b/app/(app)/domains/new/page.tsx @@ -34,6 +34,7 @@ export default function NewDomainPage() { const [redirectCode, setRedirectCode] = useState<301 | 302>(302); const [preservePath, setPreservePath] = useState(true); const [includeWww, setIncludeWww] = useState(true); + const [catchallUrl, setCatchallUrl] = useState(""); const [groups, setGroups] = useState([]); const [creating, setCreating] = useState(false); const [error, setError] = useState(""); @@ -58,6 +59,7 @@ export default function NewDomainPage() { redirect_code: redirectCode, preserve_path: preservePath, include_www: includeWww, + catchall_url: catchallUrl.trim() || null, }; if (targetMode === "url") body.target_url = targetUrl.trim(); else body.group_id = groupId; @@ -143,6 +145,20 @@ export default function NewDomainPage() {
)} + {targetMode === "url" && ( +
+ + setCatchallUrl(e.target.value)} + /> +

domain.com/ → Ziel-URL, domain.com/irgendwas → Catch-all URL.

+
+ )} +
diff --git a/app/api/domains/[id]/chain-check/route.ts b/app/api/domains/[id]/chain-check/route.ts new file mode 100644 index 0000000..a7b38ab --- /dev/null +++ b/app/api/domains/[id]/chain-check/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getDb } from "@/lib/db"; +import { checkRedirectChain } from "@/lib/chain-check"; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const { id } = await params; + const row = getDb() + .prepare("SELECT target_url FROM domains WHERE id = ?") + .get(Number(id)) as { target_url: string | null } | undefined; + + if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 }); + if (!row.target_url) return NextResponse.json({ is_chain: false, hops: 0 }); + + return NextResponse.json(await checkRedirectChain(row.target_url)); +} diff --git a/app/api/domains/[id]/qr/route.ts b/app/api/domains/[id]/qr/route.ts new file mode 100644 index 0000000..4bec429 --- /dev/null +++ b/app/api/domains/[id]/qr/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getDb } from "@/lib/db"; +import QRCode from "qrcode"; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const { id } = await params; + const row = getDb() + .prepare("SELECT domain FROM domains WHERE id = ?") + .get(Number(id)) as { domain: string } | undefined; + + if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 }); + + const svg = await QRCode.toString(`https://${row.domain}`, { + type: "svg", + margin: 2, + color: { dark: "#ffffff", light: "#09090b" }, + }); + + return new NextResponse(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Content-Disposition": `attachment; filename="${row.domain}-qr.svg"`, + "Cache-Control": "no-cache", + }, + }); +} diff --git a/app/api/domains/[id]/route.ts b/app/api/domains/[id]/route.ts index a1be90f..f17631a 100644 --- a/app/api/domains/[id]/route.ts +++ b/app/api/domains/[id]/route.ts @@ -20,6 +20,7 @@ const updateSchema = z.object({ redirect_code: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).optional(), preserve_path: z.boolean().optional(), include_www: z.boolean().optional(), + catchall_url: z.string().url().nullable().optional(), sunset_config: sunsetSchema.nullable().optional(), }); diff --git a/app/api/domains/route.ts b/app/api/domains/route.ts index 2fda146..b983f69 100644 --- a/app/api/domains/route.ts +++ b/app/api/domains/route.ts @@ -29,6 +29,7 @@ const createSchema = z.object({ redirect_code: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).default(301), preserve_path: z.boolean().default(true), include_www: z.boolean().default(true), + catchall_url: z.string().url().optional().nullable(), }); export async function POST(req: Request) { @@ -40,7 +41,7 @@ export async function POST(req: Request) { if (!parsed.success) { return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 }); } - const { domain, target_url, group_id, redirect_code, preserve_path, include_www } = parsed.data; + const { domain, target_url, group_id, redirect_code, preserve_path, include_www, catchall_url } = parsed.data; if (!isValidDomain(domain)) return NextResponse.json({ error: "invalid_domain" }, { status: 400 }); if (!target_url && !group_id) return NextResponse.json({ error: "target_required" }, { status: 400 }); @@ -50,8 +51,8 @@ export async function POST(req: Request) { if (existing) return NextResponse.json({ error: "domain_exists" }, { status: 409 }); const result = db - .prepare(`INSERT INTO domains (domain, status, target_url, group_id, redirect_code, preserve_path, include_www, created_by, created_at) - VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?)`) + .prepare(`INSERT INTO domains (domain, status, target_url, group_id, redirect_code, preserve_path, include_www, catchall_url, created_by, created_at) + VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`) .run( domain, target_url ?? null, @@ -59,6 +60,7 @@ export async function POST(req: Request) { redirect_code, preserve_path ? 1 : 0, include_www ? 1 : 0, + catchall_url ?? null, Number(session.user.id), Date.now() ); diff --git a/lib/chain-check.ts b/lib/chain-check.ts new file mode 100644 index 0000000..83a78d3 --- /dev/null +++ b/lib/chain-check.ts @@ -0,0 +1,44 @@ +export type ChainResult = { + is_chain: boolean; + hops: number; + final_url?: string; + error?: string; +}; + +export async function checkRedirectChain(url: string): Promise { + let current = url; + let hops = 0; + const maxHops = 5; + + try { + while (hops < maxHops) { + const res = await fetch(current, { + method: "HEAD", + redirect: "manual", + signal: AbortSignal.timeout(8_000), + headers: { "User-Agent": "corex-nexredirect/chain-check" }, + }); + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get("location"); + if (!location) break; + try { + current = new URL(location, current).href; + } catch { + break; + } + hops++; + } else { + break; + } + } + } catch (e) { + return { + is_chain: hops > 0, + hops, + final_url: hops > 0 ? current : undefined, + error: e instanceof Error ? e.message : String(e), + }; + } + + return { is_chain: hops > 0, hops, final_url: hops > 0 ? current : undefined }; +} diff --git a/lib/db.ts b/lib/db.ts index 9c5909f..69a8384 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -165,6 +165,11 @@ function runMigrations(db: Database.Database) { db.exec("ALTER TABLE users ADD COLUMN username TEXT"); try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username) WHERE username IS NOT NULL"); } catch {} } + + // catchall_url: redirect non-root paths to a separate target + if (!hasColumn(db, "domains", "catchall_url")) { + db.exec("ALTER TABLE domains ADD COLUMN catchall_url TEXT"); + } } export function getSetting(key: string): string | null { @@ -215,6 +220,7 @@ export type DomainRow = { redirect_code: number; preserve_path: number; include_www: number; + catchall_url: string | null; created_by: number | null; created_at: number; verified_at: number | null; diff --git a/lib/redirect-resolver.ts b/lib/redirect-resolver.ts index 5a9ea0b..193e121 100644 --- a/lib/redirect-resolver.ts +++ b/lib/redirect-resolver.ts @@ -6,6 +6,7 @@ export type ResolvedRedirect = { target_url: string; redirect_code: number; preserve_path: boolean; + catchall_url: string | null; sunset: SunsetConfig | null; }; @@ -29,6 +30,7 @@ function loadCache(): Map { target_url: target, redirect_code: d.redirect_code, preserve_path: !!d.preserve_path, + catchall_url: d.catchall_url ?? null, sunset: parseSunset(d), }; m.set(d.domain.toLowerCase(), r); diff --git a/package-lock.json b/package-lock.json index 20cf856..bd8e43d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "corex-nexredirect", - "version": "0.1.34", + "version": "0.1.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "corex-nexredirect", - "version": "0.1.34", + "version": "0.1.35", "license": "MIT", "dependencies": { "@radix-ui/react-dialog": "^1.1.4", @@ -25,6 +25,7 @@ "next-auth": "^4.24.14", "nodemailer": "^8.0.7", "puppeteer-core": "^24.42.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^2.13.3", @@ -37,6 +38,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.11", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.4.21", @@ -2131,6 +2133,16 @@ "@types/node": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2558,6 +2570,15 @@ "node": "*" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2884,6 +2905,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2956,6 +2986,12 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -3228,6 +3264,19 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -3545,6 +3594,18 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -3893,6 +3954,42 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -3925,6 +4022,15 @@ "node": ">= 14" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3973,6 +4079,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", @@ -4274,6 +4389,89 @@ "node": ">=18" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4514,6 +4712,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -4615,6 +4819,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -5271,6 +5481,12 @@ "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "license": "Apache-2.0" }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index d3f8be0..a3d795e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corex-nexredirect", - "version": "0.1.35", + "version": "0.1.36", "license": "MIT", "overrides": { "postcss": "^8.5.13", @@ -31,6 +31,7 @@ "next-auth": "^4.24.14", "nodemailer": "^8.0.7", "puppeteer-core": "^24.42.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^2.13.3", @@ -43,6 +44,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.11", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.4.21", diff --git a/server.ts b/server.ts index 68a6190..cb38296 100644 --- a/server.ts +++ b/server.ts @@ -81,9 +81,13 @@ app.prepare().then(() => { return; } - const target = resolved.preserve_path - ? resolved.target_url + (parsedUrl.path || "") - : resolved.target_url; + const reqPathname = parsedUrl.pathname || "/"; + const hasNonRootPath = reqPathname.length > 1; + const target = resolved.catchall_url && hasNonRootPath + ? resolved.catchall_url + : resolved.preserve_path + ? resolved.target_url + (parsedUrl.path || "") + : resolved.target_url; res.writeHead(resolved.redirect_code || 302, { Location: target, "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",