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