Files
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

156 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}