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

+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/gorilla/sessions"
"aether/pkg/middleware"
)
const (
sessionName = "aether_session"
sessionKeyUser = "user_id"
csrfCookieName = "aether_csrf"
csrfHeaderName = "X-CSRF-Token"
)
type SessionStore struct {
store *sessions.CookieStore
maxAge int
}
func NewSessionStore(secret string, maxAge int) *SessionStore {
store := sessions.NewCookieStore([]byte(secret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
return &SessionStore{store: store, maxAge: maxAge}
}
func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID uuid.UUID) error {
sess, err := ss.store.Get(r, sessionName)
if err != nil {
sess, _ = ss.store.New(r, sessionName)
}
sess.Values[sessionKeyUser] = userID.String()
sess.Options.Secure = middleware.IsHTTPS(r)
return ss.store.Save(r, w, sess)
}
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
sess, err := ss.store.Get(r, sessionName)
if err != nil {
return uuid.Nil, false
}
raw, ok := sess.Values[sessionKeyUser].(string)
if !ok {
return uuid.Nil, false
}
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, false
}
return id, true
}
func (ss *SessionStore) Clear(w http.ResponseWriter, r *http.Request) error {
sess, err := ss.store.Get(r, sessionName)
if err != nil {
return nil
}
sess.Options.MaxAge = -1
return ss.store.Save(r, w, sess)
}
// SetCSRFCookie sets a CSRF token as a readable (non-httpOnly) cookie.
func SetCSRFCookie(w http.ResponseWriter, r *http.Request) (string, error) {
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(token)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: encoded,
Path: "/",
HttpOnly: false, // must be readable by JS
Secure: middleware.IsHTTPS(r),
SameSite: http.SameSiteStrictMode,
})
return encoded, nil
}
// ValidateCSRF prüft, dass das mitgeschickte Token zum CSRF-Cookie passt
// (Double-Submit).
//
// Neben dem Header wird auch ein Formularfeld akzeptiert. Grund: Ein Download
// muss aus einer **Navigation** kommen, damit der Browser ihn als Datei
// behandelt statt ihn in den Speicher zu laden – und ein abgeschicktes
// Formular kann keine Header setzen. Am Schutz ändert das nichts: Verglichen
// wird weiterhin gegen das Cookie, an das eine fremde Seite nicht herankommt.
func ValidateCSRF(r *http.Request) bool {
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
return false
}
if token := r.Header.Get(csrfHeaderName); token != "" {
return token == cookie.Value
}
// Nur bei Formularen nachsehen: Bei anderen Inhaltstypen würde ParseForm
// den Rumpf lesen, den der eigentliche Handler danach noch braucht.
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
if err := r.ParseForm(); err != nil {
return false
}
token := r.PostFormValue("csrf_token")
return token != "" && token == cookie.Value
}
return false
}