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)) }