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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+535
View File
@@ -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
}