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

+142
View File
@@ -0,0 +1,142 @@
package admin
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // nur der Dekodierer – x/image ist schon da
)
// Aus einem einzigen hochgeladenen Bild entstehen alle Größen, die Browser und
// Android brauchen. Zwei Bilder zu pflegen (Tab-Symbol und App-Symbol) war der
// Grund, es zusammenzulegen.
const (
// Chrome verweigert die Installation, wenn im Manifest kein Symbol von
// mindestens dieser Kantenlänge steht.
minIconSide = 192
// Der maskable-Bereich, den Android garantiert nicht wegschneidet: 80 % in
// der Mitte. Der Rest verschwindet je nach Gerät im Kreis oder Squircle.
maskableSafeZone = 0.8
)
// iconVariant beschreibt eine der erzeugten Dateien.
type iconVariant struct {
// name ist der Dateiname im Symbol-Verzeichnis, ohne Endung.
name string
side int
// maskable legt das Motiv verkleinert auf einen farbigen Grund.
maskable bool
}
// renderAppIcon erzeugt aus der Vorlage die PNG-Dateien des Symbol-Satzes.
// Der Schlüssel der Rückgabe ist der Dateiname inklusive Endung.
//
// Hochskaliert wird nie: Aus einer 256er-Vorlage ein 512er zu rechnen ergibt
// ein matschiges Symbol, das schlechter aussieht als gar keins. Fehlt eine
// Größe, nennt das Manifest sie einfach nicht.
func renderAppIcon(src io.Reader, bg color.Color) (map[string][]byte, error) {
img, _, err := image.Decode(src)
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
// Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten – ein
// Zuschnitt würde ein Logo anschneiden, und das fällt erst auf, wenn das
// Symbol schon überall klebt.
square := squareCanvas(img)
side := square.Bounds().Dx()
if side < minIconSide {
return nil, fmt.Errorf("image must be at least %dx%d pixels, got %dx%d",
minIconSide, minIconSide, img.Bounds().Dx(), img.Bounds().Dy())
}
variants := []iconVariant{
// 64 statt 32 fürs Tab: Auf feinen Bildschirmen wird das Symbol mit
// doppelter Pixeldichte gezeichnet.
{name: "64", side: 64},
{name: "192", side: 192},
}
if side >= 512 {
variants = append(variants, iconVariant{name: "512", side: 512})
}
maskSide := 192
if side >= 512 {
maskSide = 512
}
variants = append(variants, iconVariant{name: "maskable", side: maskSide, maskable: true})
out := make(map[string][]byte, len(variants))
for _, v := range variants {
buf, err := encodeVariant(square, v, bg)
if err != nil {
return nil, fmt.Errorf("render %s: %w", v.name, err)
}
out[v.name+".png"] = buf
}
return out, nil
}
func encodeVariant(square image.Image, v iconVariant, bg color.Color) ([]byte, error) {
dst := image.NewRGBA(image.Rect(0, 0, v.side, v.side))
target := dst.Bounds()
if v.maskable {
// Durchsichtig geht hier nicht: Android legt sonst eine weiße Kachel
// unter das Symbol, die im dunklen App-Menü leuchtet.
draw.Draw(dst, dst.Bounds(), image.NewUniform(bg), image.Point{}, draw.Src)
inset := int(float64(v.side) * (1 - maskableSafeZone) / 2)
target = image.Rect(inset, inset, v.side-inset, v.side-inset)
}
// CatmullRom statt des BiLinear aus internal/thumbnails: Vorschaubilder
// dürfen weich sein, ein Symbol soll scharfe Kanten behalten.
draw.CatmullRom.Scale(dst, target, square, square.Bounds(), draw.Over, nil)
var buf bytes.Buffer
// PNG, damit Transparenz erhalten bleibt – ein Symbol mit weißem Kasten
// drumherum sieht auf jedem farbigen Hintergrund falsch aus.
if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// squareCanvas legt das Bild mittig auf eine quadratische, durchsichtige
// Fläche. Ist es schon quadratisch, bleibt es unverändert.
func squareCanvas(img image.Image) image.Image {
b := img.Bounds()
if b.Dx() == b.Dy() {
return img
}
side := b.Dx()
if b.Dy() > side {
side = b.Dy()
}
dst := image.NewRGBA(image.Rect(0, 0, side, side))
draw.Draw(dst, image.Rect(
(side-b.Dx())/2, (side-b.Dy())/2,
(side-b.Dx())/2+b.Dx(), (side-b.Dy())/2+b.Dy(),
), img, b.Min, draw.Src)
return dst
}
// parseHexColor liest "#rrggbb". Bei allem anderen kommt die Ersatzfarbe
// zurück – eine kaputte Einstellung soll keinen Upload scheitern lassen.
func parseHexColor(s string, fallback color.RGBA) color.RGBA {
if len(s) != 7 || s[0] != '#' {
return fallback
}
var r, g, b uint8
if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
return fallback
}
return color.RGBA{R: r, G: g, B: b, A: 255}
}
+122
View File
@@ -0,0 +1,122 @@
package admin
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
)
func testImage(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{R: 20, G: 180, B: 120, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}
func decodeSize(t *testing.T, blob []byte) (int, int) {
t.Helper()
img, err := png.Decode(bytes.NewReader(blob))
if err != nil {
t.Fatalf("decode result: %v", err)
}
return img.Bounds().Dx(), img.Bounds().Dy()
}
var testBG = color.RGBA{R: 31, G: 107, B: 92, A: 255}
func TestRenderAppIconSizes(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(1000, 1000)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
for name, want := range map[string]int{
"64.png": 64, "192.png": 192, "512.png": 512, "maskable.png": 512,
} {
blob, ok := out[name]
if !ok {
t.Fatalf("%s missing", name)
}
if w, h := decodeSize(t, blob); w != want || h != want {
t.Errorf("%s: got %dx%d, want %dx%d", name, w, h, want, want)
}
}
}
// Aus einer kleinen Vorlage darf kein 512er entstehen – hochskaliert sähe es
// schlechter aus als gar keins, und das Manifest nennt dann nur, was es gibt.
func TestRenderAppIconNeverUpscales(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(256, 256)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
if _, ok := out["512.png"]; ok {
t.Error("512.png was generated from a 256px source")
}
if w, _ := decodeSize(t, out["maskable.png"]); w != 192 {
t.Errorf("maskable: got %d, want 192", w)
}
}
func TestRenderAppIconRejectsSmall(t *testing.T) {
if _, err := renderAppIcon(bytes.NewReader(testImage(100, 100)), testBG); err == nil {
t.Fatal("expected an error for a 100px source")
}
}
// Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten: Ein Zuschnitt
// würde ein Logo anschneiden, und das fällt erst auf, wenn das Symbol klebt.
func TestRenderAppIconFramesInsteadOfCropping(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(400, 150)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
img, err := png.Decode(bytes.NewReader(out["192.png"]))
if err != nil {
t.Fatalf("decode: %v", err)
}
if _, _, _, a := img.At(96, 2).RGBA(); a != 0 {
t.Error("top edge should be transparent padding, not image content")
}
if _, g, _, a := img.At(96, 96).RGBA(); a == 0 || g>>8 < 150 {
t.Error("centre should carry the motif")
}
}
// Der maskable-Grund ist die Primärfarbe: Durchsichtig füllt Android mit Weiß,
// und Weiß leuchtet im dunklen App-Menü.
func TestRenderAppIconMaskableHasBackground(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(512, 512)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
img, err := png.Decode(bytes.NewReader(out["maskable.png"]))
if err != nil {
t.Fatalf("decode: %v", err)
}
r, g, b, a := img.At(2, 2).RGBA()
if a>>8 != 255 || r>>8 != 31 || g>>8 != 107 || b>>8 != 92 {
t.Errorf("corner: got %d,%d,%d,%d – want the background colour", r>>8, g>>8, b>>8, a>>8)
}
}
func TestParseHexColor(t *testing.T) {
fallback := color.RGBA{R: 1, G: 2, B: 3, A: 255}
if got := parseHexColor("#1f6b5c", fallback); got != (color.RGBA{R: 31, G: 107, B: 92, A: 255}) {
t.Errorf("valid colour: got %v", got)
}
// Eine kaputte Einstellung darf keinen Upload scheitern lassen.
for _, bad := range []string{"", "1f6b5c", "#xyz", "#1f6b5cff"} {
if got := parseHexColor(bad, fallback); got != fallback {
t.Errorf("%q: got %v, want the fallback", bad, got)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package admin
import (
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"aether/internal/audit"
"aether/pkg/response"
)
// Die Audit-Endpunkte liegen im admin-Paket, nicht in audit: `auth` schreibt
// selbst Einträge und importiert deshalb `audit` – läge der Handler dort und
// bräuchte `auth` für die Admin-Prüfung, wäre der Import-Zyklus fertig.
// GET /api/v1/admin/audit?action=&actor_id=&limit=&offset=
func (h *Handler) ListAudit(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
f := audit.Filter{Action: r.URL.Query().Get("action")}
if raw := r.URL.Query().Get("actor_id"); raw != "" {
id, err := uuid.Parse(raw)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid actor_id")
return
}
f.ActorID = &id
}
if raw := r.URL.Query().Get("limit"); raw != "" {
f.Limit, _ = strconv.Atoi(raw)
}
if raw := r.URL.Query().Get("offset"); raw != "" {
f.Offset, _ = strconv.Atoi(raw)
}
entries, total, err := h.auditor.List(r.Context(), f)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load audit log")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"entries": entries,
"total": total,
})
}
// GET /api/v1/admin/audit/actions – füttert das Filter-Dropdown.
func (h *Handler) ListAuditActions(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
actions, err := h.auditor.Actions(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load actions")
return
}
response.JSON(w, http.StatusOK, actions)
}
// POST /api/v1/admin/gc – Bereinigung von Hand anstoßen.
//
// Der GC läuft ohnehin im Hintergrund; dieser Endpunkt macht ihn beobachtbar.
// Ein Aufräumjob, dessen Wirkung man nicht nachsehen kann, ist schwer zu
// vertrauen – und genau darum geht es hier, weil er Dateien löscht.
func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
if h.gc == nil {
response.Error(w, http.StatusServiceUnavailable, "cleanup not available")
return
}
res, err := h.gc.Run(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "cleanup failed")
return
}
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
fmt.Sprintf("manual run: %d blobs scanned, %d removed, %d bytes freed",
res.Scanned, res.Deleted, res.FreedBytes))
response.JSON(w, http.StatusOK, map[string]any{
"scanned": res.Scanned,
"orphans": res.Orphans,
"deleted": res.Deleted,
"freed_bytes": res.FreedBytes,
"skipped_young": res.SkippedYoung,
})
}
+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))
}
@@ -0,0 +1,115 @@
package admin
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/files"
"aether/pkg/response"
)
// Gruppenordner werden ausschließlich hier verwaltet. Bewusst nicht in der
// Dateiansicht: Ein Gruppenordner gehört keinem der Mitglieder, also soll ihn
// auch keines von ihnen entfernen können.
// GET /api/v1/admin/group-folders
func (h *Handler) ListGroupFolders(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
list, err := h.files.ListGroupFolders(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load group folders")
return
}
response.JSON(w, http.StatusOK, list)
}
// POST /api/v1/admin/group-folders
func (h *Handler) CreateGroupFolder(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
GroupID string `json:"group_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
groupID, err := uuid.Parse(body.GroupID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group_id")
return
}
// Gleiche Namensregeln wie überall – der Name wird Teil des Ablagepfads.
name := files.SanitizeFilename(strings.TrimSpace(body.Name))
if name == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
folder, err := h.files.CreateGroupFolder(r.Context(), groupID, name)
if errors.Is(err, files.ErrGroupFolderExists) {
response.Error(w, http.StatusConflict, "this group already has a folder")
return
}
if errors.Is(err, files.ErrNameTaken) {
response.Error(w, http.StatusConflict, "a folder with that name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not create group folder")
return
}
h.logAdmin(r, audit.ActionGroupFolderCreate, "folder", folder.ID.String(), folder.Name,
"group "+groupID.String())
response.JSON(w, http.StatusCreated, folder)
}
// DELETE /api/v1/admin/group-folders/{id}
func (h *Handler) DeleteGroupFolder(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
groupID, err := h.files.DeleteGroupFolderTree(r.Context(), id)
if errors.Is(err, files.ErrNotFound) {
response.Error(w, http.StatusNotFound, "group folder not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not delete group folder")
return
}
// Speicher erst nach dem Datenbank-Commit, und am Stück statt Blob für
// Blob: Da eine Gruppe genau einen Ordner hat, ist `groups/<id>` exakt ihr
// Bereich – inklusive Papierkorb und Versionen. Einzeln gelöscht blieben
// leere Verzeichnisse zurück.
if err := h.storage.DeletePrefix(r.Context(), "groups/"+groupID.String()); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("group_id", groupID.String()).
Msg("group folder deleted but storage remains")
}
h.logAdmin(r, audit.ActionGroupFolderDelete, "folder", id.String(), "",
"group "+groupID.String()+" and all its files")
w.WriteHeader(http.StatusNoContent)
}
+490
View File
@@ -0,0 +1,490 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/gc"
"aether/internal/storage"
"aether/pkg/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
storage storage.Storage
auditor *audit.Logger
gc *gc.Collector
files *files.Store
}
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
}
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
// Wiederkehrende (Handelnder, IP), damit die Aufrufstellen kurz bleiben.
func (h *Handler) logAdmin(r *http.Request, action, targetType, targetID, targetName, detail string) {
actor := auth.UserFromContext(r.Context())
e := audit.Event{
Action: action,
TargetType: targetType,
TargetID: targetID,
TargetName: targetName,
Detail: detail,
IP: middleware.RealIP(r),
}
if actor != nil {
e.ActorID = &actor.ID
e.ActorName = actor.Username
}
h.auditor.Log(r.Context(), e)
}
// requireAdmin is called at the start of every handler — server-side check.
func requireAdmin(r *http.Request) (*auth.User, bool) {
user := auth.UserFromContext(r.Context())
return user, user != nil && user.IsAdmin
}
// ── Users ─────────────────────────────────────────────────────────────────────
// GET /api/v1/admin/users
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
users, err := h.store.ListUsers(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, users)
}
// POST /api/v1/admin/users
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(body.Username) == "" || strings.TrimSpace(body.Password) == "" {
response.Error(w, http.StatusBadRequest, "username and password required")
return
}
if body.QuotaBytes <= 0 {
body.QuotaBytes = 10 << 30 // 10 GB default
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
user, err := h.store.CreateUser(r.Context(), strings.TrimSpace(body.Username), body.DisplayName, hash, body.IsAdmin, body.QuotaBytes)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "username already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionUserCreate, "user", user.ID.String(), user.Username,
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
response.JSON(w, http.StatusCreated, user)
}
// PATCH /api/v1/admin/users/{id}
func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
caller, ok := requireAdmin(r)
if !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
var body struct {
DisplayName string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if body.QuotaBytes <= 0 {
body.QuotaBytes = 10 << 30
}
user, err := h.store.UpdateUser(r.Context(), id, body.DisplayName, body.IsAdmin, body.QuotaBytes, caller.ID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
}
if errors.Is(err, ErrSelfDemote) {
response.Error(w, http.StatusBadRequest, "cannot remove your own admin status")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionUserUpdate, "user", user.ID.String(), user.Username,
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
response.JSON(w, http.StatusOK, user)
}
// POST /api/v1/admin/users/{id}/reset-password
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
// Accept explicit password or generate a random one.
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
newPassword := strings.TrimSpace(body.Password)
if newPassword == "" {
newPassword = randomPassword()
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
if err := h.store.ResetPassword(r.Context(), id, hash); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Protokolliert wird, DASS zurückgesetzt wurde – niemals das Passwort selbst.
h.logAdmin(r, audit.ActionUserPasswordReset, "user", id.String(), "", "")
// Return the new plain-text password to the admin (no email in this system).
response.JSON(w, http.StatusOK, map[string]string{"new_password": newPassword})
}
// DELETE /api/v1/admin/users/{id}
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
caller, ok := requireAdmin(r)
if !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
// Den Namen vor dem Löschen holen: danach lässt sich der Speicherbereich
// des Kontos nicht mehr ermitteln.
victim, err := h.store.GetUser(r.Context(), id)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if err := h.store.DeleteUser(r.Context(), id, caller.ID); errors.Is(err, ErrSelfDemote) {
response.Error(w, http.StatusBadRequest, "cannot delete your own account")
return
} else if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Erst die Datenbank, dann die Dateien: Schlägt das Aufräumen fehl, bleiben
// höchstens Dateileichen zurück – umgekehrt hätte ein Konto ohne seine
// Dateien überlebt. Die Datenbankzeilen sind per Fremdschlüssel-Kaskade weg,
// es gibt also keinen Verweis mehr auf diese Blobs.
if victim.Username != "" {
if err := h.storage.DeletePrefix(r.Context(), "users/"+victim.Username); err != nil {
log.Ctx(r.Context()).Error().Err(err).
Str("username", victim.Username).
Msg("user deleted but their files could not be removed")
}
}
h.logAdmin(r, audit.ActionUserDelete, "user", id.String(), victim.Username,
"account and all files removed")
w.WriteHeader(http.StatusNoContent)
}
// ── Groups ────────────────────────────────────────────────────────────────────
// GET /api/v1/admin/groups
func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groups, err := h.store.ListGroups(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, groups)
}
// POST /api/v1/admin/groups
func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
group, err := h.store.CreateGroup(r.Context(), strings.TrimSpace(body.Name), body.Description)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "group name already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupCreate, "group", group.ID.String(), group.Name, "")
response.JSON(w, http.StatusCreated, group)
}
// PATCH /api/v1/admin/groups/{id}
func (h *Handler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
group, err := h.store.UpdateGroup(r.Context(), id, strings.TrimSpace(body.Name), body.Description)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "group not found")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "group name already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupUpdate, "group", group.ID.String(), group.Name, "")
response.JSON(w, http.StatusOK, group)
}
// DELETE /api/v1/admin/groups/{id}
func (h *Handler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
if err := h.store.DeleteGroup(r.Context(), id); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "group not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupDelete, "group", id.String(), "", "")
w.WriteHeader(http.StatusNoContent)
}
// GET /api/v1/admin/groups/{id}/members
func (h *Handler) ListGroupMembers(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
members, err := h.store.ListGroupMembers(r.Context(), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, members)
}
// POST /api/v1/admin/groups/{id}/members
func (h *Handler) AddGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
var body struct {
UserID uuid.UUID `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.UserID == uuid.Nil {
response.Error(w, http.StatusBadRequest, "user_id required")
return
}
if err := h.store.AddGroupMember(r.Context(), groupID, body.UserID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupMemberAdd, "group", groupID.String(), "",
"user "+body.UserID.String())
w.WriteHeader(http.StatusNoContent)
}
// DELETE /api/v1/admin/groups/{id}/members/{userId}
func (h *Handler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
userID, err := uuid.Parse(chi.URLParam(r, "userId"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
if err := h.store.RemoveGroupMember(r.Context(), groupID, userID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupMemberRemove, "group", groupID.String(), "",
"user "+userID.String())
w.WriteHeader(http.StatusNoContent)
}
// ── Settings ──────────────────────────────────────────────────────────────────
// GET /api/v1/admin/settings
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) {
// Settings are readable by all authenticated users (needed for theming).
if auth.UserFromContext(r.Context()) == nil {
response.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
settings, err := h.store.GetAllSettings(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, settings)
}
// PATCH /api/v1/admin/settings
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
allowed := map[string]bool{
"cloud_name": true, "primary_color": true, "accent_color": true,
}
changed := []string{}
for k, v := range body {
if !allowed[k] {
continue
}
if err := h.store.SetSetting(r.Context(), k, v); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
changed = append(changed, k+"="+v)
}
if len(changed) > 0 {
sort.Strings(changed) // Map-Reihenfolge ist zufällig – sonst wäre das Protokoll unruhig.
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", strings.Join(changed, ", "))
}
settings, _ := h.store.GetAllSettings(r.Context())
response.JSON(w, http.StatusOK, settings)
}
// ── helpers ───────────────────────────────────────────────────────────────────
func randomPassword() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,95 @@
package admin
import (
"encoding/json"
"net/http"
)
// Das Web-App-Manifest kommt aus der Datenbank statt aus dem Build. Vorher
// stand es fest in vite.config.ts und zeigte auf drei mitgelieferte Dateien –
// ein hochgeladenes Symbol konnte die installierte App also nie erreichen.
// Die mitgelieferten Symbole aus frontend/public/, wenn keins hinterlegt ist.
var builtinIcons = []manifestIcon{
{Src: "/icon-192.png", Sizes: "192x192", Type: "image/png"},
{Src: "/icon-512.png", Sizes: "512x512", Type: "image/png"},
{Src: "/icon-maskable-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
}
type manifestIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
// Purpose bleibt bei "any" leer – das ist die Vorgabe, und ein leeres Feld
// ist im Manifest weniger missverständlich als ein ausgeschriebenes "any".
Purpose string `json:"purpose,omitempty"`
}
// ServeManifest liefert /manifest.webmanifest. Ohne Anmeldung: Der Browser
// holt es, bevor irgendjemand angemeldet ist.
func (h *Handler) ServeManifest(w http.ResponseWriter, r *http.Request) {
name := "Aether"
themeColor := "#6366f1"
var iconDir string
if s, err := h.store.GetAllSettings(r.Context()); err == nil {
if n := s["cloud_name"]; n != "" {
name = n
}
if c := s["primary_color"]; c != "" {
themeColor = c
}
iconDir = s[appIconSettingKey]
}
icons := builtinIcons
if sizes := h.AvailableIconSizes(r, iconDir); len(sizes) > 0 {
icons = nil
for _, size := range sizes {
// Das 64er ist das Tab-Symbol. Android will mindestens 192 und
// wertet kleinere Einträge nur als Ballast.
if size == "64" {
continue
}
icon := manifestIcon{Src: AppIconURL(iconDir, size), Type: "image/png"}
if size == "maskable" {
icon.Purpose = "maskable"
icon.Sizes = maskableSizeOf(sizes)
} else {
icon.Sizes = size + "x" + size
}
icons = append(icons, icon)
}
}
manifest := map[string]any{
"name": name,
"short_name": name,
"description": "Your self-hosted cloud",
"theme_color": themeColor,
"background_color": "#0f172a",
"display": "standalone",
"start_url": "/",
"scope": "/",
"lang": "de",
"icons": icons,
}
w.Header().Set("Content-Type", "application/manifest+json")
// Die Symbole darin dürfen lange zwischengespeichert werden, die Liste
// selbst nicht: Sonst bekäme der Browser einen Symbolwechsel tagelang nicht
// mit, und genau darauf wartet die installierte App.
w.Header().Set("Cache-Control", "no-cache")
_ = json.NewEncoder(w).Encode(manifest)
}
// maskableSizeOf nennt die Kantenlänge der maskable-Fassung. Sie entsteht in
// 512, sobald die Vorlage das hergibt – sonst in 192.
func maskableSizeOf(sizes []string) string {
for _, s := range sizes {
if s == "512" {
return "512x512"
}
}
return "192x192"
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
UsedBytes int64 `json:"used_bytes"`
// Groups sind die Namen der Gruppen, in denen der Nutzer Mitglied ist –
// damit die Benutzerliste die Zugehörigkeit zeigt, ohne pro Zeile
// nachladen zu müssen.
Groups []string `json:"groups"`
CreatedAt time.Time `json:"created_at"`
}
type Group struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
}
type GroupMember struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
JoinedAt time.Time `json:"joined_at"`
}
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
}
+297
View File
@@ -0,0 +1,297 @@
package admin
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
var ErrNameTaken = errors.New("name already taken")
var ErrSelfDemote = errors.New("cannot remove own admin status")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
// ── Users ─────────────────────────────────────────────────────────────────────
func (s *Store) ListUsers(ctx context.Context) ([]*User, error) {
rows, err := s.db.Query(ctx,
// Die Gruppen kommen als Unterabfrage, nicht als weiterer JOIN: ein JOIN
// würde die Zeilen vervielfachen und damit die Summe der Dateigrößen
// verfälschen.
`SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes,
COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes,
COALESCE((SELECT array_agg(g.name ORDER BY g.name)
FROM group_members gm
JOIN groups g ON g.id = gm.group_id
WHERE gm.user_id = u.id), '{}') AS groups,
u.created_at
FROM users u
LEFT JOIN files f ON f.owner_id = u.id
GROUP BY u.id
ORDER BY u.created_at ASC`,
)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
users := make([]*User, 0)
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.Groups, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, &u)
}
return users, rows.Err()
}
func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes,
COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes,
u.created_at
FROM users u
LEFT JOIN files f ON f.owner_id = u.id
WHERE u.id = $1
GROUP BY u.id`,
id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &u, err
}
func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordHash string, isAdmin bool, quotaBytes int64) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`INSERT INTO users (username, display_name, password, is_admin, quota_bytes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, is_admin, quota_bytes, 0, created_at`,
username, displayName, passwordHash, isAdmin, quotaBytes,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return &u, nil
}
func (s *Store) UpdateUser(ctx context.Context, id uuid.UUID, displayName string, isAdmin bool, quotaBytes int64, callerID uuid.UUID) (*User, error) {
// Prevent admin from removing their own admin flag.
if id == callerID && !isAdmin {
return nil, ErrSelfDemote
}
var u User
err := s.db.QueryRow(ctx,
`UPDATE users SET display_name=$2, is_admin=$3, quota_bytes=$4
WHERE id=$1
RETURNING id, username, display_name, is_admin, quota_bytes, created_at`,
id, displayName, isAdmin, quotaBytes,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &u, err
}
func (s *Store) ResetPassword(ctx context.Context, id uuid.UUID, newHash string) error {
tag, err := s.db.Exec(ctx,
`UPDATE users SET password=$2 WHERE id=$1`, id, newHash)
if err != nil {
return fmt.Errorf("reset password: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteUser(ctx context.Context, id, callerID uuid.UUID) error {
if id == callerID {
return ErrSelfDemote
}
tag, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ── Groups ────────────────────────────────────────────────────────────────────
func (s *Store) ListGroups(ctx context.Context) ([]*Group, error) {
rows, err := s.db.Query(ctx,
`SELECT g.id, g.name, g.description,
COUNT(gm.user_id) AS member_count,
g.created_at
FROM groups g
LEFT JOIN group_members gm ON gm.group_id = g.id
GROUP BY g.id
ORDER BY g.name`,
)
if err != nil {
return nil, fmt.Errorf("list groups: %w", err)
}
defer rows.Close()
groups := make([]*Group, 0)
for rows.Next() {
var g Group
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt); err != nil {
return nil, err
}
groups = append(groups, &g)
}
return groups, rows.Err()
}
func (s *Store) CreateGroup(ctx context.Context, name, description string) (*Group, error) {
var g Group
err := s.db.QueryRow(ctx,
`INSERT INTO groups (name, description)
VALUES ($1, $2)
RETURNING id, name, description, 0, created_at`,
name, description,
).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("create group: %w", err)
}
return &g, nil
}
// UpdateGroup ändert Name und Beschreibung einer Gruppe.
func (s *Store) UpdateGroup(ctx context.Context, id uuid.UUID, name, description string) (*Group, error) {
var g Group
err := s.db.QueryRow(ctx,
`UPDATE groups SET name=$2, description=$3
WHERE id=$1
RETURNING id, name, description,
(SELECT COUNT(*) FROM group_members WHERE group_id = groups.id),
created_at`,
id, name, description,
).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("update group: %w", err)
}
return &g, nil
}
func (s *Store) DeleteGroup(ctx context.Context, id uuid.UUID) error {
tag, err := s.db.Exec(ctx, `DELETE FROM groups WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ListGroupMembers(ctx context.Context, groupID uuid.UUID) ([]*GroupMember, error) {
rows, err := s.db.Query(ctx,
`SELECT u.id, u.username, u.display_name, gm.joined_at
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1
ORDER BY u.username`,
groupID,
)
if err != nil {
return nil, fmt.Errorf("list members: %w", err)
}
defer rows.Close()
members := make([]*GroupMember, 0)
for rows.Next() {
var m GroupMember
if err := rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.JoinedAt); err != nil {
return nil, err
}
members = append(members, &m)
}
return members, rows.Err()
}
func (s *Store) AddGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
_, err := s.db.Exec(ctx,
`INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
groupID, userID,
)
return err
}
func (s *Store) RemoveGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
_, err := s.db.Exec(ctx,
`DELETE FROM group_members WHERE group_id=$1 AND user_id=$2`,
groupID, userID,
)
return err
}
// ── Settings ──────────────────────────────────────────────────────────────────
func (s *Store) GetAllSettings(ctx context.Context) (map[string]string, error) {
rows, err := s.db.Query(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
result[k] = v
}
return result, rows.Err()
}
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
_, err := s.db.Exec(ctx,
`INSERT INTO settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()`,
key, value,
)
return err
}
// ── helpers ───────────────────────────────────────────────────────────────────
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
type sqlStater interface{ SQLState() string }
var se sqlStater
if errors.As(err, &se) {
return se.SQLState() == "23505"
}
return false
}