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>
200 lines
8.5 KiB
Go
200 lines
8.5 KiB
Go
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
|
||
}
|