feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
import { useState, useEffect } from 'react'
import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client'
import { formatBytes } from './ui'
// Audit-Log, Speicher-Bereinigung und der Stand der Texterkennung –
// alles, was im Hintergrund läuft und sonst unbeobachtbar wäre.
// ── Audit Tab ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
/** Aktionsnamen sind maschinenlesbar (`admin.user_delete`). Hier stehen die
* lesbaren Entsprechungen – was nicht drinsteht, wird aus dem Schlüssel
* hergeleitet, damit neue Aktionen nicht als leere Zeile erscheinen. */
const ACTION_LABELS: Record<string, string> = {
'auth.login': 'Signed in',
'auth.login_failed': 'Sign-in failed',
'auth.logout': 'Signed out',
'auth.password_change': 'Changed password',
'app_password.create': 'Created app password',
'app_password.revoke': 'Revoked app password',
'admin.user_create': 'Created user',
'admin.user_update': 'Updated user',
'admin.user_delete': 'Deleted user',
'admin.user_password_reset': 'Reset a password',
'admin.group_create': 'Created group',
'admin.group_update': 'Updated group',
'admin.group_delete': 'Deleted group',
'admin.group_member_add': 'Added group member',
'admin.group_member_remove': 'Removed group member',
'admin.settings_update': 'Changed settings',
'share.create': 'Created share link',
'share.delete': 'Deleted share link',
'file.delete': 'Moved file to trash',
'file.restore': 'Restored file',
'file.trash_empty': 'Emptied trash',
'file.hard_delete': 'Permanently deleted file',
'system.gc': 'Cleaned up orphaned data',
}
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action.split('.').pop()?.replace(/_/g, ' ') ?? action
}
/** Fehlgeschlagene Anmeldungen und endgültige Löschungen sind das, wonach man
* im Protokoll sucht – die bekommen Farbe, der Rest bleibt ruhig. */
function actionColor(action: string): string {
if (action === 'auth.login_failed') return 'var(--color-danger)'
if (action.includes('delete')) return 'var(--color-warning)'
return 'var(--color-text-muted)'
}
/**
* Stand der Texterkennung.
*
* Ohne diese Anzeige ist nicht zu unterscheiden, ob die Volltextsuche nichts
* findet oder den Bestand noch gar nicht gelesen hat. Nach dem ersten Start
* läuft die Erkennung je nach Menge stundenlang – bei einem Scan rund eine
* Minute je Seite –, und eine Suche ohne Treffer sieht in dieser Zeit aus wie
* ein Fehler.
*/
function IndexingStatus() {
const [status, setStatus] = useState<OcrStatus | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
let timer: ReturnType<typeof setTimeout>
async function tick() {
try {
const s = await adminApi.ocrStatus()
if (!alive) return
setStatus(s)
setFailed(false)
// Nur nachfragen, solange sich etwas bewegt. Steht die Warteschlange
// still, ändert sich die Zahl erst beim nächsten Upload – dann wäre
// jede weitere Anfrage verschenkt.
const busy = (s.pending ?? 0) + (s.running ?? 0) > 0
if (busy) timer = setTimeout(tick, 5000)
} catch {
if (alive) setFailed(true)
}
}
tick()
return () => { alive = false; clearTimeout(timer) }
}, [])
if (failed || !status) return null
const pending = status.pending ?? 0
const running = status.running ?? 0
const indexed = status.indexed ?? 0
const bad = status.failed ?? 0
const busy = pending + running > 0
let text: string
if (!status.enabled) {
text = 'Off — file contents are not searchable. Set OCR_ENABLED and make sure ocrmypdf and tesseract are in the image.'
} else if (busy) {
text = `${indexed} indexed · ${pending + running} to go — a scanned page takes about a minute.`
} else if (indexed === 0) {
text = 'No documents indexed yet. PDFs and images are read after upload.'
} else {
text = `${indexed} ${indexed === 1 ? 'document' : 'documents'} indexed — their contents are searchable.`
}
if (bad > 0) {
text += ` ${bad} could not be read.`
}
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
{/* Der Punkt sagt den Zustand auch ohne Lesen: grün fertig, gelb läuft,
gedämpft aus. */}
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: !status.enabled
? 'var(--color-text-muted)'
: busy ? 'var(--color-warning)' : 'var(--color-success)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Full-text indexing
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{text}
</div>
</div>
{busy && (
<div style={{
width: 12, height: 12, flexShrink: 0,
border: '2px solid var(--color-primary)', borderTopColor: 'transparent',
borderRadius: '50%', animation: 'spin 0.7s linear infinite',
}} />
)}
</div>
)
}
/** Der GC läuft von selbst alle 12 Stunden. Der Knopf ist da, damit man sieht,
* was er tut – ein Aufräumjob, der unbeobachtbar Dateien löscht, ist schwer zu
* vertrauen. */
function StorageCleanup() {
const [running, setRunning] = useState(false)
const [result, setResult] = useState<string | null>(null)
async function run() {
setRunning(true)
setResult(null)
try {
const r = await adminApi.runCleanup()
setResult(
r.deleted > 0
? `${r.deleted} orphaned ${r.deleted === 1 ? 'file' : 'files'} removed, ${formatBytes(r.freed_bytes)} freed (${r.scanned} scanned)`
: `Nothing to clean up — all ${r.scanned} stored files are accounted for`,
)
} catch {
setResult('Cleanup failed')
} finally {
setRunning(false)
}
}
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Storage cleanup
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{result ?? 'Removes stored files that no longer belong to any record. Runs automatically every 12 hours.'}
</div>
</div>
<button
onClick={run}
disabled={running}
style={{
padding: '7px 16px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'var(--color-surface)',
color: 'var(--color-text)', fontSize: 12, fontWeight: 600,
cursor: running ? 'default' : 'pointer', opacity: running ? 0.6 : 1,
}}
>
{running ? 'Running…' : 'Run now'}
</button>
</div>
)
}
export default function AuditTab() {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [actions, setActions] = useState<string[]>([])
const [filter, setFilter] = useState('')
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
adminApi.listAuditActions().then(a => setActions(a ?? [])).catch(() => {})
}, [])
useEffect(() => {
setLoading(true)
adminApi.listAudit({ action: filter || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE })
.then(res => { setEntries(res.entries ?? []); setTotal(res.total) })
.catch(() => {})
.finally(() => setLoading(false))
}, [filter, page])
const pages = Math.ceil(total / PAGE_SIZE)
return (
<div>
<IndexingStatus />
<StorageCleanup />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<select
value={filter}
onChange={e => { setFilter(e.target.value); setPage(0) }}
style={{
padding: '7px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
}}
>
<option value="">All actions</option>
{actions.map(a => <option key={a} value={a}>{actionLabel(a)}</option>)}
</select>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{total} {total === 1 ? 'entry' : 'entries'}
</span>
</div>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : entries.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Nothing recorded yet.</p>
) : (
<div style={{
border: '1px solid var(--color-border)', borderRadius: 12, overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--color-surface-2)' }}>
{['When', 'Who', 'Action', 'Target', 'From'].map(h => (
<th key={h} style={{
textAlign: 'left', padding: '9px 12px', fontSize: 11,
fontWeight: 700, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 0.4,
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id} style={{ borderTop: '1px solid var(--color-border)' }}>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{new Date(e.created_at).toLocaleString()}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: 'var(--color-text)' }}>
{e.actor_name}
{/* Fehlt die actor_id, ist das Konto gelöscht – der Name bleibt
stehen, damit der Eintrag lesbar bleibt. Bei einem Fehlversuch
auf einen unbekannten Namen gab es dagegen nie ein Konto,
„gelöscht" wäre dort schlicht falsch. */}
{e.actor_id === null && e.actor_name !== 'system'
&& e.action !== 'auth.login_failed' && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}> (deleted account)</span>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: actionColor(e.action), fontWeight: 500 }}>
{actionLabel(e.action)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', maxWidth: 280 }}>
{e.target_name || e.detail || '—'}
{e.target_name && e.detail && (
<div style={{ fontSize: 11, opacity: 0.8 }}>{e.detail}</div>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
{e.ip ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page === 0 ? 'default' : 'pointer', opacity: page === 0 ? 0.5 : 1,
}}
>
Previous
</button>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
Page {page + 1} of {pages}
</span>
<button
onClick={() => setPage(p => Math.min(pages - 1, p + 1))}
disabled={page >= pages - 1}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page >= pages - 1 ? 'default' : 'pointer', opacity: page >= pages - 1 ? 0.5 : 1,
}}
>
Next
</button>
</div>
)}
</div>
)
}