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,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