diff --git a/docker/deploy.sh b/docker/deploy.sh index 4f96c3d..bb0ed8f 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -1,22 +1,39 @@ #!/usr/bin/env bash -# deploy.sh — Build the image and start (or restart) the local stack. -# Usage: ./docker/deploy.sh [TAG] -# TAG defaults to "backerup-website:latest" +# deploy.sh — Build the image locally and start (or restart) the stack. +# +# Usage: +# ./docker/deploy.sh # rebuild & restart, keep existing data +# ./docker/deploy.sh --fresh # rebuild & restart, wipe all data volumes first set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -TAG="${1:-gitea.doomlabs.de/kptltd00m/backerup-website:latest}" +FRESH=false -# 1. Build -"$SCRIPT_DIR/build.sh" "$TAG" +for arg in "$@"; do + case "$arg" in + --fresh) FRESH=true ;; + *) echo "Unknown argument: $arg" >&2; exit 1 ;; + esac +done -# 2. Export the image tag so docker compose picks it up if you override it -export BACKERUP_IMAGE="$TAG" - -# 3. Bring the stack up (recreate on image change) -echo "==> Starting stack via docker compose…" +# Stop and remove existing containers +echo "==> Stopping existing containers…" docker compose \ --file "$SCRIPT_DIR/docker-compose.yml" \ - up --detach --pull never --remove-orphans + down --remove-orphans 2>/dev/null || true + +# Optionally wipe data volumes +if [ "$FRESH" = true ]; then + echo "==> --fresh: removing data volumes…" + docker compose \ + --file "$SCRIPT_DIR/docker-compose.yml" \ + down --volumes 2>/dev/null || true +fi + +# Build image and bring the stack up +echo "==> Building and starting stack via docker compose…" +docker compose \ + --file "$SCRIPT_DIR/docker-compose.yml" \ + up --build --detach --remove-orphans echo "==> Backerup website is running at http://localhost:3000" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index efc5f71..53d525e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,9 @@ services: backerup-website: - image: gitea.doomlabs.de/kptltd00m/backerup-website:latest + build: + context: .. + dockerfile: docker/Dockerfile + image: backerup-website:latest container_name: backerup-website restart: unless-stopped ports: @@ -10,6 +13,9 @@ services: environment: - NODE_ENV=production - NEXT_TELEMETRY_DISABLED=1 + - AUTH_SECRET=tSdfJ57w1IyCpkZijj4UEytTmqk+PWRr45R9BC077cw= + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=admin333 volumes: backerup_data: diff --git a/frontend/app/api/files/[token]/route.ts b/frontend/app/api/files/[token]/route.ts index 292dbab..58433e1 100644 --- a/frontend/app/api/files/[token]/route.ts +++ b/frontend/app/api/files/[token]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' -import { getFile } from '@/lib/store' +import { getFile, deleteFile } from '@/lib/store' +import { getSession } from '@/lib/session' export async function GET( _request: Request, @@ -12,3 +13,21 @@ export async function GET( } return NextResponse.json(record) } + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ token: string }> }, +) { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { token } = await params + try { + await deleteFile(token) + return NextResponse.json({ ok: true }) + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 404 }) + } +} diff --git a/frontend/app/browse/BrowseClient.tsx b/frontend/app/browse/BrowseClient.tsx index 4f22942..f92e07d 100644 --- a/frontend/app/browse/BrowseClient.tsx +++ b/frontend/app/browse/BrowseClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import Link from "next/link"; import type { FileRecord } from "@/lib/store"; @@ -54,20 +54,52 @@ function SortIcon() { ); } +function TrashIcon() { + return ( + + ); +} + export default function BrowseClient({ records: allRecords, groups, + isAdmin = false, }: { records: FileRecord[]; groups: string[]; + isAdmin?: boolean; }) { + const [records, setRecords] = useState(allRecords); const [search, setSearch] = useState(""); const [selectedGroup, setSelectedGroup] = useState(null); const [typeFilter, setTypeFilter] = useState("all"); const [sort, setSort] = useState("newest"); + const [deleting, setDeleting] = useState(null); + + const handleDelete = useCallback(async (token: string, name: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!confirm(`Delete "${name}"?\n\nThis permanently removes the file and cannot be undone.`)) return; + setDeleting(token); + try { + const res = await fetch(`/api/files/${token}`, { method: "DELETE" }); + if (res.ok) { + setRecords((prev) => prev.filter((r) => r.token !== token)); + } else { + const data = await res.json().catch(() => ({})) as { error?: string }; + alert(data.error ?? `Delete failed (${res.status})`); + } + } catch { + alert("Network error – could not delete file"); + } finally { + setDeleting(null); + } + }, []); const filtered = useMemo(() => { - let r = allRecords; + let r = records; if (search.trim()) { const q = search.toLowerCase(); @@ -107,7 +139,7 @@ export default function BrowseClient({ } return sorted; - }, [allRecords, search, selectedGroup, typeFilter, sort]); + }, [records, search, selectedGroup, typeFilter, sort]); const activeFilters = (search.trim() ? 1 : 0) + (selectedGroup ? 1 : 0) + (typeFilter !== "all" ? 1 : 0); @@ -218,9 +250,9 @@ export default function BrowseClient({ {/* ── Results count ── */}

- {filtered.length === allRecords.length - ? `${allRecords.length} file${allRecords.length !== 1 ? "s" : ""} shared by the community` - : `${filtered.length} of ${allRecords.length} files`} + {filtered.length === records.length + ? `${records.length} file${records.length !== 1 ? "s" : ""} shared by the community` + : `${filtered.length} of ${records.length} files`}

{/* ── Cards ── */} @@ -241,11 +273,11 @@ export default function BrowseClient({ ) : (
{filtered.map((r) => ( - +
+
{/* Badges */} @@ -295,20 +327,34 @@ export default function BrowseClient({
- - {/* Meta row */} -
- {formatBytes(r.size)} - · - {formatDate(r.uploadedAt)} - {r.containerName && r.displayName && ( - <> - · - {r.containerName} - - )} -
+ + {/* Card footer: meta row + admin delete inline */} +
+ {formatBytes(r.size)} + · + {formatDate(r.uploadedAt)} + {r.containerName && r.displayName && ( + <> + · + {r.containerName} + + )} + {isAdmin && ( + + )} +
+
))}
)} diff --git a/frontend/app/browse/page.tsx b/frontend/app/browse/page.tsx index 4d374b6..eabeee0 100644 --- a/frontend/app/browse/page.tsx +++ b/frontend/app/browse/page.tsx @@ -1,9 +1,14 @@ import Link from "next/link"; import { readMeta, listGroups } from "@/lib/store"; +import { getSession } from "@/lib/session"; import BrowseClient from "./BrowseClient"; export default async function BrowsePage() { - const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]); + const [allRecords, groups, session] = await Promise.all([ + readMeta(), + listGroups(), + getSession(), + ]); return (
@@ -31,7 +36,7 @@ export default async function BrowsePage() {
{/* Client section: search + filter + cards */} - +
); } diff --git a/frontend/app/files/[token]/DeleteFileButton.tsx b/frontend/app/files/[token]/DeleteFileButton.tsx new file mode 100644 index 0000000..6d89bf0 --- /dev/null +++ b/frontend/app/files/[token]/DeleteFileButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function DeleteFileButton({ token, name }: { token: string; name: string }) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + async function handleDelete() { + if (!confirm(`Delete "${name}"?\n\nThis permanently removes the file and cannot be undone.`)) return; + setLoading(true); + try { + const res = await fetch(`/api/files/${token}`, { method: "DELETE" }); + if (res.ok) { + router.push("/browse"); + router.refresh(); + } else { + const data = await res.json().catch(() => ({})) as { error?: string }; + alert(data.error ?? `Delete failed (${res.status})`); + } + } catch { + alert("Network error – could not delete file"); + } finally { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/frontend/app/files/[token]/page.tsx b/frontend/app/files/[token]/page.tsx index 969cd42..5597dda 100644 --- a/frontend/app/files/[token]/page.tsx +++ b/frontend/app/files/[token]/page.tsx @@ -1,7 +1,9 @@ import Link from "next/link"; import { getFile } from "@/lib/store"; import { notFound } from "next/navigation"; +import { getSession } from "@/lib/session"; import CopyLinkButton from "./CopyLinkButton"; +import DeleteFileButton from "./DeleteFileButton"; function formatBytes(n: number) { if (n < 1024) return `${n} B`; @@ -24,8 +26,9 @@ export default async function FileDetailPage({ params: Promise<{ token: string }>; }) { const { token } = await params; - const record = await getFile(token); + const [record, session] = await Promise.all([getFile(token), getSession()]); if (!record) notFound(); + const isAdmin = session?.role === "admin"; const isBackerup = record.type === "backerup"; const downloadUrl = `/api/files/${token}/download`; @@ -138,6 +141,10 @@ export default async function FileDetailPage({ + {isAdmin && ( + + )} + { + await ensureReady() + const record = await getFile(token) + if (!record) throw new Error('File not found') + + // Remove from DB first + await getDb().execute({ sql: 'DELETE FROM files WHERE token = ?', args: [token] }) + + // Remove upload directory (best-effort — don't fail if already gone) + const dir = path.join(UPLOADS_DIR, token) + await fs.rm(dir, { recursive: true, force: true }) +} + diff --git a/frontend/lib/users.ts b/frontend/lib/users.ts index ba31516..7c1811e 100644 --- a/frontend/lib/users.ts +++ b/frontend/lib/users.ts @@ -152,20 +152,32 @@ export async function countUsers(): Promise { } /** - * Seeds an initial admin user if no users exist yet. - * Reads ADMIN_USERNAME (default "admin") and ADMIN_PASSWORD from environment. + * Ensures the admin user defined by ADMIN_USERNAME / ADMIN_PASSWORD env vars + * exists and has the correct password. Runs on every login attempt so that + * changing the env var (and restarting the container) takes effect immediately. + * Does nothing when ADMIN_PASSWORD is not set. */ export async function seedAdminIfEmpty(): Promise { - const count = await countUsers() - if (count > 0) return const password = process.env.ADMIN_PASSWORD if (!password) return + const username = process.env.ADMIN_USERNAME ?? 'admin' + try { - await createUser(username, password, 'admin') - console.log(`[auth] Seeded initial admin user: "${username}"`) + const existing = await findUserByUsername(username) + if (!existing) { + await createUser(username, password, 'admin') + console.log(`[auth] Created admin user: "${username}"`) + } else { + // Always sync the password so env-var changes take effect after restart + const matches = await verifyPassword(password, existing.passwordHash, existing.salt) + if (!matches) { + await updatePassword(existing.id, password) + console.log(`[auth] Synced password for admin user: "${username}"`) + } + } } catch (e) { - console.error('[auth] Failed to seed admin user:', e) + console.error('[auth] Failed to sync admin user:', e) } }