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/.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() }