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>
298 lines
8.9 KiB
Go
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
|
|
}
|