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>
417 lines
14 KiB
Go
417 lines
14 KiB
Go
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))
|
||
}
|