diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5451490..07656fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,9 +31,17 @@ jobs: tar -czf "nexredirect-next-${TAG}.tar.gz" .next ls -lh "nexredirect-next-${TAG}.tar.gz" + - name: Compute SHA256 + run: | + TAG="${GITHUB_REF_NAME}" + sha256sum "nexredirect-next-${TAG}.tar.gz" > "nexredirect-checksums-${TAG}.txt" + cat "nexredirect-checksums-${TAG}.txt" + - name: Attach to release uses: softprops/action-gh-release@v2 with: - files: nexredirect-next-*.tar.gz + files: | + nexredirect-next-*.tar.gz + nexredirect-checksums-*.txt tag_name: ${{ github.ref_name }} fail_on_unmatched_files: true diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 93fe47f..6cec283 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2 } from "lucide-react"; +import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2, Mail, Send } from "lucide-react"; import { PageHeader } from "@/components/PageHeader"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -34,20 +34,51 @@ export default function SettingsPage() { const [accountId, setAccountId] = useState(""); const [installingGeo, setInstallingGeo] = useState(false); const [geoMsg, setGeoMsg] = useState(""); + const [smtp, setSmtp] = useState<{ smtp_host: string; smtp_port: string; smtp_user: string; smtp_password: string; smtp_from: string; smtp_secure: string }>({ smtp_host: "", smtp_port: "587", smtp_user: "", smtp_password: "", smtp_from: "", smtp_secure: "false" }); + const [smtpSaving, setSmtpSaving] = useState(false); + const [smtpTestTo, setSmtpTestTo] = useState(""); + const [smtpTestBusy, setSmtpTestBusy] = useState(false); + const [smtpTestMsg, setSmtpTestMsg] = useState(""); const [saving, setSaving] = useState(false); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); const [msg, setMsg] = useState(""); async function load() { - const [s, u, g] = await Promise.all([ + const [s, u, g, m] = await Promise.all([ fetch("/api/settings").then((r) => r.json()), fetch("/api/update/check").then((r) => r.json()), fetch("/api/settings/geo").then((r) => r.json()), + fetch("/api/settings/smtp").then((r) => r.json()), ]); setSettings(s); setStatus(u); setGeo(g); + setSmtp({ + smtp_host: m.smtp_host || "", + smtp_port: m.smtp_port || "587", + smtp_user: m.smtp_user || "", + smtp_password: m.smtp_password || "", + smtp_from: m.smtp_from || "", + smtp_secure: m.smtp_secure || "false", + }); + } + + async function saveSmtp() { + setSmtpSaving(true); + try { + await fetch("/api/settings/smtp", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(smtp) }); + } finally { setSmtpSaving(false); } + } + + async function sendTestMail() { + setSmtpTestBusy(true); + setSmtpTestMsg(""); + try { + const r = await fetch("/api/settings/smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: smtpTestTo || undefined }) }); + const d = await r.json(); + setSmtpTestMsg(d.ok ? "Test-Mail verschickt." : `Fehler: ${d.error}`); + } finally { setSmtpTestBusy(false); } } useEffect(() => { load(); }, []); @@ -332,6 +363,62 @@ export default function SettingsPage() { {geoMsg &&

{geoMsg}

} + + + + + SMTP / E-Mail + {smtp.smtp_host + ? konfiguriert + : nicht konfiguriert} + + Für Passwort-Reset-Mails. Postfix/Sendgrid/Mailgun/Hetzner-SMTP — alles was speaks SMTP. + + +
+
+ + setSmtp({ ...smtp, smtp_host: e.target.value })} placeholder="smtp.example.com" /> +
+
+ + setSmtp({ ...smtp, smtp_port: e.target.value })} placeholder="587" /> +
+
+
+
+ + setSmtp({ ...smtp, smtp_user: e.target.value })} autoComplete="off" /> +
+
+ + setSmtp({ ...smtp, smtp_password: e.target.value })} autoComplete="new-password" placeholder={smtp.smtp_password === "***" ? "(unverändert)" : ""} /> +
+
+
+ + setSmtp({ ...smtp, smtp_from: e.target.value })} placeholder='"NexRedirect" ' /> +
+ +
+ +
+ setSmtpTestTo(e.target.value)} /> + +
+
+ {smtpTestMsg &&

{smtpTestMsg}

} +
+
); diff --git a/app/(app)/users/page.tsx b/app/(app)/users/page.tsx index 62e9919..7758798 100644 --- a/app/(app)/users/page.tsx +++ b/app/(app)/users/page.tsx @@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { PasswordField } from "@/components/PasswordField"; -type U = { id: number; email: string; role: "admin" | "user"; created_at: number }; +type U = { id: number; email: string; username: string | null; role: "admin" | "user"; created_at: number }; export default function UsersPage() { const [users, setUsers] = useState([]); @@ -18,6 +18,7 @@ export default function UsersPage() { const [forbidden, setForbidden] = useState(false); const [open, setOpen] = useState(false); const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [password2, setPassword2] = useState(""); const [pwdValid, setPwdValid] = useState(false); @@ -46,10 +47,10 @@ export default function UsersPage() { setBusy(true); setErr(""); try { - const r = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, role }) }); + const r = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, username: username.trim() || undefined, password, role }) }); const d = await r.json(); if (!r.ok) { setErr(d.reason || d.error || "Fehler"); return; } - setEmail(""); setPassword(""); setPassword2(""); setRole("user"); + setEmail(""); setUsername(""); setPassword(""); setPassword2(""); setRole("user"); setOpen(false); load(); } finally { setBusy(false); } @@ -104,6 +105,11 @@ export default function UsersPage() { Neuer BenutzerAccount anlegen.
setEmail(e.target.value)} />
+
+ + setUsername(e.target.value)} placeholder="z.B. hendrik" pattern="[a-zA-Z0-9_-]+" minLength={3} maxLength={40} /> +

Login mit Username möglich. Buchstaben/Ziffern/-_, 3–40 Zeichen.

+
setPwdValid(!!v.ok)} />
@@ -138,6 +144,7 @@ export default function UsersPage() { E-Mail + Benutzername Rolle Erstellt @@ -147,6 +154,7 @@ export default function UsersPage() { {users.map((u) => ( {u.email} + {u.username || "—"} {u.role === "admin" ? admin diff --git a/app/(auth)/forgot/page.tsx b/app/(auth)/forgot/page.tsx new file mode 100644 index 0000000..afd1759 --- /dev/null +++ b/app/(auth)/forgot/page.tsx @@ -0,0 +1,77 @@ +"use client"; +import { useState } from "react"; +import Link from "next/link"; +import { Loader2, ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Logo } from "@/components/Logo"; + +export default function ForgotPage() { + const [identifier, setIdentifier] = useState(""); + const [loading, setLoading] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(""); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const r = await fetch("/api/auth/forgot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: identifier.trim() }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + if (d.error === "smtp_not_configured") setError("Server hat keinen Mail-Versand konfiguriert. Bitte Admin kontaktieren."); + else if (d.error === "rate_limited") setError("Zu viele Versuche. Bitte später erneut."); + else setError("Fehler. Versuche es später nochmal."); + return; + } + setDone(true); + } finally { + setLoading(false); + } + } + + return ( +
+ + +
+ Passwort vergessen + Wir senden dir einen Link zum Zurücksetzen. +
+ + {done ? ( +
+

+ Falls ein Account zu deinen Angaben existiert, wurde eine Mail verschickt. Prüfe auch deinen Spam-Ordner. +

+ +
+ ) : ( + +
+ + setIdentifier(e.target.value)} disabled={loading} autoComplete="username" /> +
+ {error &&

{error}

} + + + + )} +
+
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 833efcd..435056f 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -11,20 +12,20 @@ import { Logo } from "@/components/Logo"; export default function LoginPage() { const router = useRouter(); - const [email, setEmail] = useState(""); + const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!email.trim() || !password) return; + if (!identifier.trim() || !password) return; setLoading(true); setError(""); try { - const result = await signIn("credentials", { email: email.trim(), password, redirect: false }); + const result = await signIn("credentials", { email: identifier.trim(), password, redirect: false }); if (result?.error) { - setError("Ungültige E-Mail oder falsches Passwort."); + setError("Ungültige Anmeldedaten."); } else { router.push("/dashboard"); router.refresh(); @@ -44,20 +45,23 @@ export default function LoginPage() {
NexRedirect - Melde dich mit deinem Admin-Account an + Melde dich mit E-Mail oder Benutzername an
- - setEmail(e.target.value)} disabled={loading} autoComplete="email" /> + + setIdentifier(e.target.value)} disabled={loading} autoComplete="username" />
- +
+ + Vergessen? +
setPassword(e.target.value)} disabled={loading} autoComplete="current-password" />
{error &&

{error}

} -
diff --git a/app/(auth)/reset/[token]/page.tsx b/app/(auth)/reset/[token]/page.tsx new file mode 100644 index 0000000..1bf9cdd --- /dev/null +++ b/app/(auth)/reset/[token]/page.tsx @@ -0,0 +1,92 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { Loader2, ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Logo } from "@/components/Logo"; +import { PasswordField } from "@/components/PasswordField"; + +export default function ResetPage() { + const params = useParams<{ token: string }>(); + const router = useRouter(); + const [validating, setValidating] = useState(true); + const [valid, setValid] = useState(false); + const [pwd, setPwd] = useState(""); + const [pwd2, setPwd2] = useState(""); + const [pwdValid, setPwdValid] = useState(false); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + fetch(`/api/auth/reset?token=${encodeURIComponent(params.token)}`) + .then((r) => r.json()) + .then((d) => setValid(!!d.valid)) + .finally(() => setValidating(false)); + }, [params.token]); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (pwd !== pwd2) { setError("Passwörter stimmen nicht überein."); return; } + if (!pwdValid) { setError("Passwort erfüllt die Anforderungen nicht."); return; } + setBusy(true); + setError(""); + try { + const r = await fetch("/api/auth/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: params.token, password: pwd }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { setError(d.reason || d.error || "Fehler."); return; } + setDone(true); + setTimeout(() => router.push("/login"), 2000); + } finally { setBusy(false); } + } + + return ( +
+ + +
+ Neues Passwort setzen + {validating ? "Token prüfen…" : valid ? "Wähle ein neues Passwort." : "Token ungültig oder abgelaufen."} +
+ + {validating ? ( +
+ ) : !valid ? ( +
+

+ Der Reset-Link ist nicht (mehr) gültig. Fordere einen neuen an. +

+ + +
+ ) : done ? ( +
+

Passwort gesetzt — leite zum Login weiter…

+
+ ) : ( +
+ setPwdValid(!!v.ok)} disabled={busy} /> +
+ + setPwd2(e.target.value)} disabled={busy} /> + {pwd2 && pwd !== pwd2 &&

Passwörter stimmen nicht überein.

} +
+ {error &&

{error}

} + + + )} +
+
+
+ ); +} diff --git a/app/api/auth/forgot/route.ts b/app/api/auth/forgot/route.ts new file mode 100644 index 0000000..bd76982 --- /dev/null +++ b/app/api/auth/forgot/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import crypto from "crypto"; +import { getDb, getSetting, logAudit, type UserRow } from "@/lib/db"; +import { sendMail, getSmtpConfig } from "@/lib/mailer"; +import { checkLimit } from "@/lib/rate-limit"; + +const schema = z.object({ + identifier: z.string().min(1).max(200), +}); + +export async function POST(req: Request) { + const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "anon"; + const rl = checkLimit(`forgot:ip:${ip}`, 5, 60 * 60 * 1000); + if (!rl.allowed) { + return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + } + + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 }); + + const id = parsed.data.identifier.toLowerCase().trim(); + const db = getDb(); + const user = db.prepare("SELECT * FROM users WHERE email = ? OR username = ? LIMIT 1").get(id, id) as UserRow | undefined; + + // Always return ok so attackers can't enumerate users + if (!user) { + await new Promise((r) => setTimeout(r, 300)); + return NextResponse.json({ ok: true }); + } + + if (!getSmtpConfig()) { + return NextResponse.json({ error: "smtp_not_configured" }, { status: 503 }); + } + + // Generate token (raw → URL; hash → DB) + const raw = crypto.randomBytes(32).toString("hex"); + const tokenHash = crypto.createHash("sha256").update(raw).digest("hex"); + const expiresAt = Date.now() + 60 * 60 * 1000; // 60 min + + db.prepare("INSERT INTO password_resets (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?)") + .run(user.id, tokenHash, Date.now(), expiresAt); + + const baseDomain = getSetting("base_domain"); + const proto = baseDomain ? "https" : "http"; + const host = baseDomain || (getSetting("server_ip") || "localhost"); + const link = `${proto}://${host}/reset/${raw}`; + + const subject = "NexRedirect — Passwort zurücksetzen"; + const html = ` +

Hallo,

+

Du (oder jemand mit Kenntnis deines Accounts) hat ein Passwort-Reset für ${user.email} bei NexRedirect angefordert.

+

→ Neues Passwort setzen

+

Der Link ist 60 Minuten gültig und kann nur einmal verwendet werden.

+

Falls du das nicht warst, ignoriere diese Mail. Niemand erhält Zugriff ohne den Link.

+ `; + + const result = await sendMail({ to: user.email, subject, html }); + if (!result.ok) { + // Don't leak details; admin sees in audit + logAudit({ user_id: user.id, user_email: user.email, action: "auth.forgot_send_failed", target_type: "user", target_id: user.id, details: { error: result.error } }); + return NextResponse.json({ error: "send_failed" }, { status: 500 }); + } + + logAudit({ user_id: user.id, user_email: user.email, action: "auth.forgot", target_type: "user", target_id: user.id }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/reset/route.ts b/app/api/auth/reset/route.ts new file mode 100644 index 0000000..71d6a1a --- /dev/null +++ b/app/api/auth/reset/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import crypto from "crypto"; +import bcrypt from "bcryptjs"; +import { getDb, logAudit, type UserRow } from "@/lib/db"; +import { validatePassword } from "@/lib/passwords"; +import { checkLimit } from "@/lib/rate-limit"; + +const schema = z.object({ + token: z.string().min(20).max(200), + password: z.string().min(10), +}); + +export async function POST(req: Request) { + const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "anon"; + const rl = checkLimit(`reset:ip:${ip}`, 10, 60 * 60 * 1000); + if (!rl.allowed) return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 }); + + const tokenHash = crypto.createHash("sha256").update(parsed.data.token).digest("hex"); + const db = getDb(); + const reset = db.prepare("SELECT id, user_id, expires_at, used_at FROM password_resets WHERE token_hash = ? LIMIT 1").get(tokenHash) as + | { id: number; user_id: number; expires_at: number; used_at: number | null } | undefined; + + if (!reset || reset.used_at !== null || reset.expires_at < Date.now()) { + return NextResponse.json({ error: "invalid_or_expired" }, { status: 400 }); + } + + const pwdCheck = await validatePassword(parsed.data.password); + if (!pwdCheck.ok) return NextResponse.json({ error: "weak_password", reason: pwdCheck.reason }, { status: 400 }); + + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(reset.user_id) as UserRow | undefined; + if (!user) return NextResponse.json({ error: "user_gone" }, { status: 400 }); + + const hash = await bcrypt.hash(parsed.data.password, 12); + db.transaction(() => { + db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, user.id); + db.prepare("UPDATE password_resets SET used_at = ? WHERE id = ?").run(Date.now(), reset.id); + // Invalidate all OTHER outstanding resets for this user + db.prepare("UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at IS NULL").run(Date.now(), user.id); + })(); + + logAudit({ user_id: user.id, user_email: user.email, action: "auth.reset", target_type: "user", target_id: user.id }); + return NextResponse.json({ ok: true }); +} + +export async function GET(req: Request) { + // Token-validity check (for the /reset/[token] page) + const url = new URL(req.url); + const token = url.searchParams.get("token"); + if (!token) return NextResponse.json({ valid: false }); + const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); + const reset = getDb().prepare("SELECT expires_at, used_at FROM password_resets WHERE token_hash = ? LIMIT 1").get(tokenHash) as { expires_at: number; used_at: number | null } | undefined; + return NextResponse.json({ valid: !!reset && reset.used_at === null && reset.expires_at > Date.now() }); +} diff --git a/app/api/settings/smtp/route.ts b/app/api/settings/smtp/route.ts new file mode 100644 index 0000000..39b1e40 --- /dev/null +++ b/app/api/settings/smtp/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getDb, getSetting, setSetting } from "@/lib/db"; +import { requireAdmin } from "@/lib/auth-helpers"; +import { sendMail } from "@/lib/mailer"; + +const KEYS = ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_secure"]; + +export async function GET() { + const u = await requireAdmin(); + if (u instanceof NextResponse) return u; + const out: Record = {}; + for (const k of KEYS) { + out[k] = k === "smtp_password" ? (getSetting(k) ? "***" : null) : getSetting(k); + } + return NextResponse.json(out); +} + +const schema = z.object({ + smtp_host: z.string().optional(), + smtp_port: z.string().optional(), + smtp_user: z.string().optional(), + smtp_password: z.string().optional(), + smtp_from: z.string().optional(), + smtp_secure: z.string().optional(), +}); + +export async function PATCH(req: Request) { + const u = await requireAdmin(); + if (u instanceof NextResponse) return u; + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 }); + + for (const [k, v] of Object.entries(parsed.data)) { + if (v === undefined) continue; + // Don't overwrite password if "***" (placeholder) + if (k === "smtp_password" && v === "***") continue; + setSetting(k, v); + } + return NextResponse.json({ ok: true }); +} + +export async function POST(req: Request) { + // Test-Mail + const u = await requireAdmin(); + if (u instanceof NextResponse) return u; + const body = await req.json().catch(() => null) as { to?: string } | null; + const to = body?.to || u.email; + const r = await sendMail({ + to, + subject: "NexRedirect — SMTP-Test", + html: `

Test-Mail von NexRedirect.

Wenn du das liest, läuft SMTP korrekt.

`, + }); + // Use db to silence unused-import (no-op) + void getDb; + return NextResponse.json(r); +} diff --git a/lib/auth.ts b/lib/auth.ts index 4e3904f..235d1e3 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -22,7 +22,7 @@ export const authOptions: NextAuthOptions = { CredentialsProvider({ name: "Credentials", credentials: { - email: { label: "E-Mail", type: "email" }, + email: { label: "E-Mail oder Benutzername", type: "text" }, password: { label: "Passwort", type: "password" }, }, async authorize(credentials, req) { @@ -37,20 +37,19 @@ export const authOptions: NextAuthOptions = { return null; } - const email = credentials.email.toLowerCase().trim(); - // Per-email attempt limit: 5 in 15 minutes (slows targeted brute-force) - const emailLimit = checkLimit(`login:email:${email}`, 5, 15 * 60 * 1000); - if (!emailLimit.allowed) { + const identifier = credentials.email.toLowerCase().trim(); + const idLimit = checkLimit(`login:id:${identifier}`, 5, 15 * 60 * 1000); + if (!idLimit.allowed) { await new Promise((r) => setTimeout(r, 1500)); return null; } + // Match by email OR username const user = getDb() - .prepare("SELECT id, email, password_hash, role, created_at FROM users WHERE email = ? LIMIT 1") - .get(email) as UserRow | undefined; + .prepare("SELECT id, email, username, password_hash, role, created_at FROM users WHERE email = ? OR username = ? LIMIT 1") + .get(identifier, identifier) as UserRow | undefined; if (!user) { - // Constant-time delay to prevent user-enumeration timing await new Promise((r) => setTimeout(r, 200)); return null; } @@ -60,7 +59,7 @@ export const authOptions: NextAuthOptions = { return { id: String(user.id), email: user.email, - name: user.email, + name: user.username || user.email, role: user.role, }; }, diff --git a/lib/db.ts b/lib/db.ts index 8bc26b2..e3121d4 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -236,6 +236,7 @@ export type DomainGroupRow = { export type UserRow = { id: number; email: string; + username: string | null; password_hash: string; role: string; created_at: number; diff --git a/lib/mailer.ts b/lib/mailer.ts new file mode 100644 index 0000000..4363e6e --- /dev/null +++ b/lib/mailer.ts @@ -0,0 +1,47 @@ +import { getSetting } from "./db"; + +type SmtpConfig = { + host: string; + port: number; + user: string; + password: string; + from: string; + secure: boolean; +}; + +export function getSmtpConfig(): SmtpConfig | null { + const host = getSetting("smtp_host"); + if (!host) return null; + return { + host, + port: Number(getSetting("smtp_port") || 587), + user: getSetting("smtp_user") || "", + password: getSetting("smtp_password") || "", + from: getSetting("smtp_from") || getSetting("smtp_user") || "", + secure: getSetting("smtp_secure") === "true", + }; +} + +export async function sendMail(opts: { to: string; subject: string; html: string; text?: string }): Promise<{ ok: boolean; error?: string }> { + const cfg = getSmtpConfig(); + if (!cfg) return { ok: false, error: "smtp_not_configured" }; + try { + const nodemailer = await import("nodemailer"); + const transport = nodemailer.default.createTransport({ + host: cfg.host, + port: cfg.port, + secure: cfg.secure || cfg.port === 465, + auth: cfg.user ? { user: cfg.user, pass: cfg.password } : undefined, + }); + await transport.sendMail({ + from: cfg.from, + to: opts.to, + subject: opts.subject, + html: opts.html, + text: opts.text || opts.html.replace(/<[^>]+>/g, ""), + }); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} diff --git a/package-lock.json b/package-lock.json index 375fe0f..451a2ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "corex-nexredirect", - "version": "0.1.13", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "corex-nexredirect", - "version": "0.1.13", + "version": "0.1.25", "license": "MIT", "dependencies": { "@radix-ui/react-dialog": "^1.1.4", @@ -14,6 +14,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tooltip": "^1.1.8", + "@types/nodemailer": "^8.0.0", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.5.0", "class-variance-authority": "^0.7.1", @@ -22,6 +23,7 @@ "maxmind": "^4.3.20", "next": "^15.5.15", "next-auth": "^4.24.14", + "nodemailer": "^7.0.13", "puppeteer-core": "^24.42.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2115,12 +2117,20 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "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", @@ -3807,6 +3817,16 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5128,7 +5148,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 7e9b5d2..111710b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corex-nexredirect", - "version": "0.1.25", + "version": "0.1.26", "license": "MIT", "overrides": { "postcss": "^8.5.13", @@ -18,6 +18,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tooltip": "^1.1.8", + "@types/nodemailer": "^8.0.0", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.5.0", "class-variance-authority": "^0.7.1", @@ -26,6 +27,7 @@ "maxmind": "^4.3.20", "next": "^15.5.15", "next-auth": "^4.24.14", + "nodemailer": "^7.0.13", "puppeteer-core": "^24.42.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/scripts/install.sh b/scripts/install.sh index 57d699d..fee75fe 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -81,17 +81,33 @@ chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$DATA_DIR" echo "==> Dependencies installieren" sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm ci --no-audit --no-fund" -echo "==> Prebuilt .next/ versuchen" +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" if curl -fsSL -o /tmp/next-build.tgz "$ASSET_URL" 2>/dev/null; then - sudo -u "$SERVICE_USER" -H tar -xzf /tmp/next-build.tgz -C "$INSTALL_DIR" + VERIFIED=0 + if curl -fsSL -o /tmp/next-checksums.txt "$CHECKSUM_URL" 2>/dev/null; then + EXPECTED=$(awk '{print $1}' /tmp/next-checksums.txt | head -n1) + ACTUAL=$(sha256sum /tmp/next-build.tgz | awk '{print $1}') + if [[ -n "$EXPECTED" && "$EXPECTED" == "$ACTUAL" ]]; then + VERIFIED=1 + echo " SHA256 verifiziert." + else + echo " ⚠ SHA256-Mismatch — verwerfe Prebuilt." + fi + rm -f /tmp/next-checksums.txt + fi + if [[ $VERIFIED -eq 1 ]]; then + sudo -u "$SERVICE_USER" -H tar -xzf /tmp/next-build.tgz -C "$INSTALL_DIR" + PREBUILT_OK=1 + echo " Prebuilt aus Release übernommen — Build übersprungen." + fi rm -f /tmp/next-build.tgz - PREBUILT_OK=1 - echo " Prebuilt aus Release übernommen — Build übersprungen." - else - echo " Kein Prebuilt für $TARGET_TAG — baue lokal." + fi + if [[ $PREBUILT_OK -eq 0 ]]; then + echo " Kein verifiziertes Prebuilt für $TARGET_TAG — baue lokal." fi fi if [[ $PREBUILT_OK -eq 0 ]]; then