Files
Aether/backend/internal/admin/store.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

298 lines
8.9 KiB
Go

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
}