Eine Zeichnung ist eine Datei, kein neues Ding – das ist die ganze Entscheidung dahinter. .excalidraw ist JSON; damit erbt sie Ordner, Gruppenordner, Papierkorb, Versionen, Freigabelinks, interne Freigaben und die Suche. Keine neue Tabelle, kein zweites Rechtemodell. Gespeichert wird über denselben Upload wie alles andere, wie beim Text-Editor in der Vorschau. Drei Sorgen, alle nachgemessen statt gehofft: Das Hauptbündel wächst von 484,11 auf 485,22 kB – das Zeichenbrett ist ein eigenes Stück (584 kB + 142 kB CSS) und wird nachgeladen wie der PDF-Betrachter. Wer nie zeichnet, holt es nie. Die Schriften: Die Richtlinie ist default-src 'self' ohne font-src, Excalidraw hätte seine handgezeichneten Schriften von aussen geladen und still eine Ersatzschrift gezeigt. Sie werden jetzt beim Bauen kopiert und über window.EXCALIDRAW_ASSET_PATH gefunden – gesetzt im Modul, nicht per Inline-Skript, das wäre von derselben Richtlinie blockiert. Xiaolai bleibt draussen: 13 der 14 MB für die chinesische Schrift. Die Reihenfolge der Dateiarten: .excalidraw wird vor code und text geprüft, sonst öffnete eine Zeichnung im Texteditor als Zahlenkolonne (json steht in codeExt). Kein gleichzeitiges Arbeiten. Die Komponente könnte es, aber den Transport bringt sie nicht mit – das wäre ein zweiter Dienst neben Aether. Solange es ihn nicht gibt, überschreibt der Zweite den Ersten. Zwei Dinge fangen das ab: Vor dem Speichern wird geprüft, ob sich die Fassung geändert hat, und dann gefragt statt überschrieben. Und Zeichnungen behalten ihre Fassungen von der ersten Minute an. Der zweite Punkt ist erst durch den Selbsttest aufgefallen: Versionierung ist sonst bewusst aus (Migration 000006), damit der Speicher nicht vollläuft. Für Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder bearbeitet und sind ein paar Kilobyte gross. Ohne sie wäre die Warnung im Editor ein Versprechen ohne Deckung gewesen. Neuer Selbsttest mit 12 Prüfungen: Er prüft die These nach, auf der alles beruht – wenn eine Zeichnung wirklich eine Datei ist, muss sie können, was Dateien können. Nebenbei: db-tests.sh liess je Lauf ein anonymes Postgres-Volume liegen (rund 49 MB). `docker rm -v` nimmt es jetzt mit; zehn verwaiste entfernt. Geprüft: go build/vet sauber, alle 15 Go-Pakete grün, 255 Prüfungen der acht Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
581 lines
19 KiB
Go
581 lines
19 KiB
Go
package files
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
var ErrNotFound = errors.New("not found")
|
||
var ErrNameTaken = errors.New("name already exists in this folder")
|
||
var ErrCycle = errors.New("target is a descendant of the folder being moved")
|
||
|
||
type Store struct {
|
||
db *pgxpool.Pool
|
||
}
|
||
|
||
func NewStore(db *pgxpool.Pool) *Store {
|
||
return &Store{db: db}
|
||
}
|
||
|
||
const fileCols = `id, owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key, has_thumbnail, version, versioning_enabled, document_date, created_at, updated_at`
|
||
|
||
func scanFile(row interface{ Scan(...any) error }, f *File) error {
|
||
return row.Scan(
|
||
&f.ID, &f.OwnerID, &f.GroupID, &f.FolderID, &f.Name, &f.MimeType,
|
||
&f.Size, &f.Checksum, &f.StorageKey, &f.HasThumbnail,
|
||
&f.Version, &f.VersioningEnabled, &f.DocumentDate, &f.CreatedAt, &f.UpdatedAt,
|
||
)
|
||
}
|
||
|
||
// ── Files ────────────────────────────────────────────────────────────────────
|
||
|
||
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
|
||
var out File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
|
||
versioning_enabled)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||
RETURNING `+fileCols,
|
||
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
||
versioniertVonAnfang(f.Name),
|
||
), &out)
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
return nil, ErrNameTaken
|
||
}
|
||
return nil, fmt.Errorf("create file: %w", err)
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
|
||
// behalten.
|
||
//
|
||
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
|
||
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
|
||
// unbemerkt voll.
|
||
//
|
||
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
|
||
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
|
||
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
|
||
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
|
||
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
|
||
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
|
||
// ohne Deckung.
|
||
func versioniertVonAnfang(name string) bool {
|
||
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
|
||
}
|
||
|
||
// GetFileByID fetches a file without owner check (used by public share downloads).
|
||
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`SELECT `+fileCols+` FROM files WHERE id = $1 AND is_deleted = false`, id,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get file by id: %w", err)
|
||
}
|
||
return &f, nil
|
||
}
|
||
|
||
func (s *Store) GetFile(ctx context.Context, id, ownerID uuid.UUID) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`SELECT `+fileCols+` FROM files WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = false`,
|
||
id, ownerID,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get file: %w", err)
|
||
}
|
||
return &f, nil
|
||
}
|
||
|
||
func (s *Store) ListFiles(ctx context.Context, ownerID uuid.UUID, folderID *uuid.UUID) ([]*File, error) {
|
||
var rows pgx.Rows
|
||
var err error
|
||
if folderID == nil {
|
||
rows, err = s.db.Query(ctx,
|
||
`SELECT `+fileCols+` FROM files
|
||
WHERE (owner_id = $1 OR group_id = $1) AND folder_id IS NULL AND is_deleted = false ORDER BY name`,
|
||
ownerID,
|
||
)
|
||
} else {
|
||
rows, err = s.db.Query(ctx,
|
||
`SELECT `+fileCols+` FROM files
|
||
WHERE (owner_id = $1 OR group_id = $1) AND folder_id = $2 AND is_deleted = false ORDER BY name`,
|
||
ownerID, folderID,
|
||
)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list files: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
return scanFiles(rows)
|
||
}
|
||
|
||
// SoftDeleteFile marks a file as deleted and updates its storage_key to the trash path.
|
||
// The caller must move the blob to trashKey before calling this.
|
||
func (s *Store) SoftDeleteFile(ctx context.Context, id, ownerID uuid.UUID, trashKey string) error {
|
||
tag, err := s.db.Exec(ctx,
|
||
`UPDATE files SET is_deleted = true, deleted_at = NOW(), storage_key = $3
|
||
WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = false`,
|
||
id, ownerID, trashKey,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("soft delete file: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetTrashedFile returns a soft-deleted file by ID (needed before restore/hard-delete).
|
||
func (s *Store) GetTrashedFile(ctx context.Context, id, ownerID uuid.UUID) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`SELECT `+fileCols+` FROM files WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=true`,
|
||
id, ownerID,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get trashed file: %w", err)
|
||
}
|
||
return &f, nil
|
||
}
|
||
|
||
func (s *Store) RenameFile(ctx context.Context, id, ownerID uuid.UUID, name, storageKey string) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`UPDATE files SET name=$3, storage_key=$4
|
||
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false
|
||
RETURNING `+fileCols,
|
||
id, ownerID, name, storageKey,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
return nil, ErrNameTaken
|
||
}
|
||
return nil, fmt.Errorf("rename file: %w", err)
|
||
}
|
||
return &f, nil
|
||
}
|
||
|
||
func (s *Store) MoveFile(ctx context.Context, id, ownerID uuid.UUID, newFolderID *uuid.UUID, storageKey string) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`UPDATE files SET folder_id=$3, storage_key=$4
|
||
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false
|
||
RETURNING `+fileCols,
|
||
id, ownerID, newFolderID, storageKey,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("move file: %w", err)
|
||
}
|
||
return &f, nil
|
||
}
|
||
|
||
type TrashFile struct {
|
||
File
|
||
FolderPath *string `json:"folder_path"`
|
||
}
|
||
|
||
func (s *Store) ListTrash(ctx context.Context, userID uuid.UUID) ([]*TrashFile, error) {
|
||
// Sichtbar ist der eigene Papierkorb plus der jeder Gruppe, in der man
|
||
// Mitglied ist. Ohne den zweiten Teil verschwände eine gelöschte
|
||
// Gruppendatei spurlos: Sie gehört keinem Konto, tauchte also in keinem
|
||
// persönlichen Papierkorb auf und wäre nicht wiederherstellbar.
|
||
const principal = `(f.owner_id = $1 OR f.group_id IN (
|
||
SELECT group_id FROM group_members WHERE user_id = $1))`
|
||
|
||
rows, err := s.db.Query(ctx,
|
||
`WITH RECURSIVE folder_paths AS (
|
||
SELECT id, name::text AS full_path
|
||
FROM folders
|
||
WHERE parent_id IS NULL
|
||
AND (owner_id = $1 OR group_id IN (
|
||
SELECT group_id FROM group_members WHERE user_id = $1))
|
||
UNION ALL
|
||
SELECT fo.id, fp.full_path || ' / ' || fo.name
|
||
FROM folders fo JOIN folder_paths fp ON fo.parent_id = fp.id
|
||
)
|
||
SELECT f.id, f.owner_id, f.group_id, f.folder_id, f.name, f.mime_type, f.size, f.checksum,
|
||
f.storage_key, f.has_thumbnail, f.version, f.versioning_enabled,
|
||
f.created_at, f.updated_at,
|
||
fp.full_path AS folder_path
|
||
FROM files f
|
||
LEFT JOIN folder_paths fp ON fp.id = f.folder_id
|
||
WHERE `+principal+` AND f.is_deleted = true
|
||
ORDER BY f.deleted_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list trash: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
result := make([]*TrashFile, 0)
|
||
for rows.Next() {
|
||
var item TrashFile
|
||
if err := rows.Scan(
|
||
&item.ID, &item.OwnerID, &item.GroupID, &item.FolderID, &item.Name, &item.MimeType,
|
||
&item.Size, &item.Checksum, &item.StorageKey, &item.HasThumbnail,
|
||
&item.Version, &item.VersioningEnabled, &item.CreatedAt, &item.UpdatedAt,
|
||
&item.FolderPath,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, &item)
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
// RestoreFile marks a file as active again and updates its storage_key to the restored path.
|
||
// The caller must move the blob from .trash/ back to newStorageKey before calling this.
|
||
func (s *Store) RestoreFile(ctx context.Context, id, ownerID uuid.UUID, newStorageKey string) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`UPDATE files SET is_deleted = false, deleted_at = NULL, storage_key = $3
|
||
WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = true
|
||
RETURNING `+fileCols,
|
||
id, ownerID, newStorageKey,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("restore file: %w", err)
|
||
}
|
||
return &f, nil
|
||
}
|
||
|
||
// HardDeleteFile permanently removes a file and returns its storage key plus all version
|
||
// storage keys so the caller can delete the blobs. file_versions rows are removed by
|
||
// the ON DELETE CASCADE on the FK.
|
||
// EmptyTrash löscht alle Papierkorb-Einträge eines Nutzers endgültig und
|
||
// liefert sämtliche Blob-Schlüssel zurück, damit der Aufrufer die Bytes
|
||
// entfernen kann.
|
||
//
|
||
// Die IDs werden mit zurückgegeben, weil zu jeder Datei noch ein
|
||
// Vorschaubild unter "thumbs/<id>.jpg" liegen kann.
|
||
func (s *Store) EmptyTrash(ctx context.Context, ownerID uuid.UUID) (fileIDs []uuid.UUID, keys []string, err error) {
|
||
// Erst die Versionsblobs einsammeln – die Kaskade räumt die Zeilen mit weg.
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT v.storage_key
|
||
FROM file_versions v
|
||
JOIN files f ON f.id = v.file_id
|
||
WHERE (f.owner_id = $1 OR f.group_id = $1) AND f.is_deleted = true`, ownerID)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("list trash version keys: %w", err)
|
||
}
|
||
for rows.Next() {
|
||
var k string
|
||
if err := rows.Scan(&k); err != nil {
|
||
rows.Close()
|
||
return nil, nil, err
|
||
}
|
||
keys = append(keys, k)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
rows, err = s.db.Query(ctx,
|
||
`DELETE FROM files WHERE (owner_id = $1 OR group_id = $1) AND is_deleted = true
|
||
RETURNING id, storage_key`, ownerID)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("empty trash: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var id uuid.UUID
|
||
var k string
|
||
if err := rows.Scan(&id, &k); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
fileIDs = append(fileIDs, id)
|
||
keys = append(keys, k)
|
||
}
|
||
return fileIDs, keys, rows.Err()
|
||
}
|
||
|
||
func (s *Store) HardDeleteFile(ctx context.Context, id, ownerID uuid.UUID) (storageKey string, versionKeys []string, err error) {
|
||
// Collect version blobs before the cascade removes them.
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("list version keys: %w", err)
|
||
}
|
||
for rows.Next() {
|
||
var k string
|
||
if err := rows.Scan(&k); err != nil {
|
||
rows.Close()
|
||
return "", nil, err
|
||
}
|
||
versionKeys = append(versionKeys, k)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return "", nil, err
|
||
}
|
||
|
||
err = s.db.QueryRow(ctx,
|
||
`DELETE FROM files WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = true
|
||
RETURNING storage_key`,
|
||
id, ownerID,
|
||
).Scan(&storageKey)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "", nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("hard delete file: %w", err)
|
||
}
|
||
return storageKey, versionKeys, nil
|
||
}
|
||
|
||
func (s *Store) UsedBytes(ctx context.Context, ownerID uuid.UUID) (int64, error) {
|
||
var n int64
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT COALESCE(SUM(size), 0) FROM files WHERE (owner_id = $1 OR group_id = $1) AND is_deleted = false`,
|
||
ownerID,
|
||
).Scan(&n)
|
||
return n, err
|
||
}
|
||
|
||
// GetFolderPath resolves the full slash-separated path for a folder, e.g. "Urlaub/2024".
|
||
// Returns "" for root (nil folderID).
|
||
func (s *Store) GetFolderPath(ctx context.Context, folderID *uuid.UUID) (string, error) {
|
||
if folderID == nil {
|
||
return "", nil
|
||
}
|
||
var path string
|
||
err := s.db.QueryRow(ctx,
|
||
`WITH RECURSIVE p AS (
|
||
SELECT id, name, parent_id, name::text AS full_path
|
||
FROM folders WHERE id = $1
|
||
UNION ALL
|
||
SELECT f.id, f.name, f.parent_id, f.name || '/' || p.full_path
|
||
FROM folders f JOIN p ON f.id = p.parent_id
|
||
)
|
||
SELECT full_path FROM p WHERE parent_id IS NULL`,
|
||
*folderID,
|
||
).Scan(&path)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "", nil
|
||
}
|
||
return path, err
|
||
}
|
||
|
||
// GetFileByName finds a non-deleted file by name in a folder (for versioning on re-upload).
|
||
func (s *Store) GetFileByName(ctx context.Context, ownerID uuid.UUID, folderID *uuid.UUID, name string) (*File, error) {
|
||
var f File
|
||
var err error
|
||
if folderID == nil {
|
||
err = scanFile(s.db.QueryRow(ctx,
|
||
`SELECT `+fileCols+` FROM files
|
||
WHERE (owner_id = $1 OR group_id = $1) AND folder_id IS NULL AND name=$2 AND is_deleted=false`,
|
||
ownerID, name,
|
||
), &f)
|
||
} else {
|
||
err = scanFile(s.db.QueryRow(ctx,
|
||
`SELECT `+fileCols+` FROM files
|
||
WHERE (owner_id = $1 OR group_id = $1) AND folder_id=$2 AND name=$3 AND is_deleted=false`,
|
||
ownerID, *folderID, name,
|
||
), &f)
|
||
}
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return &f, err
|
||
}
|
||
|
||
func (s *Store) CreateVersion(ctx context.Context, fileID uuid.UUID, version int, size int64, checksum, storageKey string) error {
|
||
_, err := s.db.Exec(ctx,
|
||
`INSERT INTO file_versions (file_id, version, size, checksum, storage_key)
|
||
VALUES ($1, $2, $3, $4, $5)`,
|
||
fileID, version, size, checksum, storageKey,
|
||
)
|
||
return err
|
||
}
|
||
|
||
// UpdateFileContent replaces blob metadata and increments version. Resets has_thumbnail.
|
||
func (s *Store) UpdateFileContent(ctx context.Context, id uuid.UUID, size int64, checksum, storageKey string) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`UPDATE files SET size=$2, checksum=$3, storage_key=$4, version=version+1, has_thumbnail=false
|
||
WHERE id=$1 AND is_deleted=false RETURNING `+fileCols,
|
||
id, size, checksum, storageKey,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return &f, err
|
||
}
|
||
|
||
// DeleteVersion entfernt einen Versionseintrag. Wird beim Wiederherstellen
|
||
// gebraucht: dessen Inhalt ist danach die aktive Fassung, der Eintrag also
|
||
// gegenstandslos. Der Blob wird nicht angefasst – er wandert zur Datei.
|
||
func (s *Store) DeleteVersion(ctx context.Context, versionID, fileID uuid.UUID) error {
|
||
_, err := s.db.Exec(ctx,
|
||
`DELETE FROM file_versions WHERE id=$1 AND file_id=$2`, versionID, fileID)
|
||
return err
|
||
}
|
||
|
||
// SetVersioning schaltet die Versionierung einer Datei um.
|
||
//
|
||
// Bestehende Fassungen bleiben beim Ausschalten erhalten – sie zu löschen wäre
|
||
// stiller Datenverlust. Es entstehen ab dann nur keine neuen mehr.
|
||
func (s *Store) SetVersioning(ctx context.Context, id, ownerID uuid.UUID, enabled bool) (*File, error) {
|
||
var f File
|
||
err := scanFile(s.db.QueryRow(ctx,
|
||
`UPDATE files SET versioning_enabled=$3
|
||
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false RETURNING `+fileCols,
|
||
id, ownerID, enabled,
|
||
), &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return &f, err
|
||
}
|
||
|
||
// PruneVersions keeps the newest maxKeep versions and deletes the rest.
|
||
// Returns storage keys of deleted versions so the caller can remove the blobs.
|
||
func (s *Store) PruneVersions(ctx context.Context, fileID uuid.UUID, maxKeep int) ([]string, error) {
|
||
rows, err := s.db.Query(ctx,
|
||
`DELETE FROM file_versions
|
||
WHERE id IN (
|
||
SELECT id FROM file_versions WHERE file_id=$1
|
||
ORDER BY version DESC OFFSET $2
|
||
) RETURNING storage_key`,
|
||
fileID, maxKeep,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var keys []string
|
||
for rows.Next() {
|
||
var k string
|
||
if err := rows.Scan(&k); err == nil {
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
return keys, rows.Err()
|
||
}
|
||
|
||
func (s *Store) ListVersions(ctx context.Context, fileID, ownerID uuid.UUID) ([]*FileVersion, error) {
|
||
// Verify ownership via JOIN
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT fv.id, fv.file_id, fv.version, fv.size, fv.checksum, fv.storage_key, fv.created_at
|
||
FROM file_versions fv
|
||
JOIN files f ON f.id = fv.file_id
|
||
WHERE fv.file_id=$1 AND (f.owner_id = $2 OR f.group_id = $2)
|
||
ORDER BY fv.version DESC`,
|
||
fileID, ownerID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
result := make([]*FileVersion, 0)
|
||
for rows.Next() {
|
||
var v FileVersion
|
||
if err := rows.Scan(&v.ID, &v.FileID, &v.Version, &v.Size, &v.Checksum, &v.StorageKey, &v.CreatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, &v)
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func (s *Store) GetVersion(ctx context.Context, versionID, fileID, ownerID uuid.UUID) (*FileVersion, error) {
|
||
var v FileVersion
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT fv.id, fv.file_id, fv.version, fv.size, fv.checksum, fv.storage_key, fv.created_at
|
||
FROM file_versions fv
|
||
JOIN files f ON f.id = fv.file_id
|
||
WHERE fv.id=$1 AND fv.file_id=$2 AND (f.owner_id = $3 OR f.group_id = $3)`,
|
||
versionID, fileID, ownerID,
|
||
).Scan(&v.ID, &v.FileID, &v.Version, &v.Size, &v.Checksum, &v.StorageKey, &v.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return &v, err
|
||
}
|
||
|
||
func (s *Store) UpdateThumbnail(ctx context.Context, id uuid.UUID, hasThumbnail bool) error {
|
||
_, err := s.db.Exec(ctx,
|
||
`UPDATE files SET has_thumbnail = $2 WHERE id = $1`, id, hasThumbnail)
|
||
return err
|
||
}
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
func scanFiles(rows pgx.Rows) ([]*File, error) {
|
||
result := make([]*File, 0)
|
||
for rows.Next() {
|
||
var f File
|
||
if err := scanFile(rows, &f); err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, &f)
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func isUniqueViolation(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
type sqlStater interface{ SQLState() string }
|
||
var se sqlStater
|
||
if errors.As(err, &se) {
|
||
return se.SQLState() == "23505"
|
||
}
|
||
return false
|
||
}
|
||
|
||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||
// entstanden ist – 0 heißt: es gibt keins.
|
||
//
|
||
|
||
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
||
// Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.
|
||
func (s *Store) FilesWithPreview(ctx context.Context) ([]string, error) {
|
||
rows, err := s.db.Query(ctx, `SELECT id::text FROM files WHERE preview_version > 0`)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("files with preview: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var ids []string
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
return nil, err
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
return ids, rows.Err()
|
||
}
|