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

+155
View File
@@ -0,0 +1,155 @@
package webdav
import (
"io"
"net/http"
"strconv"
"time"
xwebdav "golang.org/x/net/webdav"
"aether/internal/acl"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"aether/pkg/middleware"
)
// Brute-force-Schutz für /dav/. Der Endpunkt nutzt Basic Auth und läuft am
// Rate-Limit des normalen Logins vorbei – ohne eigene Bremse ließe sich hier
// beliebig oft raten.
const (
davMaxFailures = 10
davWindow = 15 * time.Minute
davLockout = 15 * time.Minute
)
// maxChallengeDrain begrenzt, wie viel von einem unauthentifizierten Upload
// verworfen wird, um die Verbindung am Leben zu halten – siehe drainForChallenge.
const maxChallengeDrain = 4 << 30 // 4 GiB
// Handler serves WebDAV at /dav/ with HTTP Basic Auth.
type Handler struct {
authStore *auth.Store
fileStore *files.Store
acl *acl.Store
storage storage.Storage
locks xwebdav.LockSystem
limiter *middleware.FailureLimiter
}
func NewHandler(authStore *auth.Store, fileStore *files.Store, aclStore *acl.Store, st storage.Storage) *Handler {
return &Handler{
authStore: authStore,
fileStore: fileStore,
acl: aclStore,
storage: st,
locks: xwebdav.NewMemLS(),
limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout),
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
clientIP := middleware.RealIP(r)
if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed {
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests)
return
}
username, password, ok := r.BasicAuth()
if !ok || username == "" || password == "" {
drainForChallenge(r)
challenge(w)
return
}
// Nur App-Passwörter. Das Kontopasswort ist zugleich der Zugang zum
// Admin-Bereich – wer es hier errät, bekommt weit mehr als Dateizugriff.
// Ein App-Passwort öffnet ausschließlich diesen Endpunkt und lässt sich
// einzeln widerrufen, ohne dass jeder andere Client neu eingerichtet werden
// muss. Nebeneffekt: ein indizierter Lookup statt eines Argon2-Durchlaufs
// bei jeder einzelnen Anfrage (gemessen 0,5 ms statt 22,7 ms).
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
if err != nil {
h.limiter.Fail(clientIP)
challenge(w)
return
}
h.limiter.Reset(clientIP)
quotaHit, denied := false, false
dav := &xwebdav.Handler{
FileSystem: newFS(h.fileStore, h.storage, h.acl, user, &quotaHit, &denied, r.ContentLength),
LockSystem: h.locks,
Prefix: "/dav",
}
dav.ServeHTTP(&statusWriter{ResponseWriter: w, quotaHit: &quotaHit, denied: &denied}, r)
}
// challenge fordert zur Anmeldung auf. Der Realm-Text nennt ausdrücklich das
// App-Passwort: Clients zeigen ihn im Anmeldedialog an, und ohne diesen Hinweis
// tippt man dort wieder und wieder sein Kontopasswort ein und versteht nicht,
// warum es abgelehnt wird.
func challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Aether WebDAV - use an app password, not your account password"`)
http.Error(w, "Unauthorized - WebDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
}
// drainForChallenge liest den Rumpf einer unauthentifizierten Anfrage weg,
// bevor die Aufforderung zur Anmeldung rausgeht.
//
// Klingt verschwenderisch, ist aber der Unterschied zwischen „Upload geht" und
// „Upload geht nicht": Manche Clients – KDEs KIO etwa – schicken grundsätzlich
// erst ohne Zugangsdaten und wiederholen die Anfrage nach der 401. Dabei senden
// sie den Rumpf sofort mit, ohne `Expect: 100-continue` abzuwarten. Antwortet
// der Server dann sofort mit 401, kann Go den angefangenen Rumpf nicht mehr
// loswerden und schließt die Verbindung. Der Client steckt aber noch mitten im
// Schreiben, bekommt einen Verbindungsabbruch und meldet einen nichtssagenden
// Fehler, statt die 401 zu lesen und sich anzumelden.
//
// Wer `Expect: 100-continue` schickt (curl, rclone, Finder), zahlt hier nichts:
// Go beantwortet die Aufforderung, bevor auch nur ein Byte des Rumpfes fließt.
// Die doppelte Übertragung trifft also nur Clients, die es nicht besser machen.
func drainForChallenge(r *http.Request) {
if r.ContentLength <= 0 || r.Header.Get("Expect") != "" {
return
}
// Obergrenze, damit eine unauthentifizierte Anfrage den Server nicht
// beliebig lange beschäftigen kann. Darüber bleibt es beim Abbruch.
_, _ = io.CopyN(io.Discard, r.Body, maxChallengeDrain)
}
// statusWriter korrigiert Statuscodes, die x/net/webdav pauschal vergibt.
//
// Die Bibliothek bildet jeden Fehler aus einer Operation auf einen festen Code
// ab – ein gescheiterter io.Copy wird zu 405, ein OpenFile-Fehler zu 404. Der
// Client zeigt dann „Methode nicht erlaubt" oder „nicht gefunden", obwohl die
// Ursache eine ganz andere war. Zwei Fälle lohnen die Korrektur:
//
// - Kontingent voll → 507 Insufficient Storage (RFC 4918)
// - fehlendes Recht → 403 Forbidden
//
// Ohne die zweite Zuordnung sähe jemand, der in einem nur lesbar freigegebenen
// Ordner schreiben will, ein „nicht gefunden" – und suchte den Fehler beim
// Pfad statt bei seinen Rechten.
type statusWriter struct {
http.ResponseWriter
quotaHit *bool
denied *bool
wroteHeader bool
}
func (w *statusWriter) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.wroteHeader = true
switch {
case *w.quotaHit && code == http.StatusMethodNotAllowed:
code = http.StatusInsufficientStorage
case *w.denied && (code == http.StatusMethodNotAllowed || code == http.StatusNotFound || code == http.StatusConflict):
code = http.StatusForbidden
}
w.ResponseWriter.WriteHeader(code)
}