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,211 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil vier
|
||||
// Abfragen mit derselben Reihenfolge scannen und eine übersehene Ergänzung
|
||||
// sonst erst zur Laufzeit auffällt.
|
||||
const userCols = `id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme,
|
||||
oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`
|
||||
|
||||
var ErrNotFound = errors.New("user not found")
|
||||
var ErrUsernameTaken = errors.New("username already taken")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) GetByUsername(ctx context.Context, username string) (*User, string, error) {
|
||||
var u User
|
||||
var passwordHash string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, password, is_admin, quota_bytes, created_at
|
||||
FROM users WHERE username = $1`,
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("query user: %w", err)
|
||||
}
|
||||
return &u, passwordHash, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
|
||||
FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO users (username, password, is_admin)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
|
||||
username, passwordHash, isAdmin,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetOrCreateByOIDC findet den Nutzer über sein OIDC-Subject – oder legt ihn an.
|
||||
//
|
||||
// linkByUsername entscheidet über den heiklen Fall: Es gibt bereits ein Konto
|
||||
// mit diesem Benutzernamen, aber ohne Bindung an eine OIDC-Identität.
|
||||
//
|
||||
// - **aus (Voreinstellung):** Es entsteht ein *zweites* Konto. Da der Name
|
||||
// schon vergeben ist, heißt es „name2" – leer, ohne Adminrechte, ohne die
|
||||
// bisherigen Dateien. Wer seine Cloud bisher mit Passwort benutzt hat und
|
||||
// dann OIDC einschaltet, landet also in einer fremden, leeren Cloud.
|
||||
// - **an:** Das bestehende Konto wird mit der Identität verbunden. Dateien,
|
||||
// Adminrecht und Freigaben bleiben, künftig meldet man sich darüber an.
|
||||
//
|
||||
// **Warum das nicht die Voreinstellung ist:** Verbinden heißt, dem
|
||||
// Identitätsanbieter zu glauben, dass „marc" bei ihm derselbe „marc" ist wie
|
||||
// hier. Bei einem eigenen Anbieter, dessen Konten man selbst anlegt, stimmt
|
||||
// das. Bei einem Anbieter, bei dem sich Fremde registrieren und ihren
|
||||
// Benutzernamen frei wählen können, wäre es die Übernahme fremder Konten.
|
||||
// Deshalb eine bewusste Entscheidung in der .env statt einer stillen Annahme.
|
||||
//
|
||||
// Verbunden wird nur, was **noch keine** Bindung hat: Ein Konto, das bereits zu
|
||||
// einer anderen Identität gehört, wird nie überschrieben.
|
||||
func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayName string, linkByUsername bool) (*User, error) {
|
||||
// First: try to find by oidc_sub.
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
|
||||
FROM users WHERE oidc_sub=$1`,
|
||||
sub,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Bestehendes Konto übernehmen, falls gewünscht und noch ungebunden.
|
||||
if linkByUsername {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`UPDATE users SET oidc_sub = $1
|
||||
WHERE username = $2 AND (oidc_sub IS NULL OR oidc_sub = '')
|
||||
RETURNING `+userCols,
|
||||
sub, username,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
// Kein Treffer heißt: kein solches Konto oder schon anderweitig
|
||||
// gebunden. Beides führt weiter unten zum Anlegen eines neuen.
|
||||
}
|
||||
|
||||
// Not found: create a new user. Ensure username uniqueness.
|
||||
candidate := username
|
||||
for i := 2; i <= 99; i++ {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, oidc_sub)
|
||||
VALUES ($1, $2, '', $3)
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
|
||||
candidate, displayName, sub,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
if isUniqueViolation(err) {
|
||||
candidate = fmt.Sprintf("%s%d", username, i)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("create oidc user: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("could not find unique username for %q", username)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayName string) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET display_name=$2 WHERE id=$1`, id, displayName)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUseCustomTheme legt um, ob die persönliche Palette angewendet wird.
|
||||
// Die gespeicherten Farben bleiben dabei unangetastet.
|
||||
func (s *Store) SetUseCustomTheme(ctx context.Context, id uuid.UUID, enabled bool) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET use_custom_theme=$2 WHERE id=$1`, id, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateColors setzt die persönliche Palette. Leere Werte werden als NULL
|
||||
// gespeichert – dann gilt wieder die globale Einstellung des Administrators.
|
||||
func (s *Store) UpdateColors(ctx context.Context, id uuid.UUID, primary, accent string) error {
|
||||
var p, a *string
|
||||
if primary != "" {
|
||||
p = &primary
|
||||
}
|
||||
if accent != "" {
|
||||
a = &accent
|
||||
}
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE users SET primary_color=$2, accent_color=$3 WHERE id=$1`, id, p, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePassword(ctx context.Context, id uuid.UUID, newHash string) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET password=$2 WHERE id=$1`, id, newHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AdminExists(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE is_admin = true`).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && (containsCode(err, "23505"))
|
||||
}
|
||||
|
||||
func containsCode(err error, code string) bool {
|
||||
return err != nil && len(err.Error()) > 0 &&
|
||||
(func() bool {
|
||||
type pgErr interface{ SQLState() string }
|
||||
var pe pgErr
|
||||
if errors.As(err, &pe) {
|
||||
return pe.SQLState() == code
|
||||
}
|
||||
return false
|
||||
}())
|
||||
}
|
||||
Reference in new issue
Block a user