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

+373
View File
@@ -0,0 +1,373 @@
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react'
import { filesApi, APIError, type FileItem } from '../api/client'
import { fileKind } from './filetypes'
/**
* Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im
* Hauptbündel und noch einmal 1,3 MB im Arbeiter. Wer nur Bilder ansieht,
* soll das nicht bei jedem Besuch herunterladen – geholt wird es erst beim
* ersten PDF und liegt danach im Cache des Browsers.
*/
const PdfViewer = lazy(() => import('./PdfViewer'))
interface Props {
file: FileItem
/** Blättern innerhalb des Ordners – null, wenn es nichts zu blättern gibt. */
onPrev?: () => void
onNext?: () => void
onClose: () => void
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
onSaved?: () => void
/**
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
* es überhaupt ein eigener Betrachter geworden ist.
*/
searchTerm?: string
}
/**
* Was der Browser selbst anzeigen kann, zeigt er: Bilder, PDF, Text, Video,
* Ton. Der Server liefert diese Typen mit `inline`, alles andere bleibt ein
* Download – die Liste steht in internal/files/handler.go und ist eine
* Positivliste, damit eine hochgeladene HTML-Datei nie im Ursprung der
* Anwendung läuft.
*/
type Kind = 'image' | 'pdf' | 'text' | 'video' | 'audio' | 'none'
/**
* Der Name zählt mit, nicht nur der MIME-Typ: Ältere Dateien liegen als
* application/octet-stream in der Datenbank, weil der Server bis zum
* 23.08.2026 die Endungen .mp3 und .mp4 nicht kannte. Ohne den Namen ließe
* sich eine vorhandene MP3 also nie abspielen.
*/
function kindOf(mime: string, name = ''): Kind {
const m = (mime || '').toLowerCase()
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
// SVG-Datei Skripte enthalten darf.
if (m.includes('svg')) return 'none'
switch (fileKind(mime, name)) {
case 'image': return 'image'
case 'pdf': return 'pdf'
case 'video': return 'video'
case 'audio': return 'audio'
case 'text':
case 'code': return 'text'
case 'doc':
// Nur echte Textdateien, kein .docx – das ist ein ZIP-Archiv.
return m.startsWith('text/') || /\.(txt|md|log)$/i.test(name) ? 'text' : 'none'
default: return 'none'
}
}
/**
* Unterhalb dieser Breite ist der Bildschirm ein Telefon.
*
* Dort zählt jeder Pixel: Ein PDF, das nach Rand und Blätterpfeilen nur noch
* die halbe Breite hat, wird bei 51 % dargestellt – lesbar ist das nicht mehr.
*/
const narrowQuery = '(max-width: 640px)'
function useNarrow(): boolean {
return useSyncExternalStore(
cb => {
const m = window.matchMedia(narrowQuery)
m.addEventListener('change', cb)
return () => m.removeEventListener('change', cb)
},
() => window.matchMedia(narrowQuery).matches,
() => false,
)
}
/** Grenze fürs Nachladen von Text. Darüber wird nicht angezeigt, sondern
* heruntergeladen – ein 200-MB-Protokoll bringt jeden Browser ins Stocken. */
const maxTextBytes = 2 * 1024 * 1024
export function canPreview(file: FileItem): boolean {
return kindOf(file.mime_type, file.name) !== 'none'
}
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
const kind = kindOf(file.mime_type, file.name)
const narrow = useNarrow()
const src = filesApi.inline(file.id)
const [text, setText] = useState<string | null>(null)
const [tooBig, setTooBig] = useState(false)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const dirty = editing && draft !== (text ?? '')
useEffect(() => {
function onKey(e: KeyboardEvent) {
// Im Textfeld gehören die Pfeiltasten dem Schreiben, nicht dem Blättern.
if (editing) {
if (e.key === 'Escape') tryClose()
return
}
if (e.key === 'Escape') { onClose(); return }
// Die Pfeiltasten blättern zwischen Dateien – aber nicht, während
// jemand in einem Feld schreibt. Der PDF-Betrachter hat eine eigene
// Suche; dort setzte ein Pfeil sonst mitten im Wort die nächste Datei
// vor.
const el = document.activeElement
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return
if (e.key === 'ArrowLeft') onPrev?.()
else if (e.key === 'ArrowRight') onNext?.()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onClose, onPrev, onNext, editing, dirty])
useEffect(() => {
if (kind !== 'text') return
setText(null); setTooBig(false)
if (file.size > maxTextBytes) { setTooBig(true); return }
setEditing(false); setSaveError('')
let alive = true
fetch(src, { credentials: 'include' })
.then(r => r.text())
.then(t => { if (alive) { setText(t); setDraft(t) } })
.catch(() => { if (alive) setText('(could not load this file)') })
return () => { alive = false }
}, [src, kind, file.size])
/**
* Gespeichert wird über denselben Weg wie ein Upload: gleicher Name, gleicher
* Ordner. Der Server ersetzt die Datei damit und legt – wenn Versionierung
* für sie eingeschaltet ist – die alte Fassung in die Historie. Ein eigener
* Endpunkt zum Schreiben bräuchte dieselben Prüfungen noch einmal.
*/
async function save() {
setSaving(true); setSaveError('')
try {
const blob = new File([draft], file.name, { type: file.mime_type || 'text/plain' })
await filesApi.upload(blob, file.folder_id ?? undefined)
setText(draft)
setEditing(false)
onSaved?.()
} catch (err) {
// Nur-Lese-Zugriff auf eine geliehene Datei ist der häufigste Fall –
// deshalb hier ausgeschrieben statt "403".
setSaveError(
err instanceof APIError && err.status === 403
? 'You only have read access to this file.'
: err instanceof Error ? err.message : 'Could not save',
)
} finally {
setSaving(false)
}
}
function tryClose() {
if (dirty && !confirm('Discard your changes?')) return
onClose()
}
return (
<div
onClick={tryClose}
style={{
position: 'fixed', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.82)',
display: 'flex', flexDirection: 'column',
}}
>
{/* Kopfzeile */}
<div
onClick={e => e.stopPropagation()}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px', flexShrink: 0,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}
>
<span style={{
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{file.name}
</span>
{kind === 'text' && !tooBig && text !== null && (
editing ? (
<>
{saveError && (
<span style={{ fontSize: 12, color: 'var(--color-danger)' }}>{saveError}</span>
)}
<button
onClick={() => { setDraft(text ?? ''); setEditing(false); setSaveError('') }}
disabled={saving}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={save}
disabled={saving || !dirty}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
border: 'none', background: 'var(--color-primary)', color: '#fff',
cursor: saving || !dirty ? 'default' : 'pointer',
opacity: saving || !dirty ? 0.6 : 1,
}}
>
{saving ? 'Saving…' : 'Save'}
</button>
</>
) : (
<button
onClick={() => setEditing(true)}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', cursor: 'pointer',
}}
>
Edit
</button>
)
)}
<a
href={filesApi.download(file.id)}
download={file.name}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
background: 'var(--color-primary)', color: '#fff', textDecoration: 'none',
}}
>
Download
</a>
<button
onClick={tryClose}
title="Close (Esc)"
style={{
padding: 6, borderRadius: 8, border: 'none', background: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Inhalt */}
<div
onClick={e => e.stopPropagation()}
style={{
flex: 1, minHeight: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
// Ein PDF bekommt fast den ganzen Platz: Es bringt seinen eigenen
// weißen Rand mit, ein zweiter darum herum verschenkt nur Breite.
padding: kind === 'pdf' ? (narrow ? 2 : 8) : 16,
}}
>
{/* Am Telefon blättert man nicht per Pfeil zur nächsten Datei, während
man ein Dokument liest – dort lägen die Knöpfe mitten auf dem Text
und würden beim Wischen getroffen. */}
{onPrev && !(kind === 'pdf' && narrow) && <PagerButton dir="prev" onClick={onPrev} />}
{kind === 'image' && (
<img src={src} alt={file.name}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
)}
{kind === 'pdf' && (
// Eigener Betrachter statt des eingebauten im <iframe>: Chrome auf
// Android zeigte darin gar nichts an, und ein Suchtreffer ließ sich
// ihm nicht mitteilen. Siehe PdfViewer.tsx.
<Suspense fallback={
<span style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</span>
}>
<PdfViewer url={src} fileId={file.id} searchTerm={searchTerm} />
</Suspense>
)}
{kind === 'video' && (
<video src={src} controls autoPlay
style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} />
)}
{kind === 'audio' && (
<audio src={src} controls style={{ width: 'min(520px, 90%)' }} />
)}
{/* Text bekommt die Form eines Blattes statt der ganzen Fläche: über
die volle Breite gelesen springt das Auge beim Zeilenwechsel, und
eine kurze Notiz auf einem riesigen Feld wirkt erschlagend. */}
{kind === 'text' && (
<div style={{
width: '100%', maxWidth: 820, height: '100%', maxHeight: 680,
display: 'flex', flexDirection: 'column',
borderRadius: 10, overflow: 'hidden',
border: `1px solid ${editing ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: 'var(--color-surface)',
boxShadow: '0 12px 40px rgba(0,0,0,0.35)',
}}>
{editing ? (
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
spellCheck={false}
autoFocus
style={{
flex: 1, resize: 'none', width: '100%',
padding: '18px 20px', border: 'none',
background: 'transparent', color: 'var(--color-text)',
fontFamily: 'ui-monospace, monospace',
fontSize: 13, lineHeight: 1.65, outline: 'none',
}}
/>
) : (
<pre style={{
flex: 1, margin: 0, overflow: 'auto',
padding: '18px 20px',
color: 'var(--color-text)',
fontFamily: 'ui-monospace, monospace',
fontSize: 13, lineHeight: 1.65,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{tooBig
? 'This file is too large to show here – use Download.'
: text ?? 'Loading…'}
</pre>
)}
</div>
)}
{onNext && !(kind === 'pdf' && narrow) && <PagerButton dir="next" onClick={onNext} />}
</div>
</div>
)
}
function PagerButton({ dir, onClick }: { dir: 'prev' | 'next'; onClick: () => void }) {
return (
<button
onClick={onClick}
title={dir === 'prev' ? 'Previous (←)' : 'Next (→)'}
style={{
position: 'absolute', [dir === 'prev' ? 'left' : 'right']: 12,
top: '50%', transform: 'translateY(-50%)',
width: 40, height: 40, borderRadius: '50%',
border: 'none', background: 'rgba(0,0,0,0.5)', color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points={dir === 'prev' ? '15 18 9 12 15 6' : '9 18 15 12 9 6'} />
</svg>
</button>
)
}