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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user