Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine dritte Ressourcenspalte an. Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra. Drei Entscheidungen, die nicht offensichtlich waren: * Ein fremder Kalender steht immer unter seiner UUID, nie unter dem Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand des Empfaengers zwei Kalender auf derselben Adresse. * Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar umzustellen -- dort kommt jede Operation vorbei. * Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen, Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer. Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst in einen Kalender ziehen, den der Server danach ablehnen wuerde. scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht -- ohne diesen Anfang beweist der Rest nichts. Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer ist nichts angekommen" bestand **leer**. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
754 lines
30 KiB
Go
754 lines
30 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/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)
|
||
|
||
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)
|
||
go 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.
|
||
go 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, cfg.OfficeJWTSecret)
|
||
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.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
|
||
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)
|
||
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
|
||
}
|
||
|
||
// Generate thumbnail in background – non-fatal if it fails.
|
||
go 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)
|
||
}
|