feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+416
View File
@@ -0,0 +1,416 @@
package admin
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"image/color"
"io"
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/storage"
"aether/pkg/response"
)
// Bild-Upload für den Admin-Bereich: Logo und App-Symbol.
//
// Das Logo ist ein einzelnes Blob. Das App-Symbol ist ein Verzeichnis mit
// mehreren Größen, die beim Hochladen aus einer Vorlage entstehen – ein Upload
// deckt damit Tab-Symbol und installierte App zugleich ab.
const (
logoSettingKey = "logo_storage_key"
appIconSettingKey = "app_icon_storage_key"
// Genug Bytes, damit http.DetectContentType sicher entscheidet.
sniffLen = 512
// 2 MiB – ein Logo, das größer ist, ist kein Logo. Fürs App-Symbol gilt
// dieselbe Grenze: Die Vorlage darf großzügig sein, ausgeliefert werden
// ohnehin nur die gerechneten Größen.
maxBrandingBytes = 2 << 20
humanMaxSize = "2 MB"
humanTypes = "a PNG, JPEG, WebP or GIF image"
)
// Erlaubte Bildformate. SVG fehlt bewusst: Eine SVG-Datei darf Skripte
// enthalten, und beide Bilder werden ohne Anmeldung ausgeliefert – also genau
// dort, wo noch niemand angemeldet ist. Ein Rasterbild kann das nicht.
//
// ICO fehlt ebenfalls: Go bringt keinen Dekodierer mit, und ohne Dekodieren
// lassen sich keine Größen rechnen.
var allowedImageTypes = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
// Die Dateien, aus denen ein Symbol-Satz besteht. renderAppIcon legt sie an –
// welche davon wirklich entstehen, hängt von der Größe der Vorlage ab.
var iconSizes = []string{"64", "192", "512", "maskable"}
// ── Logo ─────────────────────────────────────────────────────────────────────
// POST /api/v1/admin/branding/logo (multipart, Feld "file")
func (h *Handler) UploadLogo(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
data, ext, ok := readUpload(w, r, "logo")
if !ok {
return
}
suffix := randomSuffix()
if suffix == "" {
response.Error(w, http.StatusInternalServerError, "could not store the logo")
return
}
newKey := "branding/logo-" + suffix + ext
settings, _ := h.store.GetAllSettings(r.Context())
oldKey := settings[logoSettingKey]
if _, _, err := h.storage.Put(r.Context(), newKey, bytes.NewReader(data)); err != nil {
response.Error(w, http.StatusInternalServerError, "could not store the logo")
return
}
if err := h.store.SetSetting(r.Context(), logoSettingKey, newKey); err != nil {
// Ohne Eintrag zeigt niemand auf das Blob – wieder wegräumen.
_ = h.storage.Delete(r.Context(), newKey)
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Erst jetzt das alte Logo entfernen: Wäre es vorher weg und der neue
// Eintrag scheiterte, stünde man ganz ohne da.
if oldKey != "" && oldKey != newKey {
if err := h.storage.Delete(r.Context(), oldKey); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("key", oldKey).
Msg("previous logo could not be removed")
}
}
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "logo uploaded")
response.JSON(w, http.StatusOK, map[string]string{"url": LogoURL(newKey)})
}
// DELETE /api/v1/admin/branding/logo
func (h *Handler) DeleteLogo(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
settings, _ := h.store.GetAllSettings(r.Context())
key := settings[logoSettingKey]
if key == "" {
w.WriteHeader(http.StatusNoContent)
return
}
if err := h.store.SetSetting(r.Context(), logoSettingKey, ""); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if err := h.storage.Delete(r.Context(), key); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("key", key).Msg("logo blob not removed")
}
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "logo removed")
w.WriteHeader(http.StatusNoContent)
}
// ServeLogo liefert das Logo ohne Anmeldung aus – die Anmeldeseite braucht es,
// und dort ist noch niemand angemeldet.
func (h *Handler) ServeLogo(w http.ResponseWriter, r *http.Request) {
settings, err := h.store.GetAllSettings(r.Context())
if err != nil {
http.NotFound(w, r)
return
}
key := settings[logoSettingKey]
if key == "" {
http.NotFound(w, r)
return
}
mime := "application/octet-stream"
for m, ext := range allowedImageTypes {
if path.Ext(key) == ext {
mime = m
break
}
}
h.serveBlob(w, r, key, mime)
}
// ── App-Symbol ───────────────────────────────────────────────────────────────
// POST /api/v1/admin/branding/icon (multipart, Feld "file")
//
// Aus der Vorlage entstehen alle Größen auf einmal. Ein zweites Feld für das
// Tab-Symbol gibt es bewusst nicht – zwei Bilder zu pflegen, die dasselbe
// zeigen sollen, laufen mit der Zeit auseinander.
func (h *Handler) UploadAppIcon(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
data, _, ok := readUpload(w, r, "app icon")
if !ok {
return
}
settings, _ := h.store.GetAllSettings(r.Context())
// Der maskable-Grund nimmt die eingestellte Primärfarbe: Weiß leuchtet im
// dunklen App-Menü, und durchsichtig füllt Android selbst mit Weiß.
bg := parseHexColor(settings["primary_color"], color.RGBA{R: 0x63, G: 0x66, B: 0xf1, A: 255})
variants, err := renderAppIcon(bytes.NewReader(data), bg)
if err != nil {
// Zu klein oder unlesbar – beides ist eine Sache des Absenders, keine
// des Servers, und die Meldung sagt welche.
response.Error(w, http.StatusUnprocessableEntity, err.Error())
return
}
suffix := randomSuffix()
if suffix == "" {
response.Error(w, http.StatusInternalServerError, "could not store the app icon")
return
}
newDir := "branding/icon-" + suffix + "/"
oldDir := settings[appIconSettingKey]
for name, blob := range variants {
if _, _, err := h.storage.Put(r.Context(), newDir+name, bytes.NewReader(blob)); err != nil {
// Halbe Sätze sind wertlos: was schon liegt, wieder wegräumen.
h.removeIconDir(r, newDir)
response.Error(w, http.StatusInternalServerError, "could not store the app icon")
return
}
}
if err := h.store.SetSetting(r.Context(), appIconSettingKey, newDir); err != nil {
h.removeIconDir(r, newDir)
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if oldDir != "" && oldDir != newDir {
h.removeIconDir(r, oldDir)
}
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "",
fmt.Sprintf("app icon uploaded (%d sizes)", len(variants)))
response.JSON(w, http.StatusOK, map[string]string{"url": AppIconURL(newDir, "192")})
}
// DELETE /api/v1/admin/branding/icon
func (h *Handler) DeleteAppIcon(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
settings, _ := h.store.GetAllSettings(r.Context())
dir := settings[appIconSettingKey]
if dir == "" {
w.WriteHeader(http.StatusNoContent)
return
}
if err := h.store.SetSetting(r.Context(), appIconSettingKey, ""); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.removeIconDir(r, dir)
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "app icon removed")
w.WriteHeader(http.StatusNoContent)
}
// ServeAppIcon liefert eine Größe des Symbol-Satzes aus – ohne Anmeldung, der
// Browser fragt das Tab-Symbol vor dem Anmelden ab.
func (h *Handler) ServeAppIcon(w http.ResponseWriter, r *http.Request) {
size := chi.URLParam(r, "size")
if !validIconSize(size) {
http.NotFound(w, r)
return
}
settings, err := h.store.GetAllSettings(r.Context())
if err != nil {
http.NotFound(w, r)
return
}
dir := settings[appIconSettingKey]
if dir == "" {
http.NotFound(w, r)
return
}
h.serveBlob(w, r, dir+size+".png", "image/png")
}
// removeIconDir räumt ein Symbol-Verzeichnis samt Verzeichnis selbst weg.
// Einzelne Dateien zu löschen reichte nicht: Der leere Ordner bliebe stehen,
// bei jedem Austausch einer mehr – und der GC sähe ihn nie, weil er nur
// Dateien betrachtet.
//
// Fehler werden nur vermerkt: Ein Rest auf der Platte ist unschön, aber kein
// Grund, dem Admin einen erfolgreichen Upload als fehlgeschlagen zu melden.
func (h *Handler) removeIconDir(r *http.Request, dir string) {
if dir == "" || !strings.HasPrefix(dir, "branding/icon-") {
// Ein Präfix, das nicht danach aussieht, wird nicht angefasst –
// DeletePrefix löscht rekursiv, da will man sich nicht vertippen.
log.Ctx(r.Context()).Warn().Str("dir", dir).Msg("refusing to remove unexpected icon dir")
return
}
if err := h.storage.DeletePrefix(r.Context(), dir); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("dir", dir).Msg("app icon dir not removed")
}
}
// ── Gemeinsames ──────────────────────────────────────────────────────────────
// readUpload liest das hochgeladene Bild vollständig ein und prüft Größe und
// Format. Vollständig deshalb, weil das App-Symbol ohnehin dekodiert werden
// muss – bei höchstens 2 MiB ist das unproblematisch.
func readUpload(w http.ResponseWriter, r *http.Request, what string) ([]byte, string, bool) {
r.Body = http.MaxBytesReader(w, r.Body, maxBrandingBytes+1024)
file, _, err := r.FormFile("file")
if err != nil {
// MaxBytesReader schlägt schon beim Zerlegen des Formulars zu. Ohne
// diese Unterscheidung bekäme man bei einer zu großen Datei die Meldung
// „kein Bild gefunden" und suchte den Fehler an der falschen Stelle.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
response.Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("%s must be %s or smaller", what, humanMaxSize))
return nil, "", false
}
response.Error(w, http.StatusBadRequest, "expected an image in the 'file' field")
return nil, "", false
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxBrandingBytes+1))
if err != nil {
response.Error(w, http.StatusBadRequest, "could not read the image")
return nil, "", false
}
if int64(len(data)) > maxBrandingBytes {
response.Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("%s must be %s or smaller", what, humanMaxSize))
return nil, "", false
}
// Den Typ aus dem Inhalt bestimmen, nicht aus dem Dateinamen oder dem
// mitgeschickten Content-Type – beides kann der Client frei behaupten.
head := data
if len(head) > sniffLen {
head = head[:sniffLen]
}
mime := strings.Split(http.DetectContentType(head), ";")[0]
ext, ok := allowedImageTypes[mime]
if !ok {
response.Error(w, http.StatusUnsupportedMediaType,
fmt.Sprintf("%s must be %s", what, humanTypes))
return nil, "", false
}
return data, ext, true
}
// serveBlob liefert ein Branding-Blob aus.
func (h *Handler) serveBlob(w http.ResponseWriter, r *http.Request, key, mime string) {
// Nur unterhalb von branding/ – der Schlüssel kommt zwar aus den
// Einstellungen, aber die Prüfung kostet nichts und schließt aus, dass eine
// verbogene Zeile eine fremde Datei ausliefert.
if !strings.HasPrefix(key, "branding/") {
http.NotFound(w, r)
return
}
rc, err := h.storage.Get(r.Context(), key)
if err != nil {
http.NotFound(w, r)
return
}
defer rc.Close()
w.Header().Set("Content-Type", mime)
// Kein Erraten des Typs im Browser – zusammen mit dem SVG-Verbot schließt
// das aus, dass ein hochgeladenes Bild als etwas Ausführbares gedeutet wird.
w.Header().Set("X-Content-Type-Options", "nosniff")
// Der Pfad enthält einen Zufallsanteil, der sich bei jedem Austausch
// ändert – deshalb darf lange zwischengespeichert werden.
w.Header().Set("Cache-Control", "public, max-age=604800")
_, _ = io.Copy(w, rc)
}
func randomSuffix() string {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}
func validIconSize(size string) bool {
for _, s := range iconSizes {
if s == size {
return true
}
}
return false
}
// AvailableIconSizes nennt die Größen, die für den hinterlegten Symbol-Satz
// wirklich auf der Platte liegen. Aus einer kleinen Vorlage entsteht kein
// 512er, und das Manifest darf nichts nennen, was es nicht gibt.
func (h *Handler) AvailableIconSizes(r *http.Request, dir string) []string {
if dir == "" {
return nil
}
found := map[string]bool{}
err := h.storage.Walk(r.Context(), dir, func(key string, _ storage.FileInfo) error {
found[strings.TrimSuffix(path.Base(key), ".png")] = true
return nil
})
if err != nil {
return nil
}
// Reihenfolge aus iconSizes, nicht die der Platte – im Manifest sollen die
// Größen aufsteigend stehen.
var sizes []string
for _, s := range iconSizes {
if found[s] {
sizes = append(sizes, s)
}
}
return sizes
}
// LogoURL baut die öffentliche Adresse des Logos.
func LogoURL(storageKey string) string {
if storageKey == "" {
return ""
}
return "/branding/logo?v=" + version(storageKey)
}
// AppIconURL baut die öffentliche Adresse einer Symbol-Größe.
func AppIconURL(dir, size string) string {
if dir == "" {
return ""
}
return "/branding/icon/" + size + "?v=" + version(dir)
}
// version ist der Zufallsanteil aus dem Speicherschlüssel. Er hängt an jeder
// Adresse, damit ein Austausch sofort sichtbar wird – Browser halten gerade
// Tab-Symbole sonst hartnäckig fest.
func version(storageKey string) string {
base := path.Base(strings.TrimSuffix(storageKey, "/"))
return strings.TrimSuffix(base, path.Ext(base))
}