v0.1.36 — feature: chain-check, unique visitors, referer analytics, QR-code, CSV-import UI, catch-all URL
Some checks are pending
Release Build / build (push) Waiting to run
Some checks are pending
Release Build / build (push) Waiting to run
This commit is contained in:
parent
28ff3d6fae
commit
8317eed72a
16 changed files with 580 additions and 20 deletions
|
|
@ -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() {
|
|||
/>
|
||||
|
||||
<div className="space-y-4 p-8">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 pt-6">
|
||||
<MousePointer className="h-8 w-8 text-cyan-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{s.summary.total_hits.toLocaleString("de-DE")}</p>
|
||||
<p className="text-xs text-muted-foreground">Hits (30 Tage)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 pt-6">
|
||||
<Users className="h-8 w-8 text-cyan-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{s.summary.unique_visitors.toLocaleString("de-DE")}</p>
|
||||
<p className="text-xs text-muted-foreground">Unique Visitors (30 Tage)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Hits pro Tag</CardTitle></CardHeader>
|
||||
|
|
@ -78,6 +113,26 @@ export default function AnalyticsPage() {
|
|||
<CardContent><CountryPie data={s.byCountry} /></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Referer</CardTitle>
|
||||
<CardDescription>Quellen der letzten 30 Tage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{s.topReferers.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">Keine Referer-Daten.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-800/70">
|
||||
{s.topReferers.map((r, i) => (
|
||||
<li key={i} className="flex items-center justify-between py-2 text-sm gap-3">
|
||||
<span className="truncate font-mono text-xs text-zinc-300 min-w-0">{r.referer}</span>
|
||||
<Badge variant="zinc" className="shrink-0 tabular-nums">{r.n.toLocaleString("de-DE")}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Tote Domains</CardTitle>
|
||||
<CardDescription>Aktive Domains ohne Hits in den letzten 90 Tagen — kandidaten zum Kündigen</CardDescription>
|
||||
|
|
|
|||
|
|
@ -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<Set<number>>(new Set());
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importFileRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="p-8 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => { setImportResult(null); setImportOpen(true); }}>
|
||||
<Upload className="mr-1 h-3 w-3" />CSV importieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md border border-cyan-500/40 bg-cyan-500/10 px-4 py-2 text-sm">
|
||||
<span>{selected.size} ausgewählt</span>
|
||||
|
|
@ -186,6 +218,46 @@ export function DomainsListClient({ domains }: { domains: DomainListRow[] }) {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={importOpen} onOpenChange={(v) => { if (!v) setImportOpen(false); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domains importieren (CSV)</DialogTitle>
|
||||
<DialogDescription>
|
||||
Spalten: <code className="font-mono text-xs">domain</code>, <code className="font-mono text-xs">target_url</code> (Pflicht) — optional: <code className="font-mono text-xs">redirect_code</code>, <code className="font-mono text-xs">preserve_path</code>, <code className="font-mono text-xs">include_www</code>, <code className="font-mono text-xs">group</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<input ref={importFileRef} type="file" accept=".csv,text/csv" className="block w-full text-sm text-zinc-300 file:mr-3 file:rounded file:border-0 file:bg-zinc-800 file:px-3 file:py-1 file:text-xs file:text-zinc-200 hover:file:bg-zinc-700" />
|
||||
{importResult && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
<span className="text-green-400 font-medium">{importResult.imported} importiert</span>
|
||||
{importResult.errors.length > 0 && <span className="ml-2 text-red-400">{importResult.errors.length} Fehler</span>}
|
||||
</p>
|
||||
{importResult.errors.length > 0 && (
|
||||
<ul className="max-h-40 overflow-y-auto divide-y divide-zinc-800/70 text-xs">
|
||||
{importResult.errors.map((e, i) => (
|
||||
<li key={i} className="py-1 flex gap-2">
|
||||
<span className="text-zinc-500">Z.{e.row}</span>
|
||||
<span className="font-mono text-zinc-300">{e.domain}</span>
|
||||
<span className="text-red-400">{e.error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setImportOpen(false)}>Schließen</Button>
|
||||
<Button onClick={handleImport} disabled={importing}>
|
||||
{importing ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Upload className="mr-2 h-3 w-3" />}
|
||||
Importieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -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 = "" }: {
|
|||
<FileDown className="mr-2 h-3 w-3" />PDF-Report
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="w-full">
|
||||
<a href={`/api/domains/${id}/qr`} download={`${domainName}-qr.svg`}>
|
||||
<QrCode className="mr-2 h-3 w-3" />QR-Code (SVG)
|
||||
</a>
|
||||
</Button>
|
||||
<Button onClick={del} variant="destructive" size="sm" className="w-full" disabled={busy !== null}>
|
||||
{busy === "delete" ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Trash2 className="mr-2 h-3 w-3" />}
|
||||
Domain löschen
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { Loader2, Save, AlertTriangle, CheckCircle2, Link2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Group = { id: number; name: string; target_url: string };
|
||||
|
||||
type ChainResult = { is_chain: boolean; hops: number; final_url?: string; error?: string };
|
||||
|
||||
export function DomainEditForm({
|
||||
domainId,
|
||||
initial,
|
||||
|
|
@ -19,6 +21,7 @@ export function DomainEditForm({
|
|||
redirect_code: number;
|
||||
preserve_path: number;
|
||||
include_www: number;
|
||||
catchall_url: string | null;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
|
@ -28,14 +31,28 @@ export function DomainEditForm({
|
|||
const [redirectCode, setRedirectCode] = useState<301 | 302>((initial.redirect_code as 301 | 302) || 302);
|
||||
const [preservePath, setPreservePath] = useState(!!initial.preserve_path);
|
||||
const [includeWww, setIncludeWww] = useState(!!initial.include_www);
|
||||
const [catchallUrl, setCatchallUrl] = useState(initial.catchall_url ?? "");
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [chain, setChain] = useState<ChainResult | null>(null);
|
||||
const [checkingChain, setCheckingChain] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/groups").then((r) => r.json()).then((d) => setGroups(d.groups || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function checkChain() {
|
||||
setCheckingChain(true);
|
||||
setChain(null);
|
||||
try {
|
||||
const r = await fetch(`/api/domains/${domainId}/chain-check`);
|
||||
setChain(await r.json());
|
||||
} finally {
|
||||
setCheckingChain(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMsg("");
|
||||
|
|
@ -43,6 +60,7 @@ export function DomainEditForm({
|
|||
redirect_code: redirectCode,
|
||||
preserve_path: preservePath,
|
||||
include_www: includeWww,
|
||||
catchall_url: catchallUrl.trim() || null,
|
||||
};
|
||||
if (mode === "url") {
|
||||
body.target_url = targetUrl.trim();
|
||||
|
|
@ -60,6 +78,7 @@ export function DomainEditForm({
|
|||
});
|
||||
if (r.ok) {
|
||||
setMsg("Gespeichert.");
|
||||
setChain(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
|
|
@ -83,7 +102,7 @@ export function DomainEditForm({
|
|||
{mode === "url" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="target" className="text-xs">Ziel-URL</Label>
|
||||
<Input id="target" type="url" value={targetUrl} onChange={(e) => setTargetUrl(e.target.value)} placeholder="https://www.zielseite.de" />
|
||||
<Input id="target" type="url" value={targetUrl} onChange={(e) => { setTargetUrl(e.target.value); setChain(null); }} placeholder="https://www.zielseite.de" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
|
|
@ -100,6 +119,22 @@ export function DomainEditForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{mode === "url" && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="catchall" className="text-xs">Catch-all URL <span className="text-zinc-600">(optional)</span></Label>
|
||||
</div>
|
||||
<Input
|
||||
id="catchall"
|
||||
type="url"
|
||||
value={catchallUrl}
|
||||
onChange={(e) => setCatchallUrl(e.target.value)}
|
||||
placeholder="https://fallback.de — für alle Pfade außer /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Wenn gesetzt: domain.com/ → Ziel-URL, domain.com/irgendwas → Catch-all URL.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Status-Code</Label>
|
||||
|
|
@ -128,10 +163,31 @@ export function DomainEditForm({
|
|||
<p className="text-[11px] text-amber-400">⚠ 301 wird vom Browser gecacht — Folge-Aufrufe werden nicht mehr gezählt.</p>
|
||||
)}
|
||||
|
||||
<Button onClick={save} disabled={saving} size="sm" className="w-full">
|
||||
{saving ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Save className="mr-2 h-3 w-3" />}
|
||||
Speichern
|
||||
</Button>
|
||||
{chain && (
|
||||
<div className={`rounded-md border px-3 py-2 text-xs flex items-start gap-2 ${chain.is_chain ? "border-amber-500/40 bg-amber-500/10 text-amber-200" : "border-green-500/30 bg-green-500/10 text-green-200"}`}>
|
||||
{chain.is_chain
|
||||
? <AlertTriangle className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
: <CheckCircle2 className="h-3 w-3 mt-0.5 shrink-0" />}
|
||||
<span>
|
||||
{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."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={checkChain} disabled={checkingChain || mode !== "url" || !targetUrl.trim()} variant="outline" size="sm" className="flex-1">
|
||||
{checkingChain ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Link2 className="mr-2 h-3 w-3" />}
|
||||
Kette prüfen
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving} size="sm" className="flex-1">
|
||||
{saving ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Save className="mr-2 h-3 w-3" />}
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
{msg && <p className="text-xs text-muted-foreground">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
|
|
@ -114,6 +121,27 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Top Referer</CardTitle>
|
||||
<CardDescription className="text-xs">Quellen der letzten 30 Tage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topReferers.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">Keine Referer-Daten.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-800/70">
|
||||
{topReferers.map((r, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-3 py-1.5 text-xs">
|
||||
<span className="truncate font-mono text-zinc-300 min-w-0">{r.referer}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{r.n.toLocaleString("de-DE")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Hits letzte 30 Tage</CardTitle>
|
||||
|
|
|
|||
|
|
@ -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<Group[]>([]);
|
||||
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() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{targetMode === "url" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="catchall">Catch-all URL <span className="text-xs text-muted-foreground">(optional)</span></Label>
|
||||
<Input
|
||||
id="catchall"
|
||||
type="url"
|
||||
placeholder="https://fallback.de — für alle Pfade außer /"
|
||||
value={catchallUrl}
|
||||
onChange={(e) => setCatchallUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">domain.com/ → Ziel-URL, domain.com/irgendwas → Catch-all URL.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Status-Code</Label>
|
||||
|
|
|
|||
20
app/api/domains/[id]/chain-check/route.ts
Normal file
20
app/api/domains/[id]/chain-check/route.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
31
app/api/domains/[id]/qr/route.ts
Normal file
31
app/api/domains/[id]/qr/route.ts
Normal file
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
44
lib/chain-check.ts
Normal file
44
lib/chain-check.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export type ChainResult = {
|
||||
is_chain: boolean;
|
||||
hops: number;
|
||||
final_url?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function checkRedirectChain(url: string): Promise<ChainResult> {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, ResolvedRedirect> {
|
|||
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);
|
||||
|
|
|
|||
220
package-lock.json
generated
220
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
10
server.ts
10
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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue