Compare commits

...

10 commits

35 changed files with 763 additions and 96 deletions

View file

@ -0,0 +1,61 @@
name: Build & Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
token: ${{ github.token }}
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NEXT_TELEMETRY_DISABLED: 1
- name: Package release
run: |
TAG=${GITHUB_REF_NAME}
tar -czf nexredirect-next-${TAG}.tar.gz \
.next public package.json package-lock.json \
scripts/ bin/ systemd/ types/ server.ts next.config.js
sha256sum nexredirect-next-${TAG}.tar.gz > nexredirect-checksums-${TAG}.txt
echo "TAG=$TAG" >> $GITHUB_OUTPUT
id: pkg
- name: Create release
run: |
TAG=${GITHUB_REF_NAME}
curl -fsSL \
-H "Authorization: Bearer ${{ secrets.FORGEJO_TOKEN }}" \
-H "Content-Type: application/json" \
"https://forgejo.mgmt.corexmanagement.de/api/v1/repos/admin_hg/cx-nexredirect/releases" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}"
- name: Upload assets
run: |
TAG=${GITHUB_REF_NAME}
RELEASE_ID=$(curl -s \
-H "Authorization: Bearer ${{ secrets.FORGEJO_TOKEN }}" \
"https://forgejo.mgmt.corexmanagement.de/api/v1/repos/admin_hg/cx-nexredirect/releases/tags/$TAG" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
for FILE in nexredirect-next-${TAG}.tar.gz nexredirect-checksums-${TAG}.txt; do
curl -fsSL \
-H "Authorization: Bearer ${{ secrets.FORGEJO_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
"https://forgejo.mgmt.corexmanagement.de/api/v1/repos/admin_hg/cx-nexredirect/releases/$RELEASE_ID/assets?name=$FILE" \
--data-binary @"$FILE"
done

View file

@ -8,7 +8,7 @@ on:
jobs:
ci:
runs-on: [self-hosted, linux, x64, docker]
runs-on: [self-hosted, Linux, X64, docker]
steps:
- uses: actions/checkout@v4

View file

@ -10,7 +10,7 @@ permissions:
jobs:
build:
runs-on: [self-hosted, linux, x64, docker]
runs-on: [self-hosted, Linux, X64, docker]
steps:
- uses: actions/checkout@v4

View file

@ -1,37 +1,27 @@
name: Security Scan
on:
pull_request:
branches: ["**"]
push:
branches: [main]
pull_request:
branches: ["**"]
permissions:
contents: read
security-events: write
pull-requests: write
jobs:
dependency-review:
name: Dependency Review
runs-on: [self-hosted, linux, x64, docker]
if: github.event_name == "pull_request"
audit:
name: npm Audit
runs-on: [self-hosted, Linux, X64, docker]
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: always
codeql:
name: CodeQL
runs-on: [self-hosted, linux, x64, docker]
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
- uses: actions/setup-node@v4
with:
languages: javascript-typescript
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
node-version: "20"
cache: npm
- run: npm ci --no-audit --no-fund
- run: npm audit --audit-level=high
continue-on-error: true

View file

@ -24,28 +24,28 @@ Self-hosted Domain-Redirect-Server mit Web-Admin-UI und Per-Domain-Analytics. Vi
## Installation
```bash
curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh | sudo bash
curl -sSL https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/main/scripts/install.sh | sudo bash
```
Anschließend Setup unter `http://<server-ip>/setup` aufrufen.
→ Vollständige Anleitung im **[Wiki](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki)**.
→ Vollständige Anleitung im **[Wiki](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki)**.
## Dokumentation
Komplette Doku ist im **[GitHub Wiki](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki)**:
Komplette Doku ist im **[GitHub Wiki](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki)**:
- [Installation](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Installation)
- [DNS Setup](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/DNS-Setup)
- [Domain Management](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Domain-Management)
- [Sunset Pages](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Sunset-Pages)
- [Analytics & Reports](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Analytics-&-Reports)
- [Bot Filter](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Bot-Filter)
- [CLI](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/CLI)
- [API](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/API)
- [Updates](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Updates)
- [Architecture](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Architecture)
- [Troubleshooting](https://github.com/CoreXManagement/CoreX-NexRedirect/wiki/Troubleshooting)
- [Installation](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Installation)
- [DNS Setup](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/DNS-Setup)
- [Domain Management](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Domain-Management)
- [Sunset Pages](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Sunset-Pages)
- [Analytics & Reports](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Analytics-&-Reports)
- [Bot Filter](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Bot-Filter)
- [CLI](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/CLI)
- [API](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/API)
- [Updates](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Updates)
- [Architecture](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Architecture)
- [Troubleshooting](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/wiki/Troubleshooting)
## Stack
@ -59,7 +59,7 @@ Komplette Doku ist im **[GitHub Wiki](https://github.com/CoreXManagement/CoreX-N
## Lokale Entwicklung
```bash
git clone https://github.com/CoreXManagement/CoreX-NexRedirect
git clone https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect
cd CoreX-NexRedirect
npm install
npm run dev

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>
);

View file

@ -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>

View file

@ -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>

View 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));
}

View 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",
},
});
}

View file

@ -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(),
});

View file

@ -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()
);

View file

@ -5,6 +5,24 @@ import { getSetting, setSetting } from "@/lib/db";
const PUBLIC_KEYS = ["base_domain", "admin_email", "update_auto", "update_include_prereleases", "hits_retention_days", "webhook_url"];
function isPrivateUrl(raw: string): boolean {
try {
const { hostname } = new URL(raw);
const h = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
if (/^localhost$/i.test(h)) return true;
if (/^127\./.test(h)) return true;
if (/^10\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
if (/^169\.254\./.test(h)) return true;
if (/^::1$/.test(h)) return true;
if (/^fc[0-9a-f]{2}/i.test(h)) return true;
return false;
} catch {
return true;
}
}
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
@ -20,6 +38,9 @@ export async function PATCH(req: Request) {
const body = await req.json().catch(() => ({}));
for (const [k, v] of Object.entries(body)) {
if (!PUBLIC_KEYS.includes(k)) continue;
if (k === "webhook_url" && v && isPrivateUrl(String(v))) {
return NextResponse.json({ error: "webhook_url must not point to a private or loopback address" }, { status: 422 });
}
setSetting(k, String(v));
}
return NextResponse.json({ ok: true });

View file

@ -2,9 +2,16 @@ import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { getDb } from "@/lib/db";
import { authenticateToken } from "@/lib/api-auth";
import pkg from "../../../../package.json";
export async function GET() {
export async function GET(req: Request) {
const authed = authenticateToken(req);
if (!authed) {
return NextResponse.json({ ok: true });
}
const db = getDb();
const since24h = Date.now() - 24 * 60 * 60 * 1000;

View file

@ -1,6 +1,9 @@
import { NextResponse } from "next/server";
import { requireScope } from "@/lib/api-auth";
import { getUpdateStatus } from "@/lib/updater";
export async function GET() {
export async function GET(req: Request) {
const auth = requireScope(req, "read:domains");
if (auth instanceof NextResponse) return auth;
return NextResponse.json(getUpdateStatus());
}

View file

@ -1,10 +1,10 @@
import fs from "fs/promises";
import path from "path";
import { exec } from "child_process";
import { execFile } from "child_process";
import { promisify } from "util";
import { getDb, getSetting, type DomainRow, type DomainGroupRow } from "./db";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const CADDYFILE_PATH = process.env.NEXREDIRECT_CADDYFILE || "/etc/caddy/Caddyfile";
const CADDY_ADMIN = process.env.CADDY_ADMIN_URL || "http://localhost:2019";
@ -84,7 +84,7 @@ export async function reloadCaddy(): Promise<{ ok: boolean; error?: string }> {
// Try shell `caddy reload` first — it talks to admin API as caddy itself, no Origin-header issues.
try {
await execAsync(`caddy reload --config ${CADDYFILE_PATH} --address localhost:2019`, { timeout: 30_000 });
await execFileAsync("caddy", ["reload", "--config", CADDYFILE_PATH, "--address", "localhost:2019"], { timeout: 30_000 });
return { ok: true };
} catch (e) {
// Fall back to direct admin API POST (older Caddy / different admin URL).

44
lib/chain-check.ts Normal file
View 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 };
}

View file

@ -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;

View file

@ -3,6 +3,7 @@ import { getDb, getSetting } from "./db";
import { checkDomainDns } from "./dns";
import { reloadCaddy } from "./caddy";
import { invalidateRedirectCache } from "./redirect-resolver";
import { applyUpdate, checkForUpdate } from "./updater";
let started = false;
const timers: NodeJS.Timeout[] = [];
@ -61,6 +62,19 @@ async function pruneIpBlocklist() {
getDb().prepare("DELETE FROM ip_blocklist WHERE expires_at < ?").run(Date.now());
}
async function runAutoUpdate() {
if (getSetting("update_auto") !== "true") return;
const status = await checkForUpdate();
if (!status.update_available) return;
console.log(`[job:auto-update] update available (${status.current}${status.latest}), applying`);
const result = await applyUpdate();
if (result.ok) {
console.log(`[job:auto-update] applied ${result.from}${result.to}`);
} else {
console.error(`[job:auto-update] failed: ${result.error}`);
}
}
export function startJobs() {
if (started) return;
started = true;
@ -79,6 +93,11 @@ export function startJobs() {
}, 5 * 60 * 1000);
// IP blocklist cleanup: hourly
schedule(pruneIpBlocklist, HOUR);
// Auto-update check: every 6h, first run 10min after boot
setTimeout(() => {
runAutoUpdate().catch(() => {});
schedule(runAutoUpdate, 6 * HOUR);
}, 10 * 60 * 1000);
console.log("[jobs] background jobs started");
}

View file

@ -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);

View file

@ -1,11 +1,13 @@
import { exec } from "child_process";
import { execFile } from "child_process";
import { promisify } from "util";
import { getSetting, setSetting, getDb } from "./db";
import pkg from "../package.json";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const REPO = process.env.NEXREDIRECT_REPO || "CoreXManagement/CoreX-NexRedirect";
const VALID_TAG = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/;
const REPO = process.env.NEXREDIRECT_REPO || "admin_hg/cx-nexredirect";
export type ReleaseInfo = {
tag_name: string;
@ -41,11 +43,11 @@ function cmpVersions(a: string, b: string): number {
export async function fetchLatestRelease(includePrerelease = false): Promise<ReleaseInfo | null> {
const url = includePrerelease
? `https://api.github.com/repos/${REPO}/releases?per_page=10`
: `https://api.github.com/repos/${REPO}/releases/latest`;
? `https://forgejo.mgmt.corexmanagement.de/api/v1/repos/${REPO}/releases?per_page=10`
: `https://forgejo.mgmt.corexmanagement.de/api/v1/repos/${REPO}/releases/latest`;
try {
const res = await fetch(url, {
headers: { "Accept": "application/vnd.github+json", "User-Agent": "corex-nexredirect" },
headers: { "Accept": "application/json", "User-Agent": "corex-nexredirect" },
});
if (!res.ok) return null;
const data = await res.json();
@ -104,11 +106,14 @@ export async function applyUpdate(): Promise<{ ok: boolean; from: string; to: st
if (!to || !status.update_available) {
return { ok: false, from, to, error: "no_update" };
}
if (!VALID_TAG.test(to)) {
return { ok: false, from, to, error: "invalid_tag" };
}
const updateScript = process.env.NEXREDIRECT_UPDATE_SCRIPT || "/opt/corex-nexredirect/scripts/update.sh";
const start = Date.now();
try {
const { stdout, stderr } = await execAsync(`sudo -n ${updateScript} ${to}`, { timeout: 5 * 60 * 1000 });
const { stdout, stderr } = await execFileAsync("sudo", ["-n", updateScript, to], { timeout: 5 * 60 * 1000 });
getDb().prepare("INSERT INTO update_log (from_version, to_version, ts, status, log) VALUES (?, ?, ?, 'success', ?)")
.run(from, to, start, (stdout + "\n" + stderr).slice(0, 10000));
return { ok: true, from, to };

226
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "corex-nexredirect",
"version": "0.1.32",
"version": "0.1.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "corex-nexredirect",
"version": "0.1.32",
"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",
@ -5295,9 +5511,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View file

@ -1,11 +1,12 @@
{
"name": "corex-nexredirect",
"version": "0.1.32",
"version": "0.1.36",
"license": "MIT",
"overrides": {
"postcss": "^8.5.13",
"uuid": "^11.1.0",
"nodemailer": "^8.0.7"
"nodemailer": "^8.0.7",
"ws": "^8.20.1"
},
"scripts": {
"dev": "tsx watch server.ts",
@ -30,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",
@ -42,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",

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# CoreX NexRedirect — One-line install
# Usage: curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh | sudo bash
# Usage: curl -sSL https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/raw/branch/main/scripts/install.sh | sudo bash
set -euo pipefail
@ -14,7 +14,7 @@ if ! command -v apt-get >/dev/null 2>&1; then
exit 1
fi
REPO="${NEXREDIRECT_REPO:-CoreXManagement/CoreX-NexRedirect}"
REPO="${NEXREDIRECT_REPO:-admin_hg/cx-nexredirect}"
INSTALL_DIR="${NEXREDIRECT_DIR:-/opt/corex-nexredirect}"
DATA_DIR="${NEXREDIRECT_DATA_DIR:-/var/lib/corex-nexredirect}"
SERVICE_USER="nexredirect"
@ -53,7 +53,7 @@ fi
echo "==> Latest Release ermitteln"
TARGET_TAG="${NEXREDIRECT_TAG:-}"
if [[ -z "$TARGET_TAG" ]]; then
TARGET_TAG=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null \
TARGET_TAG=$(curl -fsSL "https://forgejo.mgmt.corexmanagement.de/api/v1/repos/${REPO}/releases/latest" 2>/dev/null \
| grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)
fi
if [[ -z "$TARGET_TAG" ]]; then
@ -70,7 +70,7 @@ if [[ -d "$INSTALL_DIR/.git" ]]; then
git -C "$INSTALL_DIR" reset --hard --quiet "$TARGET_REF" 2>/dev/null || git -C "$INSTALL_DIR" reset --hard --quiet "origin/$TARGET_REF"
else
rm -rf "$INSTALL_DIR"
git clone --quiet "https://github.com/${REPO}.git" "$INSTALL_DIR"
git clone --quiet "https://forgejo.mgmt.corexmanagement.de/${REPO}.git" "$INSTALL_DIR"
git -C "$INSTALL_DIR" checkout --quiet "$TARGET_REF" 2>/dev/null || true
fi
@ -84,8 +84,8 @@ sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm ci --no-audit --no-
echo "==> Prebuilt .next/ versuchen (SHA256-verifiziert)"
PREBUILT_OK=0
if [[ -n "$TARGET_TAG" ]]; then
ASSET_URL="https://github.com/${REPO}/releases/download/${TARGET_TAG}/nexredirect-next-${TARGET_TAG}.tar.gz"
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${TARGET_TAG}/nexredirect-checksums-${TARGET_TAG}.txt"
ASSET_URL="https://forgejo.mgmt.corexmanagement.de/${REPO}/releases/download/${TARGET_TAG}/nexredirect-next-${TARGET_TAG}.tar.gz"
CHECKSUM_URL="https://forgejo.mgmt.corexmanagement.de/${REPO}/releases/download/${TARGET_TAG}/nexredirect-checksums-${TARGET_TAG}.txt"
if curl -fsSL -o /tmp/next-build.tgz "$ASSET_URL" 2>/dev/null; then
VERIFIED=0
if curl -fsSL -o /tmp/next-checksums.txt "$CHECKSUM_URL" 2>/dev/null; then

View file

@ -6,7 +6,7 @@
set -euo pipefail
TAG="${1:-}"
REPO="${NEXREDIRECT_REPO:-CoreXManagement/CoreX-NexRedirect}"
REPO="${NEXREDIRECT_REPO:-admin_hg/cx-nexredirect}"
INSTALL_DIR="${NEXREDIRECT_DIR:-/opt/corex-nexredirect}"
SERVICE_USER="nexredirect"
@ -30,7 +30,7 @@ if [[ -f /etc/caddy/Caddyfile ]]; then
fi
if [[ -z "$TAG" ]]; then
TAG=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null \
TAG=$(curl -fsSL "https://forgejo.mgmt.corexmanagement.de/api/v1/repos/${REPO}/releases/latest" 2>/dev/null \
| grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)
fi
@ -46,8 +46,8 @@ sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm ci --no-audit --no-
PREBUILT_OK=0
if [[ -n "$TAG" ]]; then
ASSET_URL="https://github.com/${REPO}/releases/download/${TAG}/nexredirect-next-${TAG}.tar.gz"
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${TAG}/nexredirect-checksums-${TAG}.txt"
ASSET_URL="https://forgejo.mgmt.corexmanagement.de/${REPO}/releases/download/${TAG}/nexredirect-next-${TAG}.tar.gz"
CHECKSUM_URL="https://forgejo.mgmt.corexmanagement.de/${REPO}/releases/download/${TAG}/nexredirect-checksums-${TAG}.txt"
if curl -fsSL -o /tmp/next-build.tgz "$ASSET_URL" 2>/dev/null; then
VERIFIED=0
if curl -fsSL -o /tmp/next-checksums.txt "$CHECKSUM_URL" 2>/dev/null; then

View file

@ -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",

View file

@ -1,6 +1,6 @@
# Bot Filter
NexRedirect zählt nur "echte" Besucher — Scanner, Crawler und Monitoring-Tools werden serverseitig herausgefiltert. Kombination mehrerer Heuristiken in [`lib/hits.ts`](https://github.com/CoreXManagement/CoreX-NexRedirect/blob/main/lib/hits.ts):
NexRedirect zählt nur "echte" Besucher — Scanner, Crawler und Monitoring-Tools werden serverseitig herausgefiltert. Kombination mehrerer Heuristiken in [`lib/hits.ts`](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/blob/main/lib/hits.ts):
## 1. HTTP-Method

View file

@ -14,7 +14,7 @@ Du hast viele Domains, die nur auf eine andere Webseite weiterleiten sollen —
## Schnellstart
```bash
curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh | sudo bash
curl -sSL https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/main/scripts/install.sh | sudo bash
```
Setup-Wizard danach: `http://<server-ip>/setup`
@ -40,4 +40,4 @@ Next.js 15 + TypeScript + TailwindCSS + better-sqlite3 (eine Datei) + Caddy (Aut
## Lizenz
[MIT](https://github.com/CoreXManagement/CoreX-NexRedirect/blob/main/LICENSE) — viel Spaß damit.
[MIT](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/blob/main/LICENSE) — viel Spaß damit.

View file

@ -10,7 +10,7 @@
## One-Line Install
```bash
curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh | sudo bash
curl -sSL https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/main/scripts/install.sh | sudo bash
```
Das Script:
@ -48,7 +48,7 @@ Wer das Curl-Pipe-Bash nicht mag:
sudo apt install -y caddy nodejs git sqlite3 chromium
sudo useradd --system --home /opt/corex-nexredirect --shell /usr/sbin/nologin nexredirect
sudo mkdir -p /opt/corex-nexredirect /var/lib/corex-nexredirect
sudo git clone https://github.com/CoreXManagement/CoreX-NexRedirect /opt/corex-nexredirect
sudo git clone https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect /opt/corex-nexredirect
sudo chown -R nexredirect:nexredirect /opt/corex-nexredirect /var/lib/corex-nexredirect
sudo -u nexredirect bash -c "cd /opt/corex-nexredirect && npm ci && npm run build"
sudo cp /opt/corex-nexredirect/systemd/corex-nexredirect.service /etc/systemd/system/

View file

@ -36,7 +36,7 @@ Custom Server (`server.ts`) prüft beim Resolve eines Hosts:
Hit wird beim ERSTEN Request gezählt (Notice-Render). Continue-Click zählt NICHT als zweiter Hit (würde sonst doppeln).
HTML-Page ist in [`lib/sunset-html.ts`](https://github.com/CoreXManagement/CoreX-NexRedirect/blob/main/lib/sunset-html.ts) — schwarz auf weiß, kein JS, kein externes Asset, kein Tracking. Funktioniert auch ohne JS-Browser.
HTML-Page ist in [`lib/sunset-html.ts`](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/blob/main/lib/sunset-html.ts) — schwarz auf weiß, kein JS, kein externes Asset, kein Tracking. Funktioniert auch ohne JS-Browser.
## Wichtige Hinweise

View file

@ -131,6 +131,6 @@ sudo systemctl start caddy corex-nexredirect
## Fragen / Bugs
GitHub Issues: https://github.com/CoreXManagement/CoreX-NexRedirect/issues
GitHub Issues: https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/issues
→ Zurück zu [[Home]]

View file

@ -15,6 +15,6 @@
---
[Repo](https://github.com/CoreXManagement/CoreX-NexRedirect)
[Releases](https://github.com/CoreXManagement/CoreX-NexRedirect/releases)
[Issues](https://github.com/CoreXManagement/CoreX-NexRedirect/issues)
[Repo](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect)
[Releases](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/releases)
[Issues](https://forgejo.mgmt.corexmanagement.de/admin_hg/cx-nexredirect/issues)