Files
Aether/backend/internal/admin/branding_handler.go
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

417 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}