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

+271
View File
@@ -0,0 +1,271 @@
package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
AppEnv string
ListenAddr string // APP_LISTEN_ADDR, e.g. ":8080"
PublicURL string // PUBLIC_URL, e.g. "https://cloud.example.com"
// TrustedProxies is the list of IPs/CIDRs whose X-Forwarded-* headers are trusted.
TrustedProxies []*net.IPNet
DBHost string
DBPort string
DBName string
DBUser string
DBPassword string
SessionSecret string
SessionMaxAge int
StoragePath string
TmpPath string
AdminUsername string
AdminPassword string
// ExternalApps erscheinen im App-Umschalter der Oberfläche. Kommt aus der
// Umgebung, damit neue Apps ohne Neubau des Frontends dazukommen können.
ExternalApps []ExternalApp
// OCR – Volltextsuche über den Inhalt von PDFs und Bildern.
// Standardmäßig an; fehlen die Programme im Image, schaltet sich der
// Arbeiter beim Start selbst ab, statt den Start zu verhindern.
OCREnabled bool
OCRLanguages string // OCR_LANGUAGES, z. B. "deu+eng"
OCRMaxPages int // je Datei; 0 = unbegrenzt
OCRTimeout time.Duration // je Datei
// OIDC – all optional; if OIDCIssuer is empty, OIDC login is disabled.
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
OIDCRedirectURL string
// OIDCLinkByUsername verbindet ein bestehendes Passwort-Konto mit der
// OIDC-Identität, statt ein zweites anzulegen. Voreingestellt **aus** –
// siehe die Begründung an auth.Store.GetOrCreateByOIDC.
OIDCLinkByUsername bool
// Office – optional. Ist OfficeProvider leer, ist die Bearbeitung aus und
// die Anwendung verhält sich wie zuvor.
//
// Ein Adapter für beide Anbieter: Collabora und OnlyOffice sprechen
// dieselbe Schnittstelle (WOPI). Zwei Integrationen zu bauen hieße,
// dieselbe Sache zweimal zu pflegen.
OfficeProvider string // "collabora" | "onlyoffice" | "" (aus)
OfficeURL string // Adresse, unter der das **Backend** den Server erreicht
// OfficePublicURL ist die Adresse, unter der der **Browser** ihn erreicht.
//
// Die beiden sind selten gleich: Im Compose-Netz heißt der Server
// `collabora`, im Browser muss dort ein Name stehen, den der Rechner des
// Menschen auflösen kann. Seine Ermittlungsliste nennt zudem seinen
// eigenen internen Namen – die Adresse muss also umgeschrieben werden.
// Leer heißt: dieselbe wie OfficeURL.
OfficePublicURL string
// OfficeWopiURL ist die Adresse, unter der der **Office-Server** Aether
// erreicht.
//
// Der dritte Weg in diesem Dreieck, und der am leichtesten übersehene:
// Der Office-Server holt die Datei selbst hier ab. Aus seinem Container
// heraus ist „localhost" er selbst – PUBLIC_URL taugt dafür also nur,
// wenn es ein Name ist, den auch er auflöst. Leer heißt: PUBLIC_URL.
OfficeWopiURL string
// OfficeAllowedHosts begrenzt, welche Adressen das Backend anfragen darf.
// Ohne diese Grenze wird aus einem Tippfehler oder einer untergeschobenen
// Konfiguration ein Weg ins interne Netz (SSRF).
OfficeAllowedHosts []string
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
OfficeInsecureTLS bool
}
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
func splitList(raw string) []string {
var out []string
for _, part := range strings.Split(raw, ",") {
if v := strings.TrimSpace(part); v != "" {
out = append(out, v)
}
}
return out
}
// OfficeEnabled sagt, ob die Bearbeitung überhaupt in Frage kommt. Beides muss
// gesetzt sein – ein Anbieter ohne Adresse wäre eine halbe Konfiguration, und
// die soll nicht erst beim ersten Klick auffallen.
func (c *Config) OfficeEnabled() bool {
return (c.OfficeProvider == "collabora" || c.OfficeProvider == "onlyoffice") && c.OfficeURL != ""
}
// ExternalApp ist ein Eintrag im App-Umschalter.
type ExternalApp struct {
Name string `json:"name"`
URL string `json:"url"`
Icon string `json:"icon"` // ein Emoji, optional
}
// parseExternalApps liest EXTERNAL_APPS im Format
//
// Name|https://url|Emoji , Name|https://url|Emoji
//
// Einträge ohne Namen oder URL werden übersprungen, damit ein Tippfehler in der
// .env nicht den Start verhindert.
func parseExternalApps(raw string) []ExternalApp {
var apps []ExternalApp
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, "|")
if len(parts) < 2 {
continue
}
app := ExternalApp{
Name: strings.TrimSpace(parts[0]),
URL: strings.TrimSpace(parts[1]),
}
if len(parts) > 2 {
app.Icon = strings.TrimSpace(parts[2])
}
if app.Name == "" || app.URL == "" {
continue
}
apps = append(apps, app)
}
return apps
}
func Load() (*Config, error) {
cfg := &Config{
AppEnv: getEnv("APP_ENV", "development"),
ListenAddr: getEnv("APP_LISTEN_ADDR", ":8080"),
PublicURL: getEnv("PUBLIC_URL", "http://localhost:8080"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBName: getEnv("DB_NAME", "aether"),
DBUser: getEnv("DB_USER", "aether"),
DBPassword: getEnv("DB_PASSWORD", ""),
SessionSecret: getEnv("SESSION_SECRET", ""),
StoragePath: getEnv("STORAGE_PATH", "./data"),
TmpPath: getEnv("TMP_PATH", "./data/tmp"),
AdminUsername: getEnv("ADMIN_USERNAME", ""),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
ExternalApps: parseExternalApps(getEnv("EXTERNAL_APPS", "")),
OCREnabled: getEnv("OCR_ENABLED", "true") != "false",
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")),
OfficeInsecureTLS: getEnv("OFFICE_INSECURE_TLS", "false") == "true",
OCRLanguages: getEnv("OCR_LANGUAGES", "deu+eng"),
OIDCIssuer: getEnv("OIDC_ISSUER", ""),
OIDCClientID: getEnv("OIDC_CLIENT_ID", ""),
OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""),
OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""),
// Nur ein ausdrückliches "true" schaltet es ein: Ein Tippfehler soll
// nicht dazu führen, dass Konten stillschweigend übernommen werden.
OIDCLinkByUsername: getEnv("OIDC_LINK_BY_USERNAME", "false") == "true",
}
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "86400"))
if err != nil {
return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err)
}
cfg.SessionMaxAge = maxAge
if cfg.SessionSecret == "" {
return nil, fmt.Errorf("SESSION_SECRET must be set")
}
if cfg.DBPassword == "" {
return nil, fmt.Errorf("DB_PASSWORD must be set")
}
// Grenzen für die Texterkennung. Ein Tippfehler in der .env soll den Start
// nicht verhindern – deshalb Rückfall auf den Standard statt Abbruch.
if n, err := strconv.Atoi(getEnv("OCR_MAX_PAGES", "50")); err == nil && n >= 0 {
cfg.OCRMaxPages = n
} else {
cfg.OCRMaxPages = 50
}
if d, err := time.ParseDuration(getEnv("OCR_TIMEOUT", "10m")); err == nil && d > 0 {
cfg.OCRTimeout = d
} else {
cfg.OCRTimeout = 10 * time.Minute
}
cfg.TrustedProxies, err = parseCIDRList(getEnv("TRUSTED_PROXIES", ""))
if err != nil {
return nil, fmt.Errorf("invalid TRUSTED_PROXIES: %w", err)
}
return cfg, nil
}
func (c *Config) DSN() string {
return fmt.Sprintf(
"host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPassword,
)
}
func (c *Config) IsDev() bool {
return c.AppEnv == "development"
}
// IsTrustedProxy reports whether ip is in the TrustedProxies list.
func (c *Config) IsTrustedProxy(ip net.IP) bool {
for _, network := range c.TrustedProxies {
if network.Contains(ip) {
return true
}
}
return false
}
func parseCIDRList(raw string) ([]*net.IPNet, error) {
var networks []*net.IPNet
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// Accept plain IPs by appending /32 or /128.
if !strings.Contains(entry, "/") {
if strings.Contains(entry, ":") {
entry += "/128"
} else {
entry += "/32"
}
}
_, network, err := net.ParseCIDR(entry)
if err != nil {
return nil, fmt.Errorf("cannot parse %q: %w", entry, err)
}
networks = append(networks, network)
}
return networks, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+107
View File
@@ -0,0 +1,107 @@
package db
import (
"context"
"embed"
"fmt"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return pool, nil
}
func Migrate(dsn string) error {
src, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("load migrations: %w", err)
}
// convert pgx DSN → lib/pq DSN format for migrate
m, err := migrate.NewWithSourceInstance("iofs", src, "postgres://"+dsnToURL(dsn))
if err != nil {
return fmt.Errorf("init migrate: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
// dsnToURL converts "host=... port=... dbname=... user=... password=... sslmode=..."
// to a postgres:// URL for golang-migrate.
func dsnToURL(dsn string) string {
// pgxpool DSN is already accepted by migrate if we prefix with postgres://
// Actually golang-migrate accepts the postgres:// URL format.
// Since our DSN is key=value, we need to convert it.
// Simplest approach: parse key=value pairs.
params := map[string]string{}
for _, part := range splitDSN(dsn) {
if len(part) == 0 {
continue
}
idx := indexByte(part, '=')
if idx < 0 {
continue
}
params[part[:idx]] = part[idx+1:]
}
host := params["host"]
port := params["port"]
if port == "" {
port = "5432"
}
return fmt.Sprintf("%s:%s@%s:%s/%s?sslmode=%s",
params["user"], params["password"],
host, port, params["dbname"],
orDefault(params["sslmode"], "disable"),
)
}
func splitDSN(dsn string) []string {
var parts []string
cur := ""
for _, r := range dsn {
if r == ' ' {
if cur != "" {
parts = append(parts, cur)
cur = ""
}
} else {
cur += string(r)
}
}
if cur != "" {
parts = append(parts, cur)
}
return parts
}
func indexByte(s string, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
@@ -0,0 +1,12 @@
DROP TRIGGER IF EXISTS trg_files_updated_at ON files;
DROP TRIGGER IF EXISTS trg_folders_updated_at ON folders;
DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
DROP FUNCTION IF EXISTS update_updated_at();
DROP TABLE IF EXISTS file_versions;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
DROP EXTENSION IF EXISTS "pgcrypto";
@@ -0,0 +1,103 @@
-- Aether – Initial schema
-- Phase 0: users, sessions, folders, files (no email)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Users ─────────────────────────────────────────────────────────────────────
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, -- Argon2id hash
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
quota_bytes BIGINT NOT NULL DEFAULT 10737418240, -- 10 GB default
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Sessions ──────────────────────────────────────────────────────────────────
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- ── Folders ───────────────────────────────────────────────────────────────────
CREATE TABLE folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_id, parent_id, name)
);
CREATE INDEX idx_folders_owner ON folders(owner_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
-- ── Files ─────────────────────────────────────────────────────────────────────
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size BIGINT NOT NULL DEFAULT 0,
checksum TEXT NOT NULL, -- SHA-256 hex
storage_key TEXT NOT NULL, -- path within blob storage
version INTEGER NOT NULL DEFAULT 1,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_files_owner ON files(owner_id);
CREATE INDEX idx_files_folder ON files(folder_id);
CREATE INDEX idx_files_deleted ON files(is_deleted);
CREATE INDEX idx_files_storage ON files(storage_key);
-- Partial unique index: no two non-deleted files can share name+folder for the same owner.
CREATE UNIQUE INDEX idx_files_unique_name
ON files(owner_id, folder_id, name)
WHERE NOT is_deleted;
-- ── File versions ─────────────────────────────────────────────────────────────
-- Previous versions of files (kept for Soft-Delete + versioning).
CREATE TABLE file_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
size BIGINT NOT NULL,
checksum TEXT NOT NULL,
storage_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_file_versions_file ON file_versions(file_id);
-- ── Updated_at trigger ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_folders_updated_at
BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS uploads;
DROP TABLE IF EXISTS share_links;
ALTER TABLE files DROP COLUMN IF EXISTS has_thumbnail;
@@ -0,0 +1,35 @@
-- Phase 2: share links, resumable uploads, thumbnails
ALTER TABLE files ADD COLUMN has_thumbnail BOOLEAN NOT NULL DEFAULT FALSE;
-- ── Share links ───────────────────────────────────────────────────────────────
CREATE TABLE share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
password_hash TEXT, -- Argon2id, NULL = no password
expires_at TIMESTAMPTZ, -- NULL = never
download_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_share_links_token ON share_links(token);
CREATE INDEX idx_share_links_owner ON share_links(owner_id);
CREATE INDEX idx_share_links_file ON share_links(file_id);
-- ── Resumable uploads ────────────────────────────────────────────────────────
CREATE TABLE uploads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
total_size BIGINT NOT NULL,
uploaded BIGINT NOT NULL DEFAULT 0,
storage_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_uploads_owner ON uploads(owner_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS favorites;
@@ -0,0 +1,11 @@
CREATE TABLE favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
is_expanded BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, folder_id)
);
CREATE INDEX idx_favorites_user ON favorites(user_id);
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS group_members;
DROP TABLE IF EXISTS groups;
DELETE FROM settings WHERE key IN ('cloud_name','primary_color','accent_color','logo_storage_key');
ALTER TABLE users DROP COLUMN IF EXISTS display_name;
@@ -0,0 +1,35 @@
-- Phase 3: Admin panel – groups, settings, user display names
-- ── Users: display name ───────────────────────────────────────────────────────
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '';
-- ── Groups ───────────────────────────────────────────────────────────────────
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX idx_group_members_user ON group_members(user_id);
-- ── Settings (key-value, admin-managed) ───────────────────────────────────────
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Default settings
INSERT INTO settings (key, value) VALUES
('cloud_name', 'Aether'),
('primary_color', '#6366f1'),
('accent_color', '#8b5cf6'),
('logo_storage_key', '');
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS oidc_sub;
@@ -0,0 +1,3 @@
-- OIDC subject for linking OIDC provider accounts to local users.
-- NULL means the user only has a local password login.
ALTER TABLE users ADD COLUMN oidc_sub TEXT UNIQUE;
@@ -0,0 +1 @@
ALTER TABLE files DROP COLUMN IF EXISTS versioning_enabled;
@@ -0,0 +1,11 @@
-- Versionierung ist ab hier eine Opt-in-Einstellung pro Datei.
-- Standard: aus. Ohne den Schalter würde jede Überschreibung eine weitere
-- Fassung aufheben und den Speicher unbemerkt volllaufen lassen.
ALTER TABLE files
ADD COLUMN versioning_enabled BOOLEAN NOT NULL DEFAULT false;
-- Dateien, die bereits Fassungen haben, behalten ihr bisheriges Verhalten –
-- sonst würde ein bestehender Verlauf stillschweigend eingefroren.
UPDATE files f
SET versioning_enabled = true
WHERE EXISTS (SELECT 1 FROM file_versions v WHERE v.file_id = f.id);
@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN IF EXISTS primary_color,
DROP COLUMN IF EXISTS accent_color;
@@ -0,0 +1,9 @@
-- Persönliche Farbpalette pro Nutzer. NULL bedeutet: es gilt die globale
-- Einstellung des Administrators.
--
-- Bis hierher zeigte die Profilseite zwar Farbwähler, schrieb damit aber in die
-- globalen Einstellungen – für normale Nutzer scheiterte das an der
-- Admin-Prüfung, beim Administrator färbte es die ganze Cloud um.
ALTER TABLE users
ADD COLUMN primary_color TEXT,
ADD COLUMN accent_color TEXT;
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS use_custom_theme;
@@ -0,0 +1,13 @@
-- Schalter zwischen persönlicher und globaler Palette.
--
-- Bisher bedeutete "keine eigene Farbe" = globale Einstellung. Zum Zurückschalten
-- musste man die eigenen Werte löschen und war sie damit los. Der Schalter trennt
-- beides: die Farben bleiben gespeichert, nur ihre Anwendung wird umgelegt.
ALTER TABLE users
ADD COLUMN use_custom_theme BOOLEAN NOT NULL DEFAULT false;
-- Wer bereits eine eigene Farbe gesetzt hat, hat sie sichtbar verwendet –
-- das Verhalten soll durch die Migration nicht kippen.
UPDATE users
SET use_custom_theme = true
WHERE primary_color IS NOT NULL OR accent_color IS NOT NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS app_passwords;
@@ -0,0 +1,15 @@
-- App-Passwörter: widerrufbare Zugangsdaten für WebDAV-/Sync-Clients, damit das
-- Hauptpasswort nicht auf Geräten liegt, die man verlieren kann.
CREATE TABLE app_passwords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- SHA-256 des Tokens. Kein Argon2: das Token ist serverseitig zufällig und
-- hochentropisch, also nicht ratbar – und WebDAV authentifiziert jeden
-- einzelnen Request, wo Argon2 pro Aufruf zu teuer wäre.
token_hash TEXT NOT NULL UNIQUE,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_app_passwords_user ON app_passwords(user_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_log;
@@ -0,0 +1,27 @@
-- Audit-Log: wer hat was wann getan. Zweck ist die Nachvollziehbarkeit nach
-- einem Vorfall – deshalb muss ein Eintrag auch dann noch lesbar sein, wenn der
-- handelnde oder betroffene Account längst gelöscht ist.
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- ON DELETE SET NULL statt CASCADE: sonst räumte das Löschen eines Users
-- genau die Spuren weg, die man danach untersuchen will.
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- Denormalisiert, damit der Name den gelöschten Account überlebt.
actor_name TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
target_name TEXT,
ip TEXT,
detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Die Admin-Ansicht liest immer neueste zuerst, oft gefiltert nach Aktion oder
-- handelndem Konto.
CREATE INDEX idx_audit_log_created ON audit_log (created_at DESC);
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS acl_entries;
@@ -0,0 +1,51 @@
-- Interne Freigaben: Ordner oder Datei an einen anderen Nutzer oder eine Gruppe
-- geben, ohne dass die Bytes umziehen. Der Empfänger sieht sie virtuell unter
-- „Geteilt mit mir"; auf der Platte bleibt alles beim Eigentümer.
--
-- Abweichung vom Projektplan: dort war eine polymorphe Tabelle skizziert
-- (subject_type + subject_id als Text/UUID ohne Fremdschlüssel). Hier stattdessen
-- vier nullbare Fremdschlüssel mit CHECK-Bedingung. Grund: polymorph kann die
-- Datenbank nichts erzwingen – ein gelöschter Nutzer oder Ordner hinterließe
-- Einträge, die auf nichts mehr zeigen, und aus so einer Leiche später eine
-- Freigabe für einen neu vergebenen Namen zu machen wäre ein Sicherheitsloch.
-- Mit echten Fremdschlüsseln räumt ON DELETE CASCADE das von selbst auf.
CREATE TABLE acl_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Empfänger: genau eines von beiden.
subject_user_id UUID REFERENCES users(id) ON DELETE CASCADE,
subject_group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
-- Freigegebene Ressource: genau eines von beiden.
resource_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
resource_file_id UUID REFERENCES files(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK (permission IN ('read', 'write')),
-- Wer die Freigabe erteilt hat. SET NULL statt CASCADE: die Freigabe soll
-- nicht verschwinden, nur weil das Konto des Erteilers gelöscht wurde.
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT acl_one_subject CHECK (num_nonnulls(subject_user_id, subject_group_id) = 1),
CONSTRAINT acl_one_resource CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1)
);
-- Zwei Freigaben derselben Ressource an denselben Empfänger wären mehrdeutig:
-- welches Recht gilt dann? Partielle Unique-Indizes, weil NULL in einem
-- gewöhnlichen UNIQUE nicht mit sich selbst kollidiert.
CREATE UNIQUE INDEX idx_acl_user_folder ON acl_entries (subject_user_id, resource_folder_id)
WHERE subject_user_id IS NOT NULL AND resource_folder_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_user_file ON acl_entries (subject_user_id, resource_file_id)
WHERE subject_user_id IS NOT NULL AND resource_file_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_group_folder ON acl_entries (subject_group_id, resource_folder_id)
WHERE subject_group_id IS NOT NULL AND resource_folder_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_group_file ON acl_entries (subject_group_id, resource_file_id)
WHERE subject_group_id IS NOT NULL AND resource_file_id IS NOT NULL;
-- Die Zugriffsprüfung läuft bei jedem Request; sie fragt nach Ressource.
CREATE INDEX idx_acl_folder ON acl_entries (resource_folder_id) WHERE resource_folder_id IS NOT NULL;
CREATE INDEX idx_acl_file ON acl_entries (resource_file_id) WHERE resource_file_id IS NOT NULL;
-- „Geteilt mit mir" fragt nach Empfänger.
CREATE INDEX idx_acl_subject_user ON acl_entries (subject_user_id) WHERE subject_user_id IS NOT NULL;
CREATE INDEX idx_acl_subject_group ON acl_entries (subject_group_id) WHERE subject_group_id IS NOT NULL;
@@ -0,0 +1,18 @@
DROP INDEX IF EXISTS idx_files_group;
DROP INDEX IF EXISTS idx_folders_group;
DROP INDEX IF EXISTS idx_folders_group_root_name;
DROP INDEX IF EXISTS idx_folders_group_name;
ALTER TABLE groups DROP COLUMN IF EXISTS quota_bytes;
ALTER TABLE files DROP CONSTRAINT IF EXISTS files_one_principal;
ALTER TABLE folders DROP CONSTRAINT IF EXISTS folders_one_principal;
-- Erst die Gruppendaten entfernen, sonst scheitert das NOT NULL unten.
DELETE FROM files WHERE group_id IS NOT NULL;
DELETE FROM folders WHERE group_id IS NOT NULL;
ALTER TABLE files DROP COLUMN IF EXISTS group_id;
ALTER TABLE folders DROP COLUMN IF EXISTS group_id;
ALTER TABLE files ALTER COLUMN owner_id SET NOT NULL;
ALTER TABLE folders ALTER COLUMN owner_id SET NOT NULL;
@@ -0,0 +1,35 @@
-- Gruppenordner: ein Ordner, der keinem einzelnen Konto gehört, sondern einer
-- Gruppe. Wer Mitglied ist, sieht ihn; wer die Gruppe verlässt, nicht mehr.
--
-- Ordner und Dateien gehören ab jetzt entweder einem Nutzer ODER einer Gruppe,
-- nie beidem und nie keinem. Deshalb wird owner_id nullbar und bekommt eine
-- CHECK-Bedingung als Gegengewicht – ohne die könnte ein Datensatz herrenlos
-- werden und wäre über keine Abfrage mehr erreichbar.
ALTER TABLE folders ALTER COLUMN owner_id DROP NOT NULL;
ALTER TABLE files ALTER COLUMN owner_id DROP NOT NULL;
ALTER TABLE folders ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
ALTER TABLE files ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
ALTER TABLE folders ADD CONSTRAINT folders_one_principal
CHECK (num_nonnulls(owner_id, group_id) = 1);
ALTER TABLE files ADD CONSTRAINT files_one_principal
CHECK (num_nonnulls(owner_id, group_id) = 1);
-- Gruppen bekommen ein eigenes Kontingent. Die Alternative – „zählt beim
-- Ersteller" – wäre für die Mitglieder nicht nachvollziehbar: Der Ordner wäre
-- plötzlich voll, weil jemand anders privat etwas hochgeladen hat.
ALTER TABLE groups ADD COLUMN quota_bytes BIGINT NOT NULL DEFAULT 10737418240;
-- Die bestehende UNIQUE (owner_id, parent_id, name) greift für Gruppenordner
-- nicht: In SQL kollidiert NULL nicht mit NULL, zwei Gruppenordner gleichen
-- Namens rutschten also durch.
CREATE UNIQUE INDEX idx_folders_group_name ON folders (group_id, parent_id, name)
WHERE group_id IS NOT NULL AND parent_id IS NOT NULL;
-- Auf oberster Ebene ist parent_id NULL, das braucht einen eigenen Index.
CREATE UNIQUE INDEX idx_folders_group_root_name ON folders (group_id, name)
WHERE group_id IS NOT NULL AND parent_id IS NULL;
CREATE INDEX idx_folders_group ON folders (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX idx_files_group ON files (group_id) WHERE group_id IS NOT NULL;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_folders_name_trgm;
DROP INDEX IF EXISTS idx_files_name_trgm;
-- Die Erweiterung bleibt bewusst stehen: Sie wieder zu entfernen würde alles
-- mitreißen, was sonst noch auf ihr aufbaut, und sie kostet nichts.
@@ -0,0 +1,12 @@
-- Namenssuche über Dateien und Ordner.
--
-- pg_trgm statt einer Volltextsuche: Gesucht wird nach Namensteilen
-- ("rechn" findet "Rechnung_2026.pdf"), und eine Volltextsuche zerlegt in
-- Wörter, findet also gerade Teilwörter nicht. Volltext über den *Inhalt*
-- kommt in Phase 6 dazu – das ist eine andere Aufgabe.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN mit Trigrammen macht ILIKE '%…%' indizierbar. Ohne den Index läuft jede
-- Suche als vollständiger Tabellendurchlauf.
CREATE INDEX idx_files_name_trgm ON files USING gin (name gin_trgm_ops);
CREATE INDEX idx_folders_name_trgm ON folders USING gin (name gin_trgm_ops);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ocr_jobs;
DROP TABLE IF EXISTS file_text;
@@ -0,0 +1,63 @@
-- Volltext über den *Inhalt* – die zweite Hälfte der Suche.
--
-- Migration 000013 machte Datei*namen* durchsuchbar. Das reicht, solange
-- Dateien sprechende Namen haben. Ein Scanner nennt seine Ablage aber
-- "scan_0042.pdf", und genau die Belege sollen auffindbar werden.
-- ── Der erkannte Text ─────────────────────────────────────────────────────────
-- Eigene Tabelle statt einer Spalte an files: Der Text ist um Größenordnungen
-- länger als der Rest der Zeile, und files wird bei jedem Auflisten gelesen.
-- Postgres lagert lange Werte zwar aus (TOAST), aber die tsvector-Spalte und
-- ihr GIN-Index gehörten dann trotzdem an eine Tabelle, die ständig in
-- Bewegung ist.
CREATE TABLE file_text (
file_id UUID PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
content TEXT NOT NULL,
-- Woher der Text stammt. 'layer' = das PDF hatte bereits eine Textebene
-- (pdftotext, verlustfrei); 'ocr' = aus dem Bild erkannt, kann Fehler
-- enthalten. Der Unterschied ist für die Fehlersuche wichtig: Findet die
-- Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen hat.
source TEXT NOT NULL CHECK (source IN ('layer', 'ocr')),
-- Die Sprachen, mit denen erkannt wurde ("deu+eng"). Bei 'layer' leer.
lang TEXT NOT NULL DEFAULT '',
-- Zeichenzahl des Originals vor dem Kürzen – siehe maxTextBytes im Worker.
chars INTEGER NOT NULL DEFAULT 0,
pages INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Berechnete Spalte statt eines Triggers: Sie kann nie mit dem Inhalt
-- auseinanderlaufen, weil Postgres sie bei jedem Schreiben selbst füllt.
--
-- 'german' fest verdrahtet, nicht 'simple': Ohne Stammformen fände
-- "Rechnung" die "Rechnungen" nicht. Deutsch ist die Sprache der Belege,
-- um die es hier geht; englische Wörter darin verlieren nur ihre
-- Stammformbildung, sie bleiben wörtlich auffindbar. Eine Änderung wäre
-- eine Migration – der Preis dafür, dass der Ausdruck IMMUTABLE sein muss.
tsv tsvector GENERATED ALWAYS AS (to_tsvector('german', content)) STORED
);
CREATE INDEX idx_file_text_tsv ON file_text USING gin (tsv);
-- ── Die Warteschlange ─────────────────────────────────────────────────────────
-- Warum eine Tabelle und nicht einfach ein Goroutine-Aufruf wie bei den
-- Vorschaubildern: OCR dauert Sekunden bis Minuten. Startet der Server in
-- dieser Zeit neu – Update, Absturz, Neustart des Wirts –, ist ein Auftrag im
-- Speicher verloren, und die Datei bleibt für immer unauffindbar, ohne dass es
-- jemand merkt. Bei einem Vorschaubild fällt das auf; bei fehlendem Text nicht.
CREATE TABLE ocr_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Ein Auftrag je Datei. Wird dieselbe Datei erneut hochgeladen, ersetzt der
-- neue Auftrag den alten (ON CONFLICT), statt sich daneben zu stapeln.
file_id UUID NOT NULL UNIQUE REFERENCES files(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'done', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Der Arbeiter fragt nur nach offenen Aufträgen. Ein Teilindex bleibt klein,
-- auch wenn sich zehntausend erledigte Zeilen ansammeln.
CREATE INDEX idx_ocr_jobs_pending ON ocr_jobs(created_at)
WHERE status IN ('pending', 'running');
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_files_document_date;
ALTER TABLE files DROP COLUMN IF EXISTS document_date;
DROP TABLE IF EXISTS file_tags;
DROP TABLE IF EXISTS tags;
@@ -0,0 +1,75 @@
-- Etiketten für Dateien.
--
-- Warum neben Ordnern und Volltext noch ein drittes Ordnungsmittel: Ordner
-- beantworten „wann" (Rechnungen/2026), der Volltext beantwortet „wo steht
-- dieses Wort". Beides beantwortet **nicht**, wofür ein Beleg gebraucht wird –
-- „absetzbar" steht in keiner Rechnung, das ist eine Einschätzung des Menschen.
--
-- Und der Fall, an dem Ordner scheitern: Der Kaufbeleg eines Notebooks ist
-- zugleich Garantienachweis *und* absetzbar. In einen Ordner passt er nur
-- einmal; Etiketten trägt dieselbe Datei beliebig viele.
-- ── Die Etiketten selbst ─────────────────────────────────────────────────────
-- Prinzipal-Modell wie bei files und folders: Ein Etikett gehört entweder einem
-- Konto oder einer Gruppe, nie beidem und nie keinem.
--
-- Der Grund für die Gruppenvariante: In einem Gruppenordner arbeiten mehrere an
-- denselben Dokumenten. Hätte dort jeder seine eigenen Etiketten, sortierte
-- jeder dieselben Dateien für sich neu ein und niemand sähe die Arbeit des
-- anderen – der Sinn eines geteilten Ordners wäre dahin.
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- Hex-Farbe für den Aufkleber. Leer heißt: die Oberfläche wählt eine aus
-- dem Namen, damit ein neues Etikett nicht erst eingefärbt werden muss.
color TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT tags_one_principal CHECK (
(owner_id IS NOT NULL AND group_id IS NULL) OR
(owner_id IS NULL AND group_id IS NOT NULL)
),
CONSTRAINT tags_name_not_empty CHECK (length(btrim(name)) > 0)
);
-- Namen sind je Prinzipal eindeutig, und zwar **ohne Rücksicht auf
-- Groß-/Kleinschreibung**. Sonst stehen nach drei Monaten "Stadtwerke",
-- "stadtwerke" und "STADTWERKE" nebeneinander – genau der Zerfall, den
-- Etiketten gegenüber freien Ordnernamen verhindern sollen.
CREATE UNIQUE INDEX idx_tags_owner_name ON tags (owner_id, lower(btrim(name)))
WHERE owner_id IS NOT NULL;
CREATE UNIQUE INDEX idx_tags_group_name ON tags (group_id, lower(btrim(name)))
WHERE group_id IS NOT NULL;
-- ── Die Zuordnung ────────────────────────────────────────────────────────────
-- Eine reine Verknüpfungstabelle. Nichts davon wird in die Datei geschrieben:
-- Das Dokument bleibt Byte für Byte, wie es hochgeladen wurde – dieselbe Regel
-- wie beim erkannten Text (siehe internal/documents).
CREATE TABLE file_tags (
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
-- Wer es angehängt hat. In einem Gruppenordner sonst nicht nachvollziehbar.
tagged_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (file_id, tag_id)
);
-- Der Primärschlüssel deckt die Richtung Datei → Etiketten ab. Die andere
-- Richtung – „zeig mir alles mit steuer-2026" – ist genau die Abfrage, für die
-- Etiketten gebaut werden, und braucht ihren eigenen Index.
CREATE INDEX idx_file_tags_tag ON file_tags (tag_id);
-- ── Belegdatum ───────────────────────────────────────────────────────────────
-- Das Datum, das auf dem Dokument steht – nicht das des Uploads.
--
-- Für beide Zwecke entscheidend: Die Garantiefrist läuft ab dem **Kauf**, und in
-- die Steuererklärung 2026 gehört, was 2026 gekauft wurde – auch wenn der Beleg
-- erst im März 2027 eingescannt wird. created_at wäre für beides das falsche
-- Datum, und zwar unauffällig falsch.
--
-- NULL heißt schlicht „nicht gesetzt": Bei einem Urlaubsfoto gibt es kein
-- Belegdatum, und ein Vorgabewert wäre eine Behauptung.
ALTER TABLE files ADD COLUMN document_date DATE;
CREATE INDEX idx_files_document_date ON files (document_date) WHERE document_date IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE file_text DROP COLUMN IF EXISTS extract_version;
@@ -0,0 +1,13 @@
-- Seitengrenzen im erkannten Text.
--
-- Bis hierher ersetzte clean() den Seitenumbruch (\f) durch einen Zeilenumbruch:
-- Für die Volltextsuche ist er bedeutungslos, ein tsvector kennt keine Seiten.
-- Für den PDF-Betrachter ist er das Gegenteil von bedeutungslos – bei einem
-- Scan steht der Text *nur* hier, im PDF selbst liegt bloß ein Bild. Ohne die
-- Marke lässt sich nicht sagen, auf welcher Seite ein Suchwort steht, und der
-- Betrachter beginnt bei einem Beleg-Scan wieder stumpf auf Seite 1.
--
-- extract_version sagt, nach welcher Regel ein Eintrag entstanden ist. Ältere
-- Einträge werden beim Start erneut in die Warteschlange gelegt, statt still
-- ohne Seitenmarken liegen zu bleiben.
ALTER TABLE file_text ADD COLUMN IF NOT EXISTS extract_version smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_files_correspondent;
ALTER TABLE files DROP COLUMN IF EXISTS correspondent_id;
DROP TABLE IF EXISTS correspondents;
@@ -0,0 +1,52 @@
-- Absender („Korrespondent") an Dateien.
--
-- Warum neben Etiketten noch ein Feld: Es sind **zwei verschiedene Fragen**.
-- Ein Etikett sagt, *wofür* ein Dokument gebraucht wird („absetzbar",
-- „garantie") – davon trägt eine Datei beliebig viele. Der Absender sagt, *von
-- wem* es kommt („Stadtwerke", „Finanzamt") – davon hat eine Datei genau einen.
--
-- Als Etikett gelöst stünden beide in derselben Liste: „Stadtwerke" neben
-- „absetzbar", Absender und Verwendungszweck durcheinander. Bei zwanzig
-- Etiketten fällt das nicht auf, bei achtzig ist die Liste unbenutzbar. Und
-- „alle Rechnungen von X" ließe sich nicht von „alles Absetzbare" trennen –
-- genau die Verknüpfung, für die es gebaut wird.
--
-- Die Eins-Regel ist dabei kein Mangel, sondern der Zweck: Sie erzwingt eine
-- Entscheidung und hält die Liste kurz.
CREATE TABLE correspondents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Prinzipal-Modell wie bei tags: entweder ein Konto oder eine Gruppe.
-- In einem Gruppenordner müssen alle dieselben Absender sehen, sonst
-- sortiert jeder dieselben Dokumente für sich neu ein.
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT correspondents_one_principal CHECK (
(owner_id IS NOT NULL AND group_id IS NULL) OR
(owner_id IS NULL AND group_id IS NOT NULL)
),
CONSTRAINT correspondents_name_not_empty CHECK (length(btrim(name)) > 0)
);
-- Eindeutig je Prinzipal, ohne Rücksicht auf Groß-/Kleinschreibung – dieselbe
-- Regel wie bei Etiketten. Hier wiegt sie schwerer: „Amazon" und „amazon"
-- nebeneinander hieße, dass „alle Rechnungen von Amazon" nur die Hälfte
-- findet, und zwar unauffällig.
CREATE UNIQUE INDEX idx_correspondents_owner_name
ON correspondents (owner_id, lower(btrim(name))) WHERE owner_id IS NOT NULL;
CREATE UNIQUE INDEX idx_correspondents_group_name
ON correspondents (group_id, lower(btrim(name))) WHERE group_id IS NOT NULL;
-- Am Datei-Datensatz, nicht in einer Verknüpfungstabelle: Genau einer je
-- Datei – das steht damit im Schema und nicht bloß in einer Zusicherung des
-- Codes.
--
-- ON DELETE SET NULL statt CASCADE: Wer einen Absender löscht, will ihn aus
-- der Liste haben – nicht seine Rechnungen.
ALTER TABLE files ADD COLUMN correspondent_id UUID REFERENCES correspondents(id) ON DELETE SET NULL;
-- „Alles von Stadtwerke" ist die Abfrage, für die das Feld gebaut wird.
CREATE INDEX idx_files_correspondent ON files (correspondent_id) WHERE correspondent_id IS NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE tags DROP COLUMN IF EXISTS icon;
ALTER TABLE correspondents DROP COLUMN IF EXISTS icon;
@@ -0,0 +1,15 @@
-- Ein Symbol je Etikett und je Absender.
--
-- Die Farbe allein trägt nicht weit genug: Sie unterscheidet zuverlässig etwa
-- fünf Einträge. Danach fragt man sich, ob Blau nun „Uni" oder „Versicherung"
-- war. Ein Emoji ist sofort erkennbar, braucht keine Legende und ist in jeder
-- Schrift schon da – kein Symbolsatz, keine Grafikdateien, keine zusätzliche
-- Ladezeit.
--
-- Leer heißt „keins": Die Farbe bleibt der Rückfall, niemand muss ein Symbol
-- wählen. Ein Vorgabe-Emoji wäre eine Behauptung über den Inhalt.
--
-- TEXT statt eines einzelnen Zeichens: Ein Emoji ist selten ein Codepunkt.
-- 👨‍🎓 sind fünf, 🏳️‍🌈 vier – ein CHAR(1) hätte sie zerschnitten.
ALTER TABLE tags ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';
ALTER TABLE correspondents ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';
+102
View File
@@ -0,0 +1,102 @@
package middleware
import (
"sync"
"time"
)
// FailureLimiter throttles repeated authentication failures per key (usually a
// client IP).
//
// Deliberately not a request limiter: a WebDAV sync client legitimately sends
// hundreds of requests per minute, so capping requests would break normal use.
// Only failed logins count, and a single success clears the record — which
// leaves honest clients untouched while a password guesser runs out of tries.
type FailureLimiter struct {
mu sync.Mutex
entries map[string]*failureEntry
maxFailures int
window time.Duration
lockout time.Duration
lastSweep time.Time
}
type failureEntry struct {
count int
windowStart time.Time
lockedUntil time.Time
}
// NewFailureLimiter allows maxFailures within window; exceeding that locks the
// key out for lockout.
func NewFailureLimiter(maxFailures int, window, lockout time.Duration) *FailureLimiter {
return &FailureLimiter{
entries: make(map[string]*failureEntry),
maxFailures: maxFailures,
window: window,
lockout: lockout,
lastSweep: time.Now(),
}
}
// Allowed reports whether the key may attempt authentication. The second return
// value is how long the caller must wait, for a Retry-After header.
func (l *FailureLimiter) Allowed(key string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
e, ok := l.entries[key]
if !ok {
return true, 0
}
now := time.Now()
if now.Before(e.lockedUntil) {
return false, e.lockedUntil.Sub(now)
}
return true, 0
}
// Fail records a failed attempt and locks the key out once the limit is hit.
func (l *FailureLimiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.sweepLocked(now)
e, ok := l.entries[key]
if !ok || now.Sub(e.windowStart) > l.window {
l.entries[key] = &failureEntry{count: 1, windowStart: now}
return
}
e.count++
if e.count >= l.maxFailures {
e.lockedUntil = now.Add(l.lockout)
// Restart the window so the lockout does not extend forever on the
// retries that arrive while it is already active.
e.count = 0
e.windowStart = now
}
}
// Reset clears the record after a successful authentication.
func (l *FailureLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.entries, key)
}
// sweepLocked drops stale records so the map cannot grow without bound from
// one-off failures across many addresses. Caller must hold the mutex.
func (l *FailureLimiter) sweepLocked(now time.Time) {
if now.Sub(l.lastSweep) < l.window {
return
}
l.lastSweep = now
for k, e := range l.entries {
if now.After(e.lockedUntil) && now.Sub(e.windowStart) > l.window {
delete(l.entries, k)
}
}
}
@@ -0,0 +1,63 @@
package middleware
import (
"testing"
"time"
)
func TestFailureLimiterLocksOutAfterLimit(t *testing.T) {
l := NewFailureLimiter(3, time.Minute, time.Minute)
for i := 0; i < 2; i++ {
l.Fail("10.0.0.1")
if allowed, _ := l.Allowed("10.0.0.1"); !allowed {
t.Fatalf("locked out after %d of 3 failures", i+1)
}
}
l.Fail("10.0.0.1")
allowed, retryAfter := l.Allowed("10.0.0.1")
if allowed {
t.Fatal("still allowed after reaching the failure limit")
}
if retryAfter <= 0 {
t.Fatalf("retryAfter = %v, want positive so Retry-After is usable", retryAfter)
}
}
// Ein Sync-Client darf nicht unter fremden Fehlversuchen leiden: Erfolg räumt
// den Zähler weg.
func TestFailureLimiterResetOnSuccess(t *testing.T) {
l := NewFailureLimiter(2, time.Minute, time.Minute)
l.Fail("10.0.0.2")
l.Reset("10.0.0.2")
l.Fail("10.0.0.2")
if allowed, _ := l.Allowed("10.0.0.2"); !allowed {
t.Fatal("locked out although a success cleared the earlier failure")
}
}
func TestFailureLimiterIsolatesKeys(t *testing.T) {
l := NewFailureLimiter(1, time.Minute, time.Minute)
l.Fail("10.0.0.3")
if allowed, _ := l.Allowed("10.0.0.4"); !allowed {
t.Fatal("a different address was locked out")
}
}
// Nach Ablauf des Fensters zählt der Versuch nicht mehr mit – sonst summierten
// sich Vertipper über Wochen zu einer Sperre.
func TestFailureLimiterWindowExpires(t *testing.T) {
l := NewFailureLimiter(2, 10*time.Millisecond, time.Minute)
l.Fail("10.0.0.5")
time.Sleep(20 * time.Millisecond)
l.Fail("10.0.0.5")
if allowed, _ := l.Allowed("10.0.0.5"); !allowed {
t.Fatal("locked out although the first failure was outside the window")
}
}
+43
View File
@@ -0,0 +1,43 @@
package middleware
import (
"net/http"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(ww, r)
level := zerolog.InfoLevel
if ww.status >= 500 {
level = zerolog.ErrorLevel
} else if ww.status >= 400 {
level = zerolog.WarnLevel
}
log.WithLevel(level).
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status", ww.status).
Dur("duration", time.Since(start)).
Str("ip", r.RemoteAddr).
Msg("")
})
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(status int) {
rw.status = status
rw.ResponseWriter.WriteHeader(status)
}
+75
View File
@@ -0,0 +1,75 @@
package middleware
import (
"context"
"net"
"net/http"
"strings"
"aether/pkg/config"
)
type contextKey int
const (
keyRealIP contextKey = iota
keyIsHTTPS contextKey = iota
)
// RealIP returns the real client IP stored in context by TrustedProxy middleware.
// Falls back to r.RemoteAddr if the middleware was not applied.
func RealIP(r *http.Request) string {
if ip, ok := r.Context().Value(keyRealIP).(string); ok && ip != "" {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// IsHTTPS reports whether the original client connection was HTTPS,
// either directly or via a trusted proxy's X-Forwarded-Proto header.
func IsHTTPS(r *http.Request) bool {
if v, ok := r.Context().Value(keyIsHTTPS).(bool); ok {
return v
}
return r.TLS != nil
}
// TrustedProxy extracts the real client IP and protocol from X-Forwarded-*
// headers, but only when the immediate peer is in the trusted proxy list.
// Untrusted peers never override RemoteAddr.
func TrustedProxy(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteHost, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
remoteHost = r.RemoteAddr
}
peerIP := net.ParseIP(remoteHost)
realIP := remoteHost
isHTTPS := r.TLS != nil
if peerIP != nil && cfg.IsTrustedProxy(peerIP) {
if xfp := r.Header.Get("X-Forwarded-Proto"); xfp != "" {
isHTTPS = strings.EqualFold(strings.TrimSpace(xfp), "https")
}
// X-Forwarded-For may be a comma-separated chain; the leftmost
// entry is the original client (set by the first proxy).
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.SplitN(xff, ",", 2)
if candidate := strings.TrimSpace(parts[0]); candidate != "" {
realIP = candidate
}
}
}
ctx := context.WithValue(r.Context(), keyRealIP, realIP)
ctx = context.WithValue(ctx, keyIsHTTPS, isHTTPS)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
+55
View File
@@ -0,0 +1,55 @@
package middleware
import (
"net/http"
"net/url"
"strings"
)
// SecurityHeaders adds security headers that the app controls.
// HSTS is intentionally omitted — it belongs on the edge reverse proxy.
//
// frameSrc nennt zusätzliche Ursprünge, die in einem Rahmen erscheinen dürfen.
// Gebraucht für den Office-Server: Seine Bedienoberfläche läuft in einem
// <iframe>, und `default-src 'self'` deckt auch `frame-src` ab – ohne
// Ausnahme blockt der Browser sie, noch bevor eine Anfrage hinausgeht.
// Gemessen als „Framing 'http://…:9980/' violates … default-src 'self'".
//
// Bewusst **nur der eine konfigurierte Ursprung** und kein Platzhalter: Die
// Ausnahme soll so schmal sein wie der Anlass.
func SecurityHeaders(frameSrc ...string) func(http.Handler) http.Handler {
frame := "frame-src 'self'"
for _, raw := range frameSrc {
if origin := originOf(raw); origin != "" {
frame += " " + origin
}
}
csp := "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; " + frame
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Content-Security-Policy", csp)
next.ServeHTTP(w, r)
})
}
}
// originOf reduziert eine Adresse auf Schema und Host. Ein Pfad in einer
// CSP-Quelle wäre nicht falsch, aber enger als gemeint – und ein Tippfehler
// darin fiele erst auf, wenn der Rahmen leer bleibt.
func originOf(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
+27
View File
@@ -0,0 +1,27 @@
package response
import (
"encoding/json"
"net/http"
)
type envelope struct {
Data any `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func JSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(envelope{Data: data})
}
func Error(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(envelope{Error: msg})
}
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}