Compare commits
10 commits
e89ea85804
...
3f07578fb4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f07578fb4 | ||
|
|
8317eed72a | ||
|
|
28ff3d6fae | ||
|
|
99b00674fd | ||
|
|
71915dba04 | ||
|
|
faf054a655 | ||
|
|
302ac2a6a3 | ||
|
|
6d2f8af238 | ||
|
|
facc3eecc6 | ||
|
|
a893c42dd1 |
35 changed files with 763 additions and 96 deletions
61
.forgejo/workflows/release.yml
Normal file
61
.forgejo/workflows/release.yml
Normal 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
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
36
.github/workflows/security.yml
vendored
36
.github/workflows/security.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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;
|
||||
|
|
|
|||
19
lib/jobs.ts
19
lib/jobs.ts
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
226
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue