Kameras speichern die Bildpunkte fast immer quer und legen die Drehung als EXIF-Notiz daneben. Browser befolgen sie von selbst - Go nicht. Deshalb stand dasselbe Foto in der grossen Ansicht aufrecht und lag im Vorschaubild auf der Seite. Neues Paket internal/imageutil liest genau ein Feld (0x0112) aus dem Anfang der Datei und dreht danach. Von Hand statt mit einer Bibliothek: Der Weg dorthin ist kurz und liegt fest, und gebraucht wird ein Feld. Gelesen werden nur die ersten 64 KB, damit ein 40-MB-Foto nicht zweimal im Speicher liegt. Gedreht wird vor dem Verkleinern - andersherum bekaeme ein hochkantes Foto den Zuschnitt eines Querformats. Bestehende Vorschaubilder aendern sich davon nicht. Dafuer neu: POST /admin/thumbnails/rebuild samt Knopf im Admin-Bereich. Auch Profilbilder aus OIDC laufen jetzt durch dieselbe Auswertung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
562 lines
18 KiB
Go
562 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
|
||
}
|
||
|
||
// ImageFiles nennt alle nicht gelöschten Dateien, aus denen ein Vorschaubild
|
||
// entstehen kann – für das Neuerzeugen von Hand.
|
||
//
|
||
// Ohne Papierkorb: Für eine Datei, die man weggeworfen hat, lohnt keine Runde
|
||
// durch den Dekodierer.
|
||
func (s *Store) ImageFiles(ctx context.Context) ([]*File, error) {
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT `+fileCols+`
|
||
FROM files
|
||
WHERE is_deleted = false AND mime_type LIKE 'image/%'
|
||
ORDER BY created_at`)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query image files: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []*File
|
||
for rows.Next() {
|
||
var f File
|
||
if err := scanFile(rows, &f); err != nil {
|
||
return nil, fmt.Errorf("scan image file: %w", err)
|
||
}
|
||
out = append(out, &f)
|
||
}
|
||
return out, rows.Err()
|
||
}
|