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>
156 lines
5.6 KiB
Go
156 lines
5.6 KiB
Go
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)
|
||
}
|