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

+266
View File
@@ -0,0 +1,266 @@
package auth
import (
"encoding/json"
"net/http"
"strings"
"aether/internal/audit"
"aether/pkg/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
sessions *SessionStore
usage UsageQuerier
auditor *audit.Logger
}
func NewHandler(store *Store, sessions *SessionStore, usage UsageQuerier, auditor *audit.Logger) *Handler {
return &Handler{store: store, sessions: sessions, usage: usage, auditor: auditor}
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// POST /api/v1/auth/login
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
response.Error(w, http.StatusBadRequest, "username and password required")
return
}
user, hash, err := h.store.GetByUsername(r.Context(), req.Username)
if err != nil {
// constant-time: always hash even if user not found to prevent timing attacks
_, _ = VerifyPassword(req.Password, "$argon2id$v=19$m=65536,t=1,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
h.auditor.Log(r.Context(), audit.Event{
ActorName: req.Username,
Action: audit.ActionLoginFailed,
IP: middleware.RealIP(r),
Detail: "unknown account",
})
response.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
ok, err := VerifyPassword(req.Password, hash)
if err != nil || !ok {
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionLoginFailed,
IP: middleware.RealIP(r),
Detail: "wrong password",
})
response.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
response.Error(w, http.StatusInternalServerError, "session error")
return
}
csrfToken, err := SetCSRFCookie(w, r)
if err != nil {
response.Error(w, http.StatusInternalServerError, "csrf error")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionLogin,
IP: middleware.RealIP(r),
})
// Denselben Wert wie /me mitgeben: Ohne ihn stand der Speicherbalken
// direkt nach der Anmeldung auf 0 B und sprang erst beim nächsten
// Nutzer-Abgleich auf den echten Stand.
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
user.UsedBytes = used
}
response.JSON(w, http.StatusOK, map[string]any{
"user": user,
"csrf_token": csrfToken,
})
}
// POST /api/v1/auth/logout
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
// Die Route liegt bewusst nicht hinter RequireAuth – abmelden soll auch
// mit abgelaufener Sitzung klappen und nie mit 401 scheitern. Dadurch
// steht aber kein Nutzer im Kontext, und der Eintrag im Protokoll blieb
// bisher aus. Also hier selbst aus der Sitzung nachschlagen.
if userID, ok := h.sessions.GetUserID(r); ok {
name := ""
if u, err := h.store.GetByID(r.Context(), userID); err == nil {
name = u.Username
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &userID,
ActorName: name,
Action: audit.ActionLogout,
IP: middleware.RealIP(r),
})
}
_ = h.sessions.Clear(w, r)
// clear CSRF cookie
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: "",
Path: "/",
MaxAge: -1,
})
response.NoContent(w)
}
// PATCH /api/v1/auth/profile – update own display_name
// isHexColor lässt leere Werte zu – leer bedeutet "globale Farbe verwenden".
func isHexColor(v string) bool {
if v == "" {
return true
}
if len(v) != 7 || v[0] != '#' {
return false
}
for _, c := range v[1:] {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
return false
}
}
return true
}
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
// Zeiger, damit sich "Feld nicht mitgeschickt" von "auf leer setzen"
// unterscheiden lässt: Der Profilname und die Farben werden getrennt
// gespeichert, sollen sich aber nicht gegenseitig überschreiben.
var body struct {
DisplayName *string `json:"display_name"`
PrimaryColor *string `json:"primary_color"`
AccentColor *string `json:"accent_color"`
UseCustomTheme *bool `json:"use_custom_theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if body.DisplayName != nil {
name := strings.TrimSpace(*body.DisplayName)
if err := h.store.UpdateDisplayName(r.Context(), user.ID, name); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.DisplayName = name
}
if body.PrimaryColor != nil || body.AccentColor != nil {
primary, accent := user.PrimaryColor, user.AccentColor
if body.PrimaryColor != nil {
primary = strings.TrimSpace(*body.PrimaryColor)
}
if body.AccentColor != nil {
accent = strings.TrimSpace(*body.AccentColor)
}
if !isHexColor(primary) || !isHexColor(accent) {
response.Error(w, http.StatusBadRequest, "colors must be hex values like #4f46e5")
return
}
if err := h.store.UpdateColors(r.Context(), user.ID, primary, accent); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.PrimaryColor, user.AccentColor = primary, accent
}
if body.UseCustomTheme != nil {
if err := h.store.SetUseCustomTheme(r.Context(), user.ID, *body.UseCustomTheme); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.UseCustomTheme = *body.UseCustomTheme
}
response.JSON(w, http.StatusOK, user)
}
// POST /api/v1/auth/change-password
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.CurrentPassword == "" || body.NewPassword == "" {
response.Error(w, http.StatusBadRequest, "current_password and new_password required")
return
}
// Konten, die über den Identitätsanbieter kommen, haben hier kein Passwort.
// Der Aufruf scheitert ohnehin – '' ist kein gültiger Argon2-Hash –, aber
// mit „current password incorrect" statt mit dem wahren Grund. Diese
// Prüfung sagt, was los ist; die Oberfläche blendet das Formular
// zusätzlich aus.
if user.IsOIDC {
response.Error(w, http.StatusBadRequest,
"this account signs in through your identity provider — change the password there")
return
}
_, hash, err := h.store.GetByUsername(r.Context(), user.Username)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
ok, err := VerifyPassword(body.CurrentPassword, hash)
if err != nil || !ok {
response.Error(w, http.StatusUnauthorized, "current password incorrect")
return
}
newHash, err := HashPassword(body.NewPassword)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
if err := h.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionPasswordChange,
IP: middleware.RealIP(r),
})
response.NoContent(w)
}
// GET /api/v1/auth/me
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
user.UsedBytes = used
}
// Refresh CSRF token on each /me call so frontend always has a valid one.
csrfToken, err := SetCSRFCookie(w, r)
if err != nil {
response.Error(w, http.StatusInternalServerError, "csrf error")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"user": user,
"csrf_token": csrfToken,
})
}