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
--