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 (…). // 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 …: 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 --