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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user