Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche. `EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit in der Tabelle und wurde nie abgerufen. Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus. Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271) samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC, die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien unter previews/ sammelt der GC ein. Zwei Fehler, die dabei sichtbar wurden: fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte, den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste Schritte verschluckt genau diese popstate. fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst` standen nie drin; das war die Asymmetrie. Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die Ankündigung nennt 5) und die Android-App als Idee. Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
561 lines
18 KiB
Go
561 lines
18 KiB
Go
package files
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"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)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||
RETURNING `+fileCols,
|
||
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
|
||
), &out)
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
return nil, ErrNameTaken
|
||
}
|
||
return nil, fmt.Errorf("create file: %w", err)
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
// 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()
|
||
}
|