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

+115
View File
@@ -0,0 +1,115 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { publicApi } from '../api/client'
import { applyTheme } from '../theme'
/**
* Hält die serverseitigen Anzeige-Einstellungen (Name, Farben) und wendet das
* Farbschema an.
*
* Bewusst über den öffentlichen Endpunkt: so ist schon die Login-Seite im
* richtigen Namen und Farbschema, bevor überhaupt jemand angemeldet ist.
* `refresh()` holt den gespeicherten Stand erneut – das nutzt das Admin-Panel
* nach dem Speichern und um eine nicht gespeicherte Vorschau zu verwerfen.
*/
interface AppSettingsValue {
cloudName: string
/** Leer, wenn kein Logo hinterlegt ist. */
logoUrl: string
/** Leer, wenn kein eigenes App-Symbol hinterlegt ist. */
faviconUrl: string
/** Die 192er-Fassung desselben Symbols – Vorschau im Admin-Bereich. */
appIconUrl: string
/** Die vom Administrator gesetzten Farben (Grundlage, wenn der Nutzer
* keine eigenen hat). */
globalColors: { primary?: string; accent?: string }
/** Gibt es einen Office-Server? Steuert nur, ob der Menuepunkt erscheint. */
officeEnabled: boolean
refresh: () => Promise<void>
}
const Ctx = createContext<AppSettingsValue>({
cloudName: 'Aether',
officeEnabled: false,
logoUrl: '',
faviconUrl: '',
appIconUrl: '',
globalColors: {},
refresh: async () => {},
})
/**
* Tauscht das Tab-Symbol aus. Der Eintrag in index.html wird ersetzt statt
* ergaenzt – haengt man einen zweiten an, sucht sich der Browser einen davon
* aus, und welchen, ist von Browser zu Browser verschieden.
*
* Ohne eigenes Favicon steht wieder die mitgelieferte Datei da, damit ein
* Entfernen im Admin-Bereich nicht einen leeren Platz hinterlaesst.
*/
/**
* Faerbt die Systemleisten der installierten App.
*
* `<meta name="theme-color">` stand fest auf dem Indigo der Voreinstellung –
* das Manifest nannte aber die eingestellte Primaerfarbe. In der installierten
* App war die Statusleiste damit in einer Farbe, die sonst nirgends vorkommt.
*/
function applyThemeColor(primary?: string) {
if (!primary) return
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
?? document.head.appendChild(Object.assign(document.createElement('meta'), { name: 'theme-color' }))
meta.content = primary
}
function applyFavicon(url: string, touchUrl: string) {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
?? document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }))
if (url) {
link.removeAttribute('type') // Typ kommt aus dem Content-Type der Antwort
link.href = url
} else {
link.type = 'image/svg+xml'
link.href = '/icon.svg'
}
// Das Symbol auf dem Startbildschirm von iOS – dafuer die 192er-Fassung,
// nicht die 64er aus dem Tab. Android holt seines aus dem Manifest, das der
// Server erzeugt.
const touch = document.querySelector<HTMLLinkElement>('link[rel="apple-touch-icon"]')
if (touch) touch.href = touchUrl || '/icon-192.png'
}
export function AppSettingsProvider({ children }: { children: ReactNode }) {
const [cloudName, setCloudName] = useState('Aether')
const [logoUrl, setLogoUrl] = useState('')
const [faviconUrl, setFaviconUrl] = useState('')
const [appIconUrl, setAppIconUrl] = useState('')
const [globalColors, setGlobalColors] = useState<{ primary?: string; accent?: string }>({})
const [officeEnabled, setOfficeEnabled] = useState(false)
const refresh = useCallback(async () => {
try {
const cfg = await publicApi.config()
setCloudName(cfg.cloud_name || 'Aether')
setLogoUrl(cfg.logo_url ?? '')
setFaviconUrl(cfg.favicon_url ?? '')
setAppIconUrl(cfg.apple_touch_icon_url ?? '')
applyFavicon(cfg.favicon_url ?? '', cfg.apple_touch_icon_url ?? '')
setGlobalColors({ primary: cfg.primary_color, accent: cfg.accent_color })
setOfficeEnabled(!!cfg.office_enabled)
applyTheme(cfg.primary_color, cfg.accent_color)
applyThemeColor(cfg.primary_color)
document.title = cfg.cloud_name || 'Aether'
} catch {
/* Erreichbarkeit des Servers ist nicht Sache des Themings – Vorgaben bleiben */
}
}, [])
useEffect(() => { void refresh() }, [refresh])
return (
<Ctx.Provider value={{ cloudName, logoUrl, faviconUrl, appIconUrl, globalColors, officeEnabled, refresh }}>{children}</Ctx.Provider>
)
}
export function useAppSettings() {
return useContext(Ctx)
}
+55
View File
@@ -0,0 +1,55 @@
import { useState, useEffect, useCallback } from 'react'
import { authApi, setCSRFToken, type User } from '../api/client'
export interface AuthState {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
/** Lädt den Nutzer neu – vor allem für den Speicherverbrauch, der sich
* bei jedem Hoch- und Herunterladen ändert. */
refreshUser: () => Promise<void>
}
export function useAuth(): AuthState {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
authApi.me()
.then(({ user, csrf_token }) => {
setCSRFToken(csrf_token)
setUser(user)
})
.catch(() => setUser(null))
.finally(() => setLoading(false))
}, [])
const login = useCallback(async (username: string, password: string) => {
const { user, csrf_token } = await authApi.login(username, password)
setCSRFToken(csrf_token)
setUser(user)
}, [])
const refreshUser = useCallback(async () => {
try {
const { user, csrf_token } = await authApi.me()
// /auth/me erneuert das CSRF-Cookie bei jedem Aufruf. Wird das neue
// Token hier nicht übernommen, passen Cookie und Kopfzeile nicht mehr
// zusammen und jeder folgende schreibende Aufruf scheitert mit 403.
setCSRFToken(csrf_token)
setUser(user)
} catch {
/* Eine fehlgeschlagene Aktualisierung darf die Sitzung nicht beenden –
der angezeigte Wert bleibt dann eben kurz veraltet. */
}
}, [])
const logout = useCallback(async () => {
await authApi.logout()
setCSRFToken('')
setUser(null)
}, [])
return { user, loading, login, logout, refreshUser }
}
+176
View File
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { FolderItem } from '../api/client'
export type View = 'files' | 'trash' | 'incoming' | 'shared' | 'admin' | 'profile' | 'tags' | 'from'
const views: View[] = ['files', 'trash', 'incoming', 'shared', 'admin', 'profile', 'tags', 'from']
export interface NavState {
view: View
/** Ordnerkette der Dateiansicht – leer heisst Wurzel. */
folderPath: FolderItem[]
/** Gewaehltes Etikett in der Etikettenansicht. Steht im Pfad (`/tags/<id>`),
* damit ein Lesezeichen auf „alle Steuerbelege 2026" zeigen kann. */
tagId?: string
/** Gewaehlter Absender (`/from/<id>`) – „alles von den Stadtwerken". */
correspondentId?: string
}
/** Was im History-Eintrag steckt. Der Zaehler unterscheidet zwei Eintraege am
* selben Ort und wandert beim Zurueckgehen mit. */
interface NavEntry extends NavState {
index: number
}
const stateKey = 'aether:nav'
function entryOf(raw: unknown): NavEntry | null {
const holder = raw as Record<string, unknown> | null
const e = holder?.[stateKey] as NavEntry | undefined
if (!e || !views.includes(e.view) || !Array.isArray(e.folderPath)) return null
return {
view: e.view,
folderPath: e.folderPath,
tagId: typeof e.tagId === 'string' ? e.tagId : undefined,
correspondentId: typeof e.correspondentId === 'string' ? e.correspondentId : undefined,
index: typeof e.index === 'number' ? e.index : 0,
}
}
/**
* Die Ansicht steht im Pfad (`/admin`), damit ein Lesezeichen sie trifft. Der
* Server liefert fuer jeden unbekannten Pfad die Anwendung aus (`spaHandler`),
* die Adresse ist also direkt aufrufbar.
*/
function viewFromUrl(): View {
const first = window.location.pathname.split('/')[1]
return views.includes(first as View) ? (first as View) : 'files'
}
function idFromUrl(prefix: string): string | undefined {
const parts = window.location.pathname.split('/')
return parts[1] === prefix && parts[2] ? parts[2] : undefined
}
function urlFor(s: NavState): string {
if (s.view === 'files') return '/'
if (s.view === 'tags' && s.tagId) return `/tags/${s.tagId}`
if (s.view === 'from' && s.correspondentId) return `/from/${s.correspondentId}`
return `/${s.view}`
}
function sameSpot(a: NavState, b: NavState): boolean {
if (a.view !== b.view) return false
// Zwei Etiketten sind zwei Orte – sonst ersetzte der Wechsel den Eintrag im
// Verlauf und die Zurueck-Taste spraenge an ihnen vorbei.
if (a.view === 'tags') return a.tagId === b.tagId
if (a.view === 'from') return a.correspondentId === b.correspondentId
if (a.view !== 'files') return true
const x = a.folderPath.at(-1)?.id
const y = b.folderPath.at(-1)?.id
return x === y
}
/**
* Haelt Ansicht und Ordnerkette im Verlauf des Browsers. Damit gehen die
* Zurueck-Taste, die Maustasten an der Seite und die Wischgeste – und ein
* Neuladen landet wieder da, wo man war, denn den State bringt der Browser
* zum Eintrag mit.
*
* Bedient wird das ausschliesslich ueber den Browser. Eigene Vor/Zurueck-Knoepfe
* standen bis zum 23.08.2026 in der Werkzeugleiste und sind wieder raus: Zwei
* Bedienelemente fuer dieselbe Sache, von denen eines nur die halbe Wahrheit
* kennt – ob es vorwaerts weitergeht, verraet der Browser einer Seite naemlich
* nicht.
*/
export function useNavHistory() {
const [state, setState] = useState<NavState>(() => {
const restored = entryOf(window.history.state)
return restored
? {
view: restored.view, folderPath: restored.folderPath,
tagId: restored.tagId, correspondentId: restored.correspondentId,
}
: {
view: viewFromUrl(), folderPath: [],
tagId: idFromUrl('tags'), correspondentId: idFromUrl('from'),
}
})
const indexRef = useRef(entryOf(window.history.state)?.index ?? 0)
// Der erste Eintrag hat noch keinen State – ohne ihn faende ein spaeteres
// Zurueck nichts vor.
useEffect(() => {
if (entryOf(window.history.state)) return
const entry: NavEntry = { ...state, index: indexRef.current }
window.history.replaceState({ [stateKey]: entry }, '', urlFor(state))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
function onPop(e: PopStateEvent) {
const entry = entryOf(e.state)
if (!entry) return
indexRef.current = entry.index
setState({
view: entry.view, folderPath: entry.folderPath,
tagId: entry.tagId, correspondentId: entry.correspondentId,
})
}
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
// Der Verlauf wird beim Navigieren beschrieben – die Funktionen brauchen den
// aktuellen Stand, ohne selbst neu zu entstehen.
const stateRef = useRef(state)
stateRef.current = state
const navigate = useCallback((next: NavState) => {
// Denselben Ort nicht zweimal in den Verlauf legen: sonst braeuchte ein
// Klick auf den Ordner, in dem man schon steht, ein Zurueck.
if (sameSpot(stateRef.current, next)) {
const entry: NavEntry = { ...next, index: indexRef.current }
window.history.replaceState({ [stateKey]: entry }, '', urlFor(next))
setState(next)
return
}
const index = indexRef.current + 1
indexRef.current = index
window.history.pushState({ [stateKey]: { ...next, index } }, '', urlFor(next))
setState(next)
}, [])
// Die Ordnerkette faehrt bei jedem Ansichtswechsel mit, sonst stuende man
// nach einem Abstecher in den Papierkorb wieder in der Wurzel.
const setView = useCallback((view: View) => {
navigate({ view, folderPath: stateRef.current.folderPath })
}, [navigate])
const setFolderPath = useCallback((folderPath: FolderItem[]) => {
navigate({ view: 'files', folderPath })
}, [navigate])
/** Zu einem Etikett springen – eigener Eintrag im Verlauf. */
const setTag = useCallback((tagId: string) => {
navigate({ view: 'tags', folderPath: stateRef.current.folderPath, tagId })
}, [navigate])
/** Zu einem Absender springen – „alles von den Stadtwerken". */
const setCorrespondent = useCallback((correspondentId: string) => {
navigate({ view: 'from', folderPath: stateRef.current.folderPath, correspondentId })
}, [navigate])
return {
view: state.view,
folderPath: state.folderPath,
tagId: state.tagId,
correspondentId: state.correspondentId,
setView,
setFolderPath,
setTag,
setCorrespondent,
navigate,
}
}
+25
View File
@@ -0,0 +1,25 @@
import { useRef, type MouseEvent } from 'react'
/**
* Klick auf das Overlay schließt den Dialog – aber nur, wenn Drücken UND
* Loslassen beide auf dem Overlay passiert sind.
*
* Ohne die Merker-Prüfung reicht ein reines `onClick` nicht: Markiert man Text
* im Dialog und lässt die Maus außerhalb los, feuert der Browser `click` auf
* dem gemeinsamen Vorfahren – das Overlay. Der Dialog schlösse dann mitten in
* der Textauswahl. Ein `e.target === e.currentTarget`-Check allein hilft dabei
* nicht, weil das Ziel in genau diesem Fall tatsächlich das Overlay ist.
*/
export function useOverlayDismiss(onDismiss: () => void) {
const startedOnOverlay = useRef(false)
return {
onMouseDown: (e: MouseEvent) => {
startedOnOverlay.current = e.target === e.currentTarget
},
onClick: (e: MouseEvent) => {
if (startedOnOverlay.current && e.target === e.currentTarget) onDismiss()
startedOnOverlay.current = false
},
}
}