feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,535 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user