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,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, "aHit, &denied, r.ContentLength),
|
||||
LockSystem: h.locks,
|
||||
Prefix: "/dav",
|
||||
}
|
||||
dav.ServeHTTP(&statusWriter{ResponseWriter: w, quotaHit: "aHit, 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)
|
||||
}
|
||||
Reference in new issue
Block a user