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,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
|
||||
}
|
||||
@@ -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 '';
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user