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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
// Package audit protokolliert sicherheitsrelevante Vorgänge.
//
// Leitgedanke: Ein Audit-Eintrag ist nur so viel wert, wie er nach einem
// Vorfall noch aussagt. Deshalb werden Namen mitgeschrieben statt nur IDs, und
// das Protokollieren darf die eigentliche Aktion niemals scheitern lassen –
// ein kaputtes Log ist ärgerlich, eine deswegen abgebrochene Anmeldung wäre
// schlimmer.
package audit
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
// Actions – als Konstanten, damit Filter und Schreiber nicht auseinanderlaufen.
const (
ActionLogin = "auth.login"
ActionLoginFailed = "auth.login_failed"
ActionLogout = "auth.logout"
ActionPasswordChange = "auth.password_change"
ActionAppPasswordCreate = "app_password.create"
ActionAppPasswordRevoke = "app_password.revoke"
ActionUserCreate = "admin.user_create"
ActionUserUpdate = "admin.user_update"
ActionUserDelete = "admin.user_delete"
ActionUserPasswordReset = "admin.user_password_reset"
ActionGroupCreate = "admin.group_create"
ActionGroupUpdate = "admin.group_update"
ActionGroupDelete = "admin.group_delete"
ActionGroupMemberAdd = "admin.group_member_add"
ActionGroupMemberRemove = "admin.group_member_remove"
ActionSettingsUpdate = "admin.settings_update"
ActionGroupFolderCreate = "admin.group_folder_create"
ActionGroupFolderDelete = "admin.group_folder_delete"
ActionShareCreate = "share.create"
ActionShareDelete = "share.delete"
ActionShareGrant = "share.grant"
ActionShareRevoke = "share.revoke"
ActionFileDelete = "file.delete"
ActionTrashEmpty = "file.trash_empty"
ActionHardDelete = "file.hard_delete"
ActionFileRestore = "file.restore"
ActionGarbageCollect = "system.gc"
)
// Entry ist ein Eintrag, wie ihn die Admin-Ansicht liest.
type Entry struct {
ID uuid.UUID `json:"id"`
ActorID *uuid.UUID `json:"actor_id"`
ActorName string `json:"actor_name"`
Action string `json:"action"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
TargetName *string `json:"target_name"`
IP *string `json:"ip"`
Detail *string `json:"detail"`
CreatedAt time.Time `json:"created_at"`
}
// Event beschreibt einen zu protokollierenden Vorgang.
type Event struct {
ActorID *uuid.UUID
ActorName string
Action string
TargetType string
TargetID string
TargetName string
IP string
Detail string
}
type Logger struct {
db *pgxpool.Pool
}
func NewLogger(db *pgxpool.Pool) *Logger {
return &Logger{db: db}
}
// Log schreibt einen Eintrag. Fehler werden geloggt, nicht zurückgegeben: der
// Aufrufer soll seine Aktion nicht abbrechen, nur weil das Protokoll klemmt.
//
// Bewusst mit eigenem Kontext: bei einem abgebrochenen Request (Client weg,
// Timeout) ist der Vorgang trotzdem passiert und gehört ins Protokoll.
func (l *Logger) Log(ctx context.Context, e Event) {
if l == nil || l.db == nil {
return
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_, err := l.db.Exec(writeCtx,
`INSERT INTO audit_log
(actor_id, actor_name, action, target_type, target_id, target_name, ip, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
e.ActorID, e.ActorName, e.Action,
nullIfEmpty(e.TargetType), nullIfEmpty(e.TargetID), nullIfEmpty(e.TargetName),
nullIfEmpty(e.IP), nullIfEmpty(e.Detail),
)
if err != nil {
log.Ctx(ctx).Error().Err(err).
Str("action", e.Action).
Str("actor", e.ActorName).
Msg("audit: could not write entry")
}
}
// Filter für die Admin-Ansicht.
type Filter struct {
Action string
ActorID *uuid.UUID
Limit int
Offset int
}
const (
defaultLimit = 100
maxLimit = 500
)
// List liefert Einträge, neueste zuerst, plus die Gesamtzahl für die
// Blätterfunktion.
func (l *Logger) List(ctx context.Context, f Filter) ([]Entry, int, error) {
limit := f.Limit
if limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}
var total int
if err := l.db.QueryRow(ctx,
`SELECT count(*) FROM audit_log
WHERE ($1 = '' OR action = $1)
AND ($2::uuid IS NULL OR actor_id = $2)`,
f.Action, f.ActorID,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count audit entries: %w", err)
}
rows, err := l.db.Query(ctx,
`SELECT id, actor_id, actor_name, action, target_type, target_id,
target_name, ip, detail, created_at
FROM audit_log
WHERE ($1 = '' OR action = $1)
AND ($2::uuid IS NULL OR actor_id = $2)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4`,
f.Action, f.ActorID, limit, f.Offset)
if err != nil {
return nil, 0, fmt.Errorf("query audit entries: %w", err)
}
defer rows.Close()
entries := []Entry{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.ActorID, &e.ActorName, &e.Action,
&e.TargetType, &e.TargetID, &e.TargetName, &e.IP, &e.Detail, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, rows.Err()
}
// Actions listet die tatsächlich vorkommenden Aktionen – füttert das
// Filter-Dropdown, damit dort nichts steht, was es im Protokoll gar nicht gibt.
func (l *Logger) Actions(ctx context.Context) ([]string, error) {
rows, err := l.db.Query(ctx, `SELECT DISTINCT action FROM audit_log ORDER BY action`)
if err != nil {
return nil, fmt.Errorf("query audit actions: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, fmt.Errorf("scan audit action: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}