Vorschaubilder entstehen sonst serverseitig aus den hochgeladenen Bytes – bei
einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer sie
gerade bearbeitet, hat aber genau den Browser, der sie ohnehin zeichnet.
Neu: POST /api/v1/files/{id}/thumbnail. Der Editor schickt nach dem Speichern
ein PNG hinterher.
Den Bytes wird dabei nicht geglaubt: Sie werden nicht abgelegt, sondern durch
denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
übliche Grösse; nichts anderes kann im thumbs-Bereich landen. Dazu Schreibrecht
auf die Datei: Wer sie nicht ändern darf, bestimmt auch nicht, wie sie in der
Liste aussieht.
Das Hochladen läuft nach dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schönheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb stumm.
Politur am Zeichenbrett: dunkles Thema (ein weisses Brett in dunkler Oberfläche
war der auffälligste Bruch), „Unsaved" als Marke statt eines Punktes hinter dem
Namen, Strg+S, Dateisymbol und Name ohne Endung.
„Open" und „Save to…" sind aus. Beide gehen am Browser vorbei: „Open" ersetzte
den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" schriebe eine
zweite Fassung in den Download-Ordner, die niemand wiederfindet.
Selbsttest 12 → 18 Prüfungen, darunter die beiden Ablehnungen (kein Bild → 400,
fremder Typ → 415).
Geprüft: go build/vet sauber, alle Go-Pakete grün, 261 Prüfungen der acht
Selbsttest-Suiten grün, 53 Vitest-Tests, tsc sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
782 lines
32 KiB
Go
782 lines
32 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||
"github.com/go-chi/httprate"
|
||
"github.com/google/uuid"
|
||
"github.com/rs/zerolog"
|
||
"github.com/rs/zerolog/log"
|
||
|
||
"aether/internal/acl"
|
||
"aether/internal/admin"
|
||
"aether/internal/audit"
|
||
"aether/internal/auth"
|
||
"aether/internal/avatar"
|
||
"aether/internal/calendar"
|
||
"aether/internal/documents"
|
||
"aether/internal/files"
|
||
"aether/internal/gc"
|
||
"aether/internal/office"
|
||
appoidc "aether/internal/oidc"
|
||
"aether/internal/rescan"
|
||
"aether/internal/sharing"
|
||
"aether/internal/storage"
|
||
"aether/internal/tags"
|
||
"aether/internal/thumbnails"
|
||
appwebdav "aether/internal/webdav"
|
||
"aether/pkg/bg"
|
||
"aether/pkg/config"
|
||
appdb "aether/pkg/db"
|
||
"aether/pkg/middleware"
|
||
"aether/pkg/response"
|
||
)
|
||
|
||
// spaHandler liefert statische Dateien aus und fällt für alles andere auf
|
||
// index.html zurück.
|
||
//
|
||
// Nötig, weil das Frontend seine Adressen selbst verwaltet: `/s/<token>`,
|
||
// `/admin` und `/profile` existieren als Dateien nicht. Ohne Rückfalloption
|
||
// beantwortet ein reiner FileServer sie mit 404 – ein frisch geteilter Link
|
||
// landete beim Empfänger also auf einer Fehlerseite. Dass es bisher trotzdem
|
||
// meist funktionierte, lag allein am Service Worker, der die Navigation
|
||
// abfing; wer die Seite zum ersten Mal öffnet, hat den noch nicht.
|
||
func spaHandler(root string) http.Handler {
|
||
fs := http.FileServer(http.Dir(root))
|
||
index := filepath.Join(root, "index.html")
|
||
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
upath := path.Clean("/" + r.URL.Path)
|
||
|
||
// Serverseitige Bereiche nie überdecken – dort ist ein 404 die richtige
|
||
// Antwort und kein Grund, die Anwendung auszuliefern.
|
||
for _, reserved := range []string{"/api/", "/dav/", "/share/", "/branding/", "/wopi/"} {
|
||
if strings.HasPrefix(upath+"/", reserved) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
}
|
||
|
||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
||
setStaticCaching(w, upath)
|
||
fs.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
// Nur echte Seitenaufrufe bekommen die Anwendung. Eine fehlende
|
||
// JavaScript- oder Bilddatei soll als 404 sichtbar bleiben, statt
|
||
// stillschweigend HTML zu liefern – das erzeugt sonst rätselhafte
|
||
// Syntaxfehler in der Konsole statt einer klaren Meldung.
|
||
if r.Method != http.MethodGet || !strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
setStaticCaching(w, "/index.html")
|
||
http.ServeFile(w, r, index)
|
||
})
|
||
}
|
||
|
||
// setStaticCaching entscheidet, was der Browser behalten darf.
|
||
//
|
||
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
|
||
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
|
||
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
|
||
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
|
||
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
|
||
// als rätselhafter Fehler mitten in der Anwendung.
|
||
//
|
||
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
|
||
//
|
||
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
|
||
// ändert sich der Name. Solche Dateien darf der Browser für immer
|
||
// behalten – "immutable" spart ihm sogar die Rückfrage.
|
||
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
|
||
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
|
||
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
|
||
// der Server dann meist nur mit 304 und ein paar Byte.
|
||
func setStaticCaching(w http.ResponseWriter, upath string) {
|
||
if strings.HasPrefix(upath, "/assets/") {
|
||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||
return
|
||
}
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
}
|
||
|
||
func main() {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
if cfg.IsDev() {
|
||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||
}
|
||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||
|
||
// **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.**
|
||
//
|
||
// zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext
|
||
// keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher
|
||
// Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein
|
||
// Blob verwaist ist, und die Meldung über eine beschädigte Datei beim
|
||
// Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos:
|
||
// Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem
|
||
// man den Fehler herbeiführt, den er melden soll.
|
||
//
|
||
// Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue
|
||
// Logeintrag blieb aus, obwohl der Abbruch funktionierte.
|
||
zerolog.DefaultContextLogger = &log.Logger
|
||
|
||
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
||
|
||
ctx := context.Background()
|
||
|
||
if err := appdb.Migrate(cfg.DSN()); err != nil {
|
||
log.Fatal().Err(err).Msg("migrations failed")
|
||
}
|
||
log.Info().Msg("migrations up to date")
|
||
|
||
pool, err := appdb.Connect(ctx, cfg.DSN())
|
||
if err != nil {
|
||
log.Fatal().Err(err).Msg("database connection failed")
|
||
}
|
||
defer pool.Close()
|
||
|
||
// ── Storage ───────────────────────────────────────────────────────────────
|
||
// Thumbnails are stored in the main blob store under the "thumbs/" prefix.
|
||
blobStore, err := storage.NewLocalFS(cfg.StoragePath, cfg.TmpPath)
|
||
if err != nil {
|
||
log.Fatal().Err(err).Msg("blob storage init failed")
|
||
}
|
||
|
||
// ── Packages ──────────────────────────────────────────────────────────────
|
||
auditor := audit.NewLogger(pool)
|
||
aclStore := acl.NewStore(pool)
|
||
aclHandler := acl.NewHandler(aclStore, auditor)
|
||
|
||
authStore := auth.NewStore(pool)
|
||
sessions := auth.NewSessionStore(cfg.SessionSecret, cfg.SessionMaxAge)
|
||
fileStore := files.NewStore(pool)
|
||
authHandler := auth.NewHandler(authStore, sessions, fileStore, auditor)
|
||
tagStore := tags.NewStore(pool)
|
||
tagHandler := tags.NewHandler(tagStore, aclStore)
|
||
// ── Texterkennung ─────────────────────────────────────────────────────────
|
||
// Bleibt nil, wenn abgeschaltet oder die Programme fehlen. Alles, was
|
||
// docStore benutzt, prüft deshalb auf nil – eine Cloud ohne OCR ist eine
|
||
// vollständige Cloud, nur ohne Volltextsuche.
|
||
var docStore *documents.Store
|
||
if cfg.OCREnabled {
|
||
if ok, missing := documents.Available(); !ok {
|
||
log.Warn().Strs("missing", missing).
|
||
Msg("Texterkennung abgeschaltet: Programme fehlen im Image")
|
||
} else {
|
||
docStore = documents.NewStore(pool)
|
||
worker := documents.NewWorker(docStore, blobStore, &documents.Extractor{
|
||
Languages: cfg.OCRLanguages,
|
||
MaxPages: cfg.OCRMaxPages,
|
||
Timeout: cfg.OCRTimeout,
|
||
TmpDir: cfg.TmpPath,
|
||
}, log.Logger)
|
||
// Die Schleife darin sichert jeden Auftrag einzeln ab (siehe
|
||
// worker.Run). Diese Hülle fängt, was **davor** passiert – das
|
||
// Aufräumen der Zwischendateien beim Start.
|
||
bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) })
|
||
|
||
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||
// Anfang der gesamte Bestand.
|
||
bg.Go("Bestand nachtragen", func() {
|
||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||
if err != nil {
|
||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||
return
|
||
}
|
||
if n > 0 {
|
||
log.Info().Int("files", n).Msg("Bestandsdateien zur Texterkennung eingeplant")
|
||
}
|
||
|
||
// Einträge nach älterer Regel erneut erkennen. Siehe
|
||
// documents.ExtractVersion – die Seitenmarke im Text kam
|
||
// nachträglich dazu, und ohne sie kann der Betrachter bei
|
||
// einem Scan die Fundstelle nicht auf einer Seite verorten.
|
||
m, err := docStore.EnqueueOutdated(ctx, 5000)
|
||
if err != nil {
|
||
log.Warn().Err(err).Msg("veraltete Texteintraege nachtragen fehlgeschlagen")
|
||
return
|
||
}
|
||
if m > 0 {
|
||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// fileHandler bekommt docStore mit: Der PDF-Betrachter holt sich darueber
|
||
// den erkannten Text eines Scans, in Seiten zerlegt.
|
||
// Wie viele frühere Fassungen aufgehoben werden. Einmal hier gesetzt,
|
||
// danach nur gelesen – siehe files.SetMaxVersionsKept.
|
||
files.SetMaxVersionsKept(cfg.MaxVersionsKept)
|
||
|
||
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
||
|
||
shareStore := sharing.NewStore(pool)
|
||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor, aclStore)
|
||
|
||
collector := gc.New(pool, blobStore)
|
||
|
||
adminStore := admin.NewStore(pool)
|
||
// Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die
|
||
// andere Richtung: von der Platte in die Datenbank statt umgekehrt.
|
||
rescanner := rescan.New(pool, fileStore, blobStore)
|
||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner)
|
||
|
||
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||
// gut, solange sie ihn nicht dereferenziert; Status prüft genau darauf.
|
||
docHandler := documents.NewHandler(docStore,
|
||
func(r *http.Request) bool {
|
||
u := auth.UserFromContext(r.Context())
|
||
return u != nil && u.IsAdmin
|
||
},
|
||
func(r *http.Request) uuid.UUID {
|
||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||
return u.ID
|
||
}
|
||
return uuid.Nil
|
||
})
|
||
|
||
// ── Office ────────────────────────────────────────────────────────────────
|
||
// Bleibt nil, wenn OFFICE_PROVIDER leer ist. Ein nil-Handler meldet der
|
||
// Oberfläche schlicht „aus"; die Anwendung ist ohne Office vollständig.
|
||
var officeHandler *office.Handler
|
||
if cfg.OfficeEnabled() {
|
||
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
|
||
if err != nil {
|
||
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
||
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
||
// fehlt – und der Log sagt, warum.
|
||
log.Warn().Err(err).Msg("Office abgeschaltet: Konfiguration fehlerhaft")
|
||
} else {
|
||
officeHandler = office.NewHandler(client, office.NewSigner(cfg.SessionSecret),
|
||
fileStore, blobStore, aclStore, cfg.PublicURL, cfg.OfficeWopiURL)
|
||
log.Info().Str("provider", cfg.OfficeProvider).Str("url", cfg.OfficeURL).
|
||
Msg("Office-Bearbeitung aktiv")
|
||
}
|
||
}
|
||
|
||
// Der Ursprung, den die Sicherheitsrichtlinie als Rahmen zulassen muss.
|
||
// Leer, wenn kein Office-Server eingerichtet ist – dann bleibt die
|
||
// Richtlinie so eng wie zuvor.
|
||
officeFrameSrc := ""
|
||
if officeHandler.Enabled() {
|
||
officeFrameSrc = cfg.OfficePublicURL
|
||
if officeFrameSrc == "" {
|
||
officeFrameSrc = cfg.OfficeURL
|
||
}
|
||
}
|
||
|
||
avatarHandler := avatar.NewHandler(authStore, blobStore)
|
||
|
||
// WebDAV
|
||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
||
|
||
// CalDAV
|
||
calStore := calendar.NewStore(pool, aclStore)
|
||
calHandler := calendar.NewHandler(authStore, calStore)
|
||
calAPIHandler := calendar.NewAPIHandler(calStore)
|
||
|
||
// OIDC (optional – disabled if OIDC_ISSUER not set)
|
||
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
||
Issuer: cfg.OIDCIssuer,
|
||
ClientID: cfg.OIDCClientID,
|
||
ClientSecret: cfg.OIDCClientSecret,
|
||
RedirectURL: cfg.OIDCRedirectURL,
|
||
|
||
LinkByUsername: cfg.OIDCLinkByUsername,
|
||
}, authStore, sessions, blobStore)
|
||
if err != nil {
|
||
log.Fatal().Err(err).Msg("OIDC init failed")
|
||
}
|
||
|
||
// ── Admin seed ────────────────────────────────────────────────────────────
|
||
if cfg.AdminUsername != "" && cfg.AdminPassword != "" {
|
||
if err := seedAdmin(ctx, authStore, cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||
log.Warn().Err(err).Msg("admin seed skipped")
|
||
} else {
|
||
// Die **Länge** mit ins Log, nicht das Passwort.
|
||
//
|
||
// Anlass: Ein `$` in der .env verschluckt bei Docker Compose
|
||
// lautlos den Rest des Wertes – `geheim$wort` kommt als `geheim`
|
||
// an. Angelegt wird dann ein Konto mit einem Passwort, das
|
||
// niemand kennt, und die Anmeldung scheitert mit „invalid
|
||
// credentials", obwohl alles richtig eingetippt ist. Gemessen,
|
||
// und im Log war nichts davon zu sehen.
|
||
//
|
||
// Eine Zahl verrät nichts Brauchbares, macht den verschluckten
|
||
// Wert aber sofort sichtbar.
|
||
log.Info().
|
||
Str("username", cfg.AdminUsername).
|
||
Int("password_length", len(cfg.AdminPassword)).
|
||
Msg("admin account ready")
|
||
if len(cfg.AdminPassword) < 12 {
|
||
log.Warn().
|
||
Int("password_length", len(cfg.AdminPassword)).
|
||
Msg("ADMIN_PASSWORD ist auffaellig kurz – enthaelt es ein $? " +
|
||
"In der .env muss es als $$ geschrieben oder der Wert in " +
|
||
"'einfache Anfuehrungszeichen' gesetzt werden, sonst " +
|
||
"schneidet Docker Compose dort ab.")
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Blob-GC ───────────────────────────────────────────────────────────────
|
||
// Räumt Blobs weg, auf die keine Datenbankzeile mehr zeigt. Läuft im
|
||
// Hintergrund; jeder Lauf, der etwas entfernt, landet im Audit-Log.
|
||
collector.Start(ctx, func(res gc.Result) {
|
||
auditor.Log(ctx, audit.Event{
|
||
ActorName: "system",
|
||
Action: audit.ActionGarbageCollect,
|
||
Detail: fmt.Sprintf("%d orphaned blobs removed, %d bytes freed",
|
||
res.Deleted, res.FreedBytes),
|
||
})
|
||
})
|
||
|
||
// ── Router ────────────────────────────────────────────────────────────────
|
||
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
||
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
||
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
|
||
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "REPORT", "MKCALENDAR"} {
|
||
chi.RegisterMethod(m)
|
||
}
|
||
|
||
r := chi.NewRouter()
|
||
r.Use(chimiddleware.RequestID)
|
||
r.Use(middleware.TrustedProxy(cfg))
|
||
r.Use(middleware.Logger)
|
||
r.Use(middleware.SecurityHeaders(middleware.CSP{
|
||
FrameSrc: []string{officeFrameSrc},
|
||
// Die HoneyOS-Leiste kommt als Skript von dort und holt sich von dort
|
||
// auch ihre App-Liste. Leer = keine Leiste und die alte, engere Regel.
|
||
ScriptSrc: []string{cfg.HoneybarOrigin},
|
||
ConnectSrc: []string{cfg.HoneybarOrigin},
|
||
}))
|
||
r.Use(chimiddleware.Recoverer)
|
||
|
||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||
response.JSON(w, http.StatusOK, map[string]string{"status": "ok", "version": "0.2.0"})
|
||
})
|
||
|
||
// Logo ohne Anmeldung – die Login-Seite zeigt es, bevor jemand angemeldet ist.
|
||
r.Get("/branding/logo", adminHandler.ServeLogo)
|
||
r.Get("/branding/icon/{size}", adminHandler.ServeAppIcon)
|
||
// Das Manifest beschreibt die installierbare App. Es kommt aus der
|
||
// Datenbank, damit Name, Farbe und Symbol dem Admin-Bereich folgen.
|
||
r.Get("/manifest.webmanifest", adminHandler.ServeManifest)
|
||
|
||
// WOPI – **bewusst ausserhalb der Sitzungs- und CSRF-Kette.** Diese Aufrufe
|
||
// kommen vom Office-Server, nicht aus einem Browser; ihre Berechtigung ist
|
||
// allein das kurzlebige Token, das an genau eine Datei gebunden ist.
|
||
if officeHandler.Enabled() {
|
||
r.Get("/wopi/files/{id}", officeHandler.CheckFileInfo)
|
||
r.Get("/wopi/files/{id}/contents", officeHandler.GetFile)
|
||
r.Post("/wopi/files/{id}/contents", officeHandler.PutFile)
|
||
}
|
||
|
||
// Public share API – completely outside /api/v1 to avoid auth middleware conflict
|
||
r.Get("/share/{token}", shareHandler.GetPublicInfo)
|
||
r.Post("/share/{token}/download", shareHandler.Download)
|
||
|
||
// WebDAV endpoint – own auth (Basic Auth), outside /api/v1.
|
||
// Kein StripPrefix: der xwebdav.Handler entfernt "/dav" selbst über sein
|
||
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
||
r.Mount("/dav", davHandler)
|
||
|
||
// CalDAV endpoint – eigene Basic Auth (App-Passwörter), wie /dav.
|
||
// .well-known/caldav liegt bewusst außerhalb von /caldav: RFC 6764 sieht
|
||
// diesen Pfad an der Wurzel vor, Clients wie DAVx5 fragen dort zuerst an.
|
||
r.Mount("/caldav", calHandler)
|
||
r.Get("/.well-known/caldav", calHandler.ServeWellKnown)
|
||
|
||
// Public config (no auth) – used by login page to discover OIDC availability
|
||
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
|
||
cloudName := "Aether"
|
||
oidcLabel := ""
|
||
if oidcHandler.IsEnabled() {
|
||
oidcLabel = cfg.OIDCButtonLabel
|
||
}
|
||
// Farben gehören hierher (statt hinter die Auth), damit schon die
|
||
// Login-Seite im eingestellten Farbschema erscheint.
|
||
var primaryColor, accentColor, logoURL, faviconURL, appleTouchURL string
|
||
if s, err := adminStore.GetAllSettings(r.Context()); err == nil {
|
||
if n, ok := s["cloud_name"]; ok && n != "" {
|
||
cloudName = n
|
||
}
|
||
primaryColor = s["primary_color"]
|
||
accentColor = s["accent_color"]
|
||
logoURL = admin.LogoURL(s["logo_storage_key"])
|
||
// Dasselbe Symbol in zwei Größen: klein für den Tab, groß für den
|
||
// Startbildschirm von iOS.
|
||
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
|
||
appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192")
|
||
}
|
||
response.JSON(w, http.StatusOK, map[string]any{
|
||
"oidc_enabled": oidcHandler.IsEnabled(),
|
||
"cloud_name": cloudName,
|
||
"primary_color": primaryColor,
|
||
"accent_color": accentColor,
|
||
"logo_url": logoURL,
|
||
"favicon_url": faviconURL,
|
||
"apple_touch_icon_url": appleTouchURL,
|
||
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
|
||
// erst anbietet, wenn es keinen Office-Server gibt.
|
||
"office_enabled": officeHandler.Enabled(),
|
||
// Welcher Anbieter – die Oberflaeche entscheidet daran, ob ein
|
||
// Dokument am Telefon im Editor oder als PDF aufgeht. Collabora
|
||
// hat eine brauchbare Handy-Ansicht, OnlyOffice baut dort die
|
||
// PC-Oberflaeche auf. Ohne Office-Server ein leerer Wert.
|
||
"office_provider": func() string {
|
||
if officeHandler.Enabled() {
|
||
return cfg.OfficeProvider
|
||
}
|
||
return ""
|
||
}(),
|
||
// Damit der Verlaufs-Dialog dieselbe Zahl nennt, die der Server
|
||
// anwendet. Vorher stand sie zweimal im Quelltext, mit einem
|
||
// Kommentar „muss zusammenpassen" – das ist keine Zusicherung.
|
||
"max_versions_kept": cfg.MaxVersionsKept,
|
||
// Beschriftung des SSO-Knopfes. Nur mitgeschickt, wenn OIDC an
|
||
// ist – sonst gibt der Endpunkt eine Einstellung preis, die
|
||
// niemanden etwas angeht.
|
||
"oidc_button_label": oidcLabel,
|
||
// Die HoneyOS-Leiste laedt die Oberflaeche selbst nach. Zur
|
||
// Laufzeit statt beim Bauen, damit ein fertiges Abbild ueber die
|
||
// .env umgezogen werden kann — und damit derselbe Ursprung
|
||
// gilt, den die CSP erlaubt.
|
||
"honeybar_origin": cfg.HoneybarOrigin,
|
||
})
|
||
})
|
||
|
||
// Auth routes – login/logout strictly rate-limited, /me is lenient
|
||
r.Route("/api/v1/auth", func(r chi.Router) {
|
||
r.With(httprate.LimitByIP(20, time.Minute)).Post("/login", authHandler.Login)
|
||
r.With(httprate.LimitByIP(20, time.Minute)).Post("/logout", authHandler.Logout)
|
||
r.With(auth.RequireAuth(sessions, authStore)).Get("/me", authHandler.Me)
|
||
r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Patch("/profile", authHandler.UpdateProfile)
|
||
r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Post("/change-password", authHandler.ChangePassword)
|
||
// OIDC routes (only wired if oidcHandler != nil)
|
||
if oidcHandler != nil {
|
||
r.With(httprate.LimitByIP(20, time.Minute)).Get("/oidc/login", oidcHandler.Login)
|
||
r.Get("/oidc/callback", oidcHandler.Callback)
|
||
}
|
||
})
|
||
|
||
// Protected API
|
||
r.Route("/api/v1", func(r chi.Router) {
|
||
r.Use(auth.RequireAuth(sessions, authStore))
|
||
r.Use(auth.CSRF)
|
||
r.Use(httprate.LimitByIP(2000, time.Minute))
|
||
|
||
// Files
|
||
// Sammel-Download als Archiv. POST, weil eine Auswahl beliebig lang sein
|
||
// kann – als Adresse würde sie an der Längengrenze scheitern.
|
||
r.Post("/download/zip", fileHandler.DownloadZip)
|
||
r.Post("/files", afterUpload(fileHandler, fileStore, blobStore, docStore))
|
||
r.Get("/files", fileHandler.List)
|
||
// Suche über Namen von Ordnern und Dateien, begrenzt auf das, was der
|
||
// Anfragende erreichen darf.
|
||
r.Get("/search", fileHandler.Search)
|
||
r.Get("/files/{id}/download", fileHandler.Download)
|
||
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
||
r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail)
|
||
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
||
r.Get("/files/{id}/text", fileHandler.TextPages)
|
||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||
// angemeldeten Zusammenhang.
|
||
r.Get("/office/session/{id}", officeHandler.Session)
|
||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||
// Telefon der Normalfall.
|
||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||
r.Post("/files/{id}/versions/{versionId}/restore", fileHandler.RestoreVersion)
|
||
r.Delete("/files/{id}", fileHandler.Delete)
|
||
r.Patch("/files/{id}/rename", fileHandler.Rename)
|
||
r.Patch("/files/{id}/move", fileHandler.Move)
|
||
r.Post("/files/{id}/share", shareHandler.Create)
|
||
|
||
// Folders
|
||
r.Post("/folders", fileHandler.CreateFolder)
|
||
r.Patch("/folders/{id}/rename", fileHandler.RenameFolder)
|
||
r.Patch("/folders/{id}/move", fileHandler.MoveFolder)
|
||
r.Delete("/folders/{id}", fileHandler.DeleteFolder)
|
||
|
||
// Favorites
|
||
r.Get("/favorites", fileHandler.ListFavorites)
|
||
r.Post("/favorites", fileHandler.AddFavorite)
|
||
r.Delete("/favorites/{folderId}", fileHandler.RemoveFavorite)
|
||
r.Patch("/favorites/{folderId}/expand", fileHandler.SetFavoriteExpanded)
|
||
r.Patch("/favorites/reorder", fileHandler.ReorderFavorites)
|
||
|
||
// Trash
|
||
// Etiketten – siehe internal/tags. Die Rechteprüfung steckt in den
|
||
// Abfragen des Stores, nicht in einer Middleware davor.
|
||
r.Get("/tags", tagHandler.List)
|
||
r.Post("/tags", tagHandler.Create)
|
||
r.Patch("/tags/{id}", tagHandler.Update)
|
||
r.Delete("/tags/{id}", tagHandler.Delete)
|
||
r.Get("/tags/{id}/files", tagHandler.Files)
|
||
r.Put("/files/{id}/tags", tagHandler.SetForFile)
|
||
|
||
// Absender: dieselbe Form wie die Etiketten, aber genau einer je Datei.
|
||
// Gesetzt wird er ueber /files/{id}/tags mit – ein Dialog, ein Vorgang.
|
||
r.Get("/correspondents", tagHandler.ListCorrespondents)
|
||
r.Post("/correspondents", tagHandler.CreateCorrespondent)
|
||
r.Patch("/correspondents/{id}", tagHandler.UpdateCorrespondent)
|
||
r.Delete("/correspondents/{id}", tagHandler.DeleteCorrespondent)
|
||
r.Get("/correspondents/{id}/files", tagHandler.CorrespondentFiles)
|
||
|
||
r.Get("/trash", fileHandler.ListTrash)
|
||
r.Post("/trash/{id}/restore", fileHandler.RestoreFile)
|
||
r.Delete("/trash", fileHandler.EmptyTrash)
|
||
r.Delete("/trash/{id}", fileHandler.HardDelete)
|
||
|
||
// Shares
|
||
r.Get("/shares", shareHandler.List)
|
||
r.Delete("/shares/{id}", shareHandler.Delete)
|
||
|
||
// Interne Freigaben
|
||
r.Get("/shared-with-me", aclHandler.SharedWithMe)
|
||
r.Get("/share-recipients", aclHandler.Recipients)
|
||
r.Get("/folders/{id}/acl", aclHandler.ListFolderACL)
|
||
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
||
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
||
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
||
// Kalender liegen in derselben Rechtetabelle – deshalb dieselben
|
||
// Endpunkte, und /acl/{id} widerruft alle drei Arten.
|
||
r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL)
|
||
r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL)
|
||
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
||
|
||
// App passwords (WebDAV/sync clients)
|
||
// Profilbild eines Kontos. Sichtbar für jedes angemeldete Konto –
|
||
// es steht ohnehin an Freigaben und in Nutzerlisten.
|
||
r.Get("/users/{id}/avatar", avatarHandler.Serve)
|
||
|
||
// Kalender – die Termine selbst laufen über CalDAV (/caldav/), das
|
||
// hier ist nur Verwaltung: mehrere Kalender, umbenennen, Farbe,
|
||
// löschen, sowie Export/Import als .ics.
|
||
r.Get("/calendars", calAPIHandler.List)
|
||
r.Post("/calendars", calAPIHandler.Create)
|
||
r.Patch("/calendars/{id}", calAPIHandler.Update)
|
||
r.Delete("/calendars/{id}", calAPIHandler.Delete)
|
||
r.Get("/calendars/{id}/export", calAPIHandler.Export)
|
||
r.Post("/calendars/{id}/import", calAPIHandler.Import)
|
||
r.Get("/calendars/{id}/events", calAPIHandler.ListEvents)
|
||
r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent)
|
||
r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent)
|
||
r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent)
|
||
r.Post("/calendars/{id}/events/{uid}/move", calAPIHandler.MoveEvent)
|
||
|
||
r.Get("/app-passwords", authHandler.ListAppPasswords)
|
||
r.Post("/app-passwords", authHandler.CreateAppPassword)
|
||
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
|
||
|
||
// Settings (readable by all authenticated users for theming)
|
||
r.Get("/admin/settings", adminHandler.GetSettings)
|
||
|
||
// Admin-only routes (server-side admin check inside each handler)
|
||
r.Patch("/admin/settings", adminHandler.UpdateSettings)
|
||
r.Get("/admin/users", adminHandler.ListUsers)
|
||
r.Post("/admin/users", adminHandler.CreateUser)
|
||
r.Patch("/admin/users/{id}", adminHandler.UpdateUser)
|
||
r.Post("/admin/users/{id}/reset-password", adminHandler.ResetPassword)
|
||
r.Delete("/admin/users/{id}", adminHandler.DeleteUser)
|
||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||
r.Post("/admin/gc", adminHandler.RunGC)
|
||
r.Post("/admin/rescan", adminHandler.Rescan)
|
||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||
// Platz, der in keinem Kontingent auftaucht.
|
||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
|
||
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
|
||
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
|
||
r.Get("/admin/ocr", docHandler.Status)
|
||
// Derselbe Stand, aber nur für die eigenen Dateien – ohne Adminrechte.
|
||
r.Get("/ocr", docHandler.MyStatus)
|
||
r.Post("/admin/branding/logo", adminHandler.UploadLogo)
|
||
r.Delete("/admin/branding/logo", adminHandler.DeleteLogo)
|
||
r.Post("/admin/branding/icon", adminHandler.UploadAppIcon)
|
||
r.Delete("/admin/branding/icon", adminHandler.DeleteAppIcon)
|
||
r.Get("/admin/group-folders", adminHandler.ListGroupFolders)
|
||
r.Post("/admin/group-folders", adminHandler.CreateGroupFolder)
|
||
r.Delete("/admin/group-folders/{id}", adminHandler.DeleteGroupFolder)
|
||
r.Get("/admin/groups", adminHandler.ListGroups)
|
||
r.Post("/admin/groups", adminHandler.CreateGroup)
|
||
r.Patch("/admin/groups/{id}", adminHandler.UpdateGroup)
|
||
r.Delete("/admin/groups/{id}", adminHandler.DeleteGroup)
|
||
r.Get("/admin/groups/{id}/members", adminHandler.ListGroupMembers)
|
||
r.Post("/admin/groups/{id}/members", adminHandler.AddGroupMember)
|
||
r.Delete("/admin/groups/{id}/members/{userId}", adminHandler.RemoveGroupMember)
|
||
})
|
||
|
||
// Serve built frontend
|
||
r.Handle("/*", spaHandler("./static"))
|
||
|
||
srv := &http.Server{
|
||
Addr: cfg.ListenAddr,
|
||
Handler: r,
|
||
ReadHeaderTimeout: 15 * time.Second,
|
||
WriteTimeout: 60 * time.Minute, // großzügig für Multi-GB Up-/Downloads
|
||
IdleTimeout: 60 * time.Second,
|
||
}
|
||
|
||
done := make(chan struct{})
|
||
quit := make(chan os.Signal, 1)
|
||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
// Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie
|
||
// wartet auf ein Signal und schließt `done`. Ein recover brächte hier
|
||
// nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer.
|
||
// Sie berührt außerdem keine fremden Daten.
|
||
go func() {
|
||
<-quit
|
||
log.Info().Msg("shutting down...")
|
||
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
_ = srv.Shutdown(shutCtx)
|
||
close(done)
|
||
}()
|
||
|
||
log.Info().Str("addr", srv.Addr).Msg("listening")
|
||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
log.Fatal().Err(err).Msg("server error")
|
||
}
|
||
<-done
|
||
log.Info().Msg("stopped")
|
||
}
|
||
|
||
// afterUpload hängt sich hinter den Upload und stößt an, was danach im
|
||
// Hintergrund passieren soll: das Vorschaubild und – bei Dokumenten – die
|
||
// Texterkennung.
|
||
//
|
||
// Der Unterschied zwischen beiden ist Absicht: Das Vorschaubild entsteht hier
|
||
// direkt in einer Goroutine, die Texterkennung wird nur in die Warteschlange
|
||
// eingetragen. Ein verlorenes Vorschaubild fällt sofort auf und ist in
|
||
// Millisekunden neu gemacht; verlorener Text fällt nie auf – man sucht später
|
||
// vergeblich und weiß nicht, warum. Deshalb ist er die Datenbankzeile wert.
|
||
//
|
||
// docStore darf nil sein (OCR abgeschaltet oder Programme fehlen).
|
||
func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage, docStore *documents.Store) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
crw := &captureWriter{ResponseWriter: w}
|
||
fh.Upload(crw, r)
|
||
|
||
if crw.status != http.StatusCreated {
|
||
return
|
||
}
|
||
|
||
// Parse file ID and MIME type from captured JSON response (wrapped in {"data":{...}}).
|
||
var wrapper struct {
|
||
Data struct {
|
||
ID uuid.UUID `json:"id"`
|
||
Name string `json:"name"`
|
||
MimeType string `json:"mime_type"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(crw.body.Bytes(), &wrapper); err != nil || wrapper.Data.ID == uuid.Nil {
|
||
return
|
||
}
|
||
created := wrapper.Data
|
||
|
||
if docStore != nil && documents.Supported(created.MimeType, created.Name) {
|
||
// Eigener Kontext: Der Anfrage-Kontext endet, sobald die Antwort
|
||
// draußen ist – der Eintrag ginge dann ins Leere.
|
||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||
if err := docStore.Enqueue(ctx, created.ID); err != nil {
|
||
log.Warn().Err(err).Str("file_id", created.ID.String()).
|
||
Msg("Texterkennung konnte nicht eingeplant werden")
|
||
}
|
||
cancel()
|
||
}
|
||
|
||
if !thumbnails.SupportedMIME(created.MimeType) {
|
||
return
|
||
}
|
||
|
||
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
|
||
//
|
||
// **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine
|
||
// hochgeladene Datei dekodiert. Chis Recoverer hängt am
|
||
// Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer
|
||
// nähme sonst den ganzen Prozess mit – ausgelöst durch einen
|
||
// gewöhnlichen Upload.
|
||
bg.Go("Vorschaubild", func() {
|
||
ctx := context.Background()
|
||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
rc, err := st.Get(ctx, f.StorageKey)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer rc.Close()
|
||
if err := thumbnails.Generate(ctx, st, created.ID.String(), created.MimeType, rc); err != nil {
|
||
log.Warn().Err(err).Str("file_id", created.ID.String()).Msg("thumbnail generation failed")
|
||
return
|
||
}
|
||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
||
})
|
||
}
|
||
}
|
||
|
||
func seedAdmin(ctx context.Context, store *auth.Store, username, password string) error {
|
||
exists, err := store.AdminExists(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("check admin: %w", err)
|
||
}
|
||
if exists {
|
||
return fmt.Errorf("admin already exists, skipping seed")
|
||
}
|
||
hash, err := auth.HashPassword(password)
|
||
if err != nil {
|
||
return fmt.Errorf("hash password: %w", err)
|
||
}
|
||
_, err = store.Create(ctx, username, hash, true)
|
||
return err
|
||
}
|
||
|
||
// captureWriter buffers the response body while forwarding to the real ResponseWriter.
|
||
type captureWriter struct {
|
||
http.ResponseWriter
|
||
status int
|
||
body bytes.Buffer
|
||
}
|
||
|
||
func (cw *captureWriter) WriteHeader(status int) {
|
||
cw.status = status
|
||
cw.ResponseWriter.WriteHeader(status)
|
||
}
|
||
|
||
func (cw *captureWriter) Write(b []byte) (int, error) {
|
||
cw.body.Write(b)
|
||
return cw.ResponseWriter.Write(b)
|
||
}
|