feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
package files
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Namenssuche über alles, was der Anfragende erreichen darf.
//
// Der heikle Teil ist nicht die Abfrage, sondern der Umfang: Eine Suche, die
// die Zugriffsregeln nicht mitdenkt, ist ein Leck – sie zeigte Namen aus
// Ordnern, die man nicht betreten darf. Deshalb schneidet die Abfrage den
// erreichbaren Bereich selbst zu, statt Treffer nachträglich zu filtern.
// Nachträglich zu filtern wäre die Variante, bei der man einen Fall vergisst.
//
// Erreichbar ist:
// - was dem Anfragenden gehört
// - was einer seiner Gruppen gehört (Gruppenordner)
// - was ihm freigegeben wurde – direkt oder über eine Gruppe
// - alles *unterhalb* eines freigegebenen Ordners (Vererbung nach unten)
//
// Papierkorb bleibt außen vor: Gelöschtes taucht dort auf, nicht in der Suche.
// SearchLimit begrenzt die Trefferzahl. Wer mehr als 100 Treffer bekommt, hat
// zu ungenau gesucht – eine längere Liste hilft niemandem und kostet nur.
const SearchLimit = 100
// SearchHit ist ein Treffer. Ordner und Dateien kommen in derselben Liste,
// damit die Oberfläche sie in einer Trefferliste zeigen kann.
type SearchHit struct {
Kind string `json:"kind"` // "folder" oder "file"
ID uuid.UUID `json:"id"`
Name string `json:"name"`
// FolderID ist der Ordner, in dem der Treffer liegt – bei einem Ordner der
// übergeordnete. Leer heißt Wurzel. Die Oberfläche springt damit dorthin.
FolderID *uuid.UUID `json:"folder_id"`
// FolderName benennt denselben Ordner, damit ein Treffer einordbar ist:
// Drei Dateien namens „Rechnung.pdf" unterscheiden sich sonst nicht.
FolderName string `json:"folder_name"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size"`
HasThumbnail bool `json:"has_thumbnail"`
UpdatedAt time.Time `json:"updated_at"`
// MatchedContent heißt: Nicht der Name passte, sondern der erkannte Text
// im Dokument. Für die Oberfläche ein wichtiger Unterschied – sonst wirkt
// ein Treffer namens "scan_0042.pdf" wie ein Fehler der Suche.
MatchedContent bool `json:"matched_content,omitempty"`
// Snippet ist die Fundstelle im Text, Treffer hervorgehoben (<b>…</b>).
// Nur gesetzt, wenn MatchedContent gilt.
Snippet string `json:"snippet,omitempty"`
}
// Search findet Ordner und Dateien – nach Name und, bei Dokumenten mit
// erkanntem Text, auch nach Inhalt.
//
// Beides in *einer* Abfrage und in *einem* Zweig des UNION, nicht in zweien:
// Eine Datei, deren Name und Inhalt beide passen, käme sonst doppelt in der
// Liste an. Welche Hälfte den Treffer verursacht hat, sagt matched_content.
func (s *Store) Search(ctx context.Context, userID uuid.UUID, query string) ([]SearchHit, error) {
query = strings.TrimSpace(query)
if query == "" {
return []SearchHit{}, nil
}
// Die Platzhalter von LIKE gehören nicht dem Nutzer: Ein "%" im Suchwort
// soll ein Prozentzeichen suchen, keine beliebige Zeichenfolge.
pattern := "%" + escapeLike(query) + "%"
// Für den Volltext wird das Suchwort unverändert weitergereicht:
// websearch_to_tsquery versteht das, was Menschen ohnehin eintippen –
// Anführungszeichen für Wortgruppen, "or", ein Minus zum Ausschließen –
// und wirft bei krummer Eingabe keinen Fehler, sondern liefert eine leere
// Abfrage. Eine Suche darf an einem Sonderzeichen nicht scheitern.
const q = `
WITH RECURSIVE my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $1
),
-- Ordner, die dem Anfragenden freigegeben wurden – persönlich oder über eine
-- seiner Gruppen.
granted_roots AS (
SELECT resource_folder_id AS id
FROM acl_entries
WHERE resource_folder_id IS NOT NULL
AND (subject_user_id = $1
OR subject_group_id IN (SELECT group_id FROM my_groups))
),
-- Freigaben vererben sich nach unten: Wer einen Ordner freigibt, meint dessen
-- ganzen Inhalt. Hier deshalb ausnahmsweise nach unten statt nach oben – die
-- Einzelprüfung in internal/acl läuft andersherum, weil sie nur einen Pfad
-- zur Wurzel braucht.
shared_tree AS (
SELECT id FROM granted_roots
UNION
SELECT f.id FROM folders f JOIN shared_tree s ON f.parent_id = s.id
),
-- Einmal berechnet statt in jeder Zeile. NULLIF fängt den Fall ab, dass die
-- Eingabe nur aus Füllwörtern besteht ("und der"): Daraus entsteht eine leere
-- tsquery, und die passt auf nichts – ohne NULLIF stünde stattdessen eine
-- Bedingung da, die nie erfüllt ist, aber trotzdem jede Zeile prüft.
q AS (
SELECT NULLIF(websearch_to_tsquery('german', $4)::text, '')::tsquery AS tsq
),
granted_files AS (
SELECT resource_file_id AS id
FROM acl_entries
WHERE resource_file_id IS NOT NULL
AND (subject_user_id = $1
OR subject_group_id IN (SELECT group_id FROM my_groups))
)
SELECT * FROM (
SELECT 'folder' AS kind, fo.id, fo.name, fo.parent_id AS folder_id,
COALESCE(p.name, '') AS folder_name,
'' AS mime_type, 0::bigint AS size, false AS has_thumbnail, fo.updated_at,
false AS matched_content, '' AS snippet
FROM folders fo
LEFT JOIN folders p ON p.id = fo.parent_id
WHERE fo.name ILIKE $2 ESCAPE '\'
AND (fo.owner_id = $1
OR fo.group_id IN (SELECT group_id FROM my_groups)
OR fo.id IN (SELECT id FROM shared_tree))
UNION ALL
SELECT 'file' AS kind, fi.id, fi.name, fi.folder_id,
COALESCE(p.name, '') AS folder_name,
fi.mime_type, fi.size, fi.has_thumbnail, fi.updated_at,
-- Nur als Inhaltstreffer zählen, was der Name nicht schon hergibt:
-- Passt beides, ist der Name die bessere Erklärung.
(fi.name NOT ILIKE $2 ESCAPE '\') AS matched_content,
CASE WHEN fi.name NOT ILIKE $2 ESCAPE '\' AND ft.file_id IS NOT NULL
-- Eigene Markierungen statt der voreingestellten <b>…</b>: Der
-- Text stammt aus einem fremden Dokument und darf im Frontend
-- niemals als HTML eingesetzt werden. Die Oberfläche zerlegt die
-- Zeichenkette an diesen Marken und baut daraus Textknoten – ein
-- <script> im Dokument bleibt damit sichtbarer Text.
-- MaxWords/MinWords passen zu zwei Zeilen in der Trefferliste.
-- Bei 18 Wörtern brach die Fundstelle mitten im Satz ab, ohne dass
-- die zweite Zeile voll war; 30 füllt sie und wird darunter von
-- der Oberfläche sauber gekappt statt vom Textende.
THEN ts_headline('german', ft.content, q.tsq,
'StartSel=[[hl]], StopSel=[[/hl]], MaxWords=30, MinWords=12, MaxFragments=1')
ELSE '' END AS snippet
FROM files fi
LEFT JOIN folders p ON p.id = fi.folder_id
-- LEFT JOIN, nicht JOIN: Dateien ohne erkannten Text – alles außer PDFs und
-- Bildern – müssen weiterhin über ihren Namen gefunden werden.
LEFT JOIN file_text ft ON ft.file_id = fi.id
CROSS JOIN q
WHERE fi.is_deleted = false
AND (fi.name ILIKE $2 ESCAPE '\' OR (q.tsq IS NOT NULL AND ft.tsv @@ q.tsq))
AND (fi.owner_id = $1
OR fi.group_id IN (SELECT group_id FROM my_groups)
OR fi.folder_id IN (SELECT id FROM shared_tree)
OR fi.id IN (SELECT id FROM granted_files))
) hits
-- Ordner zuerst: Wer einen Ordner sucht, will nicht erst an dreißig Dateien
-- vorbeiscrollen. (Nicht nach kind sortieren – alphabetisch käme "file" vor
-- "folder", also genau andersherum. Und die Sortierung braucht die Klammer
-- drumherum: Über einem UNION erlaubt Postgres nur Spaltennamen, keine
-- Ausdrücke.)
-- Ordner zuerst, dann Namenstreffer, dann Inhaltstreffer. Wer "Stadtwerke"
-- sucht und eine so benannte Datei hat, will die oben sehen – nicht die
-- dreißig Rechnungen, in denen das Wort nur vorkommt.
ORDER BY (kind = 'folder') DESC, matched_content ASC, name ASC
LIMIT $3`
rows, err := s.db.Query(ctx, q, userID, pattern, SearchLimit, query)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
defer rows.Close()
hits := []SearchHit{}
for rows.Next() {
var h SearchHit
if err := rows.Scan(&h.Kind, &h.ID, &h.Name, &h.FolderID, &h.FolderName,
&h.MimeType, &h.Size, &h.HasThumbnail, &h.UpdatedAt,
&h.MatchedContent, &h.Snippet); err != nil {
return nil, fmt.Errorf("scan search hit: %w", err)
}
hits = append(hits, h)
}
return hits, rows.Err()
}
// escapeLike entschärft die Platzhalter von LIKE. Der Backslash ist dabei
// zuerst dran, sonst verdoppelt der Ersatz die eigenen Fluchtzeichen wieder.
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "%", `\%`)
s = strings.ReplaceAll(s, "_", `\_`)
return s
}