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>
210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
package files
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Ordner: anlegen, umbenennen, verschieben, löschen, auflisten.
|
|
|
|
// ── Folders ──────────────────────────────────────────────────────────────────
|
|
|
|
func (s *Store) CreateFolder(ctx context.Context, f *Folder) (*Folder, error) {
|
|
var out Folder
|
|
err := s.db.QueryRow(ctx,
|
|
`INSERT INTO folders (owner_id, group_id, parent_id, name)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, owner_id, group_id, parent_id, name, created_at`,
|
|
f.OwnerID, f.GroupID, f.ParentID, f.Name,
|
|
).Scan(&out.ID, &out.OwnerID, &out.GroupID, &out.ParentID, &out.Name, &out.CreatedAt)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrNameTaken
|
|
}
|
|
return nil, fmt.Errorf("create folder: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *Store) GetFolder(ctx context.Context, id, ownerID uuid.UUID) (*Folder, error) {
|
|
var f Folder
|
|
err := s.db.QueryRow(ctx,
|
|
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders WHERE id = $1 AND (owner_id = $2 OR group_id = $2)`,
|
|
id, ownerID,
|
|
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get folder: %w", err)
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// ListFilesInSubtree returns all non-deleted files inside a folder and all its descendants.
|
|
func (s *Store) ListFilesInSubtree(ctx context.Context, ownerID, rootFolderID uuid.UUID) ([]*File, error) {
|
|
rows, err := s.db.Query(ctx,
|
|
`WITH RECURSIVE subtree AS (
|
|
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
|
|
UNION ALL
|
|
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
|
|
)
|
|
SELECT `+fileCols+`
|
|
FROM files
|
|
WHERE (owner_id = $2 OR group_id = $2) AND is_deleted=false AND folder_id IN (SELECT id FROM subtree)`,
|
|
rootFolderID, ownerID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list files in subtree: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
files := make([]*File, 0)
|
|
for rows.Next() {
|
|
var f File
|
|
if err := scanFile(rows, &f); err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, &f)
|
|
}
|
|
return files, rows.Err()
|
|
}
|
|
|
|
// UpdateFileStorageKey updates only the storage_key of a file (used after blob moves).
|
|
func (s *Store) UpdateFileStorageKey(ctx context.Context, id uuid.UUID, storageKey string) error {
|
|
_, err := s.db.Exec(ctx, `UPDATE files SET storage_key=$2 WHERE id=$1`, id, storageKey)
|
|
return err
|
|
}
|
|
|
|
// RenameFolder renames a folder. Blob path cascading is handled by the handler.
|
|
func (s *Store) RenameFolder(ctx context.Context, id, ownerID uuid.UUID, name string) (*Folder, error) {
|
|
var f Folder
|
|
err := s.db.QueryRow(ctx,
|
|
`UPDATE folders SET name=$3 WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
|
|
RETURNING id, owner_id, group_id, parent_id, name, created_at`,
|
|
id, ownerID, name,
|
|
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrNameTaken
|
|
}
|
|
return nil, fmt.Errorf("rename folder: %w", err)
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// DeleteFolder soft-deletes all files in the subtree (storage_keys must already be
|
|
// updated to trash paths by the caller) and hard-deletes the folder tree.
|
|
func (s *Store) DeleteFolder(ctx context.Context, id, ownerID uuid.UUID) error {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`WITH RECURSIVE subtree AS (
|
|
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
|
|
UNION ALL
|
|
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
|
|
)
|
|
UPDATE files SET is_deleted=true, deleted_at=NOW()
|
|
WHERE (owner_id = $2 OR group_id = $2) AND is_deleted=false AND folder_id IN (SELECT id FROM subtree)`,
|
|
id, ownerID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("soft-delete files in folder: %w", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`WITH RECURSIVE subtree AS (
|
|
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
|
|
UNION ALL
|
|
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
|
|
)
|
|
DELETE FROM folders WHERE id IN (SELECT id FROM subtree) AND (owner_id = $2 OR group_id = $2)`,
|
|
id, ownerID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("delete folders: %w", err)
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (s *Store) ListFolders(ctx context.Context, ownerID uuid.UUID, parentID *uuid.UUID) ([]*Folder, error) {
|
|
var rows pgx.Rows
|
|
var err error
|
|
if parentID == nil {
|
|
rows, err = s.db.Query(ctx,
|
|
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders
|
|
WHERE (owner_id = $1 OR group_id = $1) AND parent_id IS NULL ORDER BY name`,
|
|
ownerID,
|
|
)
|
|
} else {
|
|
rows, err = s.db.Query(ctx,
|
|
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders
|
|
WHERE (owner_id = $1 OR group_id = $1) AND parent_id = $2 ORDER BY name`,
|
|
ownerID, parentID,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list folders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
folders := make([]*Folder, 0)
|
|
for rows.Next() {
|
|
var f Folder
|
|
if err := rows.Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
folders = append(folders, &f)
|
|
}
|
|
return folders, rows.Err()
|
|
}
|
|
|
|
// MoveFolder changes a folder's parent. Returns ErrCycle if newParentID is a descendant.
|
|
func (s *Store) MoveFolder(ctx context.Context, id, ownerID uuid.UUID, newParentID *uuid.UUID) (*Folder, error) {
|
|
if newParentID != nil {
|
|
var isDescendant bool
|
|
err := s.db.QueryRow(ctx,
|
|
`WITH RECURSIVE subtree AS (
|
|
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
|
|
UNION ALL
|
|
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
|
|
)
|
|
SELECT EXISTS(SELECT 1 FROM subtree WHERE id=$3)`,
|
|
id, ownerID, *newParentID,
|
|
).Scan(&isDescendant)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cycle check: %w", err)
|
|
}
|
|
if isDescendant {
|
|
return nil, ErrCycle
|
|
}
|
|
}
|
|
|
|
var f Folder
|
|
err := s.db.QueryRow(ctx,
|
|
`UPDATE folders SET parent_id=$3 WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
|
|
RETURNING id, owner_id, group_id, parent_id, name, created_at`,
|
|
id, ownerID, newParentID,
|
|
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrNameTaken
|
|
}
|
|
return nil, fmt.Errorf("move folder: %w", err)
|
|
}
|
|
return &f, nil
|
|
}
|