Beim Löschen von convert.go ist Aethers einzige Verwendung des JWT-Schlüssels mitgegangen: signJWT stand dort. Zurück blieb ein Feld auf dem Client, das niemand liest, plus Config-Eintrag und Aufrufparameter – Go meldet ungenutzte Strukturfelder nicht, deshalb lief der Build durch. Eine Einstellung, die nichts mehr tut, ist schlimmer als keine: Wer sie setzt, glaubt, sie wirke. OFFICE_JWT_SECRET bleibt in der .env, wird aber vom **Office-Container** gelesen, nicht von Aether. Geprüft mit Euro-Office: JWT_ENABLED=true und Aether ohne jedes Signieren – der WOPI-Weg läuft. Doku richtiggestellt, nachdem Marc den Compose-Schnipsel angezweifelt hat: - „Euro-Office ist hier nie gelaufen" stimmte nicht mehr. Jetzt steht da, was tatsächlich geprüft ist (Abbild, Start, Discovery, CheckFileInfo, Ausliefern, mobileEdit) und was nicht: PutFile, also das Zurückschreiben. - Der Absatz zu /ConvertService.ashx beschrieb gelöschten Code. - OFFICE_PUBLIC_URL: die Falle benannt, die einen Tag gekostet hat. Stand dort localhost, lief alles am Docker-Rechner und blieb am Telefon schwarz – dort ist localhost das Telefon. Ohne Fehlermeldung. - Zur Portbindung: Die Office-Schnipsel binden auf 0.0.0.0, anders als Aether selbst. Das ist nötig, weil der Browser direkt hin muss – steht jetzt dabei, samt dem sauberen Weg über einen Reverse Proxy. Geprüft: go build/vet sauber, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
781 lines
32 KiB
Go
781 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)
|
||
|
||
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)
|
||
// 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)
|
||
}
|