From bd6e7c38f9f0b9f92cef48e77d6372c5bc2b62b5 Mon Sep 17 00:00:00 2001 From: m Date: Tue, 28 Jul 2026 18:12:56 +0200 Subject: [PATCH] V3 version with backward comp --- V1.md | 315 +++++++++++ backend/cmd/server/main.go | 10 +- backend/internal/db/auth.sql.go | 9 +- backend/internal/db/files.sql.go | 390 -------------- .../db/migrations/012_v3_new_tables.down.sql | 10 + .../db/migrations/012_v3_new_tables.up.sql | 145 ++++++ .../migrations/013_v3_migrate_data.down.sql | 15 + .../db/migrations/013_v3_migrate_data.up.sql | 100 ++++ .../db/migrations/014_v3_cleanup.down.sql | 33 ++ .../db/migrations/014_v3_cleanup.up.sql | 10 + backend/internal/db/models.go | 124 +++-- backend/internal/db/queries/files.sql | 57 -- .../internal/db/queries/rebac_relations.sql | 36 ++ .../db/queries/resource_placements.sql | 32 ++ .../internal/db/queries/resource_variants.sql | 21 + backend/internal/db/queries/resources.sql | 67 +++ .../db/queries/retention_policies.sql | 20 + .../internal/db/queries/storage_locations.sql | 27 + backend/internal/db/queries/sync_queue.sql | 27 + backend/internal/db/queries/tags.sql | 30 +- backend/internal/db/queries/thumbnails.sql | 21 - backend/internal/db/rebac_relations.sql.go | 202 ++++++++ .../internal/db/resource_placements.sql.go | 194 +++++++ backend/internal/db/resource_variants.sql.go | 157 ++++++ backend/internal/db/resources.sql.go | 487 ++++++++++++++++++ backend/internal/db/retention_policies.sql.go | 144 ++++++ backend/internal/db/storage_locations.sql.go | 134 +++++ backend/internal/db/sync_queue.sql.go | 172 +++++++ backend/internal/db/tags.sql.go | 92 ++-- backend/internal/db/thumbnails.sql.go | 150 ------ backend/internal/handler/device.go | 65 +++ backend/internal/handler/handler.go | 34 +- backend/internal/handler/ocr.go | 4 +- .../handler/{files.go => resource.go} | 297 ++++++----- backend/internal/handler/router.go | 66 ++- backend/internal/handler/share.go | 89 ++++ backend/internal/handler/sync.go | 56 ++ backend/internal/model/file.go | 47 +- backend/internal/model/thumbnail.go | 12 - backend/internal/service/checksum.go | 13 +- backend/internal/service/conversion.go | 55 +- backend/internal/service/file.go | 349 ------------- backend/internal/service/ocr.go | 36 +- backend/internal/service/placement.go | 65 +++ backend/internal/service/rebac.go | 89 ++++ backend/internal/service/resource.go | 423 +++++++++++++++ backend/internal/service/sync.go | 74 +++ backend/internal/service/url.go | 24 +- 48 files changed, 3685 insertions(+), 1344 deletions(-) create mode 100644 V1.md delete mode 100644 backend/internal/db/files.sql.go create mode 100644 backend/internal/db/migrations/012_v3_new_tables.down.sql create mode 100644 backend/internal/db/migrations/012_v3_new_tables.up.sql create mode 100644 backend/internal/db/migrations/013_v3_migrate_data.down.sql create mode 100644 backend/internal/db/migrations/013_v3_migrate_data.up.sql create mode 100644 backend/internal/db/migrations/014_v3_cleanup.down.sql create mode 100644 backend/internal/db/migrations/014_v3_cleanup.up.sql delete mode 100644 backend/internal/db/queries/files.sql create mode 100644 backend/internal/db/queries/rebac_relations.sql create mode 100644 backend/internal/db/queries/resource_placements.sql create mode 100644 backend/internal/db/queries/resource_variants.sql create mode 100644 backend/internal/db/queries/resources.sql create mode 100644 backend/internal/db/queries/retention_policies.sql create mode 100644 backend/internal/db/queries/storage_locations.sql create mode 100644 backend/internal/db/queries/sync_queue.sql delete mode 100644 backend/internal/db/queries/thumbnails.sql create mode 100644 backend/internal/db/rebac_relations.sql.go create mode 100644 backend/internal/db/resource_placements.sql.go create mode 100644 backend/internal/db/resource_variants.sql.go create mode 100644 backend/internal/db/resources.sql.go create mode 100644 backend/internal/db/retention_policies.sql.go create mode 100644 backend/internal/db/storage_locations.sql.go create mode 100644 backend/internal/db/sync_queue.sql.go delete mode 100644 backend/internal/db/thumbnails.sql.go create mode 100644 backend/internal/handler/device.go rename backend/internal/handler/{files.go => resource.go} (53%) create mode 100644 backend/internal/handler/share.go create mode 100644 backend/internal/handler/sync.go delete mode 100644 backend/internal/service/file.go create mode 100644 backend/internal/service/placement.go create mode 100644 backend/internal/service/rebac.go create mode 100644 backend/internal/service/resource.go create mode 100644 backend/internal/service/sync.go diff --git a/V1.md b/V1.md new file mode 100644 index 0000000..6056251 --- /dev/null +++ b/V1.md @@ -0,0 +1,315 @@ +# VaultDrop Backend — Spécification V1 (État Existant) + +## 1. Vue d'ensemble + +Application de gestion de documents : upload, scan OCR, tagging, recherche, organisation en dossiers. Backend Go avec API REST, base PostgreSQL, microservice OCR Python séparé. + +``` +┌──────────────────┐ ┌──────────────────────┐ +│ Mobile App │ HTTPS │ Go Backend (Gin) │ +│ (React Native) │────────>│ REST API │ +│ │ │ - Upload / Tags │ +│ │ │ - Auth (PASETO) │ +│ │ │ - Thumbnails │ +└──────────────────┘ │ - Conversion PDF │ + └────────┬─────────────┘ + │ HTTP + ┌────────▼─────────────┐ + │ OCR Server (Python) │ + │ PaddleOCR + FastAPI │ + └──────────────────────┘ +``` + +## 2. Choix Techniques + +| Composant | Technologie | +|---|---| +| Framework HTTP | Gin v1.12 | +| Base de données | PostgreSQL (lib/pq) | +| ORM/Query | sqlc v1.31 (généré) | +| Migrations | golang-migrate v4 | +| Auth | PASETO v4 (go-paseto) + Argon2id | +| OCR | PaddleOCR (microservice Python séparé) | +| Conversion PDF | LibreOffice + pdftoppm (appels système) | +| Checksum | SHA-256 | +| Stockage fichiers | Disque local (`./uploads/`) | +| URLs signées | HMAC-SHA256 avec expiry | + +## 3. Structure du Code + +``` +backend/ +├── cmd/server/main.go # Point d'entrée +├── internal/ +│ ├── auth/ # Auth (3 fichiers) +│ │ ├── service.go # Register, Login, Refresh, Logout, ValidateAccessToken +│ │ ├── handler.go # HTTP handlers auth +│ │ └── middleware.go # RequireAuth() middleware +│ ├── config/config.go # Config struct (11 vars d'env) +│ ├── db/ +│ │ ├── connect.go # Connexion PostgreSQL +│ │ ├── migrate.go # golang-migrate +│ │ ├── db.go # sqlc Queries interface +│ │ ├── models.go # sqlc models (généré) +│ │ ├── files.sql.go # sqlc queries fichiers (généré) +│ │ ├── tags.sql.go # sqlc queries tags (généré) +│ │ ├── thumbnails.sql.go # sqlc queries thumbnails (généré) +│ │ ├── health.sql.go # sqlc health check +│ │ ├── auth.sql.go # sqlc queries auth (généré) +│ │ ├── queries/ # SQL sources (5 fichiers .sql) +│ │ └── migrations/ # 11 migrations (001→011) +│ ├── handler/ +│ │ ├── handler.go # Handler struct + constructor +│ │ ├── router.go # SetupRoutes() +│ │ ├── files.go # 14 handlers fichiers/thumbnails +│ │ ├── ocr.go # OCR stubs (501/404) +│ │ └── health.go # Health check +│ ├── model/ +│ │ ├── file.go # File, Tag, UploadResult structs +│ │ ├── ocrjob.go # OcrJob struct +│ │ ├── tag.go # (vide) +│ │ └── thumbnail.go # Thumbnail struct +│ ├── ocr/ +│ │ ├── client.go # Client HTTP PaddleOCR + détection doc type +│ │ └── types.go # OCR request/response types +│ └── service/ +│ ├── file.go # FileService: upload, list, get, delete, tags, folders, thumbnails, dedup +│ ├── ocr.go # OCRService: worker async, enqueue, process +│ ├── conversion.go # ConversionService: PDF→images, thumbnails multi-résolution +│ ├── url.go # URLService: signed URLs HMAC +│ └── checksum.go # SHA-256 hash helpers +├── pkg/api/response.go # Helpers: Success, Created, Paginated, Error +├── ocr-server/ +│ ├── server.py # FastAPI + PaddleOCR +│ └── Dockerfile +├── uploads/ # Stockage fichiers + thumbnails +├── sqlc.yaml +├── Dockerfile +├── go.mod +└── .env +``` + +## 4. Base de Données (Schéma Final) + +### Tables + +**files** + +| Colonne | Type | Contraintes | +|---|---|---| +| id | TEXT | PK, DEFAULT gen_random_uuid() | +| name | TEXT | NOT NULL | +| mime_type | TEXT | NOT NULL DEFAULT '' | +| size | BIGINT | NOT NULL DEFAULT 0 | +| storage_key | TEXT | NOT NULL DEFAULT '' | +| checksum | TEXT | NOT NULL DEFAULT '' | +| ocr_text | TEXT | NOT NULL DEFAULT '' | +| is_folder | BOOLEAN | NOT NULL DEFAULT false | +| parent_file_id | TEXT | FK → files(id) | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | + +**tags** + +| Colonne | Type | Contraintes | +|---|---|---| +| id | TEXT | PK, DEFAULT gen_random_uuid() | +| parent_tag_id | TEXT | FK → tags(id) | +| tag_name | TEXT | NOT NULL | +| tag_type | TEXT | NOT NULL DEFAULT 'none' | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | + +**file_tags** + +| Colonne | Type | Contraintes | +|---|---|---| +| id | TEXT | PK, DEFAULT gen_random_uuid() | +| tag_id | TEXT | FK → tags(id) | +| file_id | TEXT | FK → files(id) | + +**users** + +| Colonne | Type | Contraintes | +|---|---|---| +| id | TEXT | PK, DEFAULT gen_random_uuid() | +| username | TEXT | NOT NULL, UNIQUE | +| password_hash | TEXT | NOT NULL | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | + +**refresh_tokens** + +| Colonne | Type | Contraintes | +|---|---|---| +| id | TEXT | PK, DEFAULT gen_random_uuid() | +| user_id | TEXT | FK → users(id), ON DELETE CASCADE | +| token_hash | TEXT | NOT NULL | +| expires_at | TIMESTAMP | NOT NULL | +| revoked | BOOLEAN | DEFAULT FALSE | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | + +**thumbnails** + +| Colonne | Type | Contraintes | +|---|---|---| +| id | TEXT | PK, DEFAULT gen_random_uuid() | +| file_id | TEXT | FK → files(id), ON DELETE CASCADE | +| page_number | INTEGER | NOT NULL | +| resolution_label | TEXT | NOT NULL | +| width | INTEGER | NOT NULL | +| height | INTEGER | NOT NULL | +| storage_key | TEXT | NOT NULL | +| mime_type | TEXT | DEFAULT 'image/jpeg' | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | + +### Index + +- `idx_refresh_tokens_user_id` ON refresh_tokens(user_id) +- `idx_refresh_tokens_token_hash` ON refresh_tokens(token_hash) +- `idx_thumbnails_file_id` ON thumbnails(file_id) +- `idx_thumbnails_unique` UNIQUE ON thumbnails(file_id, page_number, resolution_label) + +## 5. API Endpoints + +### Public + +| Méthode | Path | Handler | Description | +|---|---|---|---| +| GET | `/api/v1/health` | HealthHandler.Check | Retourne `{status: "healthy"}` | +| POST | `/api/v1/auth/register` | AuthHandler.Register | Créer un compte (username + password) | +| POST | `/api/v1/auth/login` | AuthHandler.Login | Login → access_token + refresh_token | +| POST | `/api/v1/auth/refresh` | AuthHandler.Refresh | Renouveler les tokens | +| POST | `/api/v1/auth/logout` | AuthHandler.Logout | Révoquer le refresh token | +| GET | `/api/v1/files/download/:id` | FileHandler.Download | Télécharger un fichier (URL signée) | +| GET | `/api/v1/thumbnails/:id` | FileHandler.ServeThumbnail | Télécharger une thumbnail (URL signée) | + +### Protégées (Bearer token) + +| Méthode | Path | Handler | Description | +|---|---|---|---| +| GET | `/api/v1/files` | FileHandler.List | Lister tous les fichiers (racine) | +| POST | `/api/v1/files/upload` | FileHandler.Upload | Upload multi-fichiers (multipart) | +| POST | `/api/v1/files/move` | FileHandler.MoveFiles | Déplacer des fichiers vers un dossier | +| POST | `/api/v1/files/folders` | FileHandler.CreateFolder | Créer un dossier | +| GET | `/api/v1/files/folders` | FileHandler.ListFolders | Lister les dossiers | +| GET | `/api/v1/files/folders/:id/files` | FileHandler.ListFilesByParent | Lister les fichiers d'un dossier | +| DELETE | `/api/v1/files/:id` | FileHandler.Delete | Supprimer un fichier (+ thumbnails + fichier disque) | +| GET | `/api/v1/files/:id` | FileHandler.Get | Détail d'un fichier (+ tags + thumbnails) | +| POST | `/api/v1/files/:id/tags` | FileHandler.AddTags | Ajouter des tags à un fichier | +| GET | `/api/v1/files/:id/tags` | FileHandler.GetTags | Récupérer les tags d'un fichier | +| POST | `/api/v1/files/:id/thumbnails` | FileHandler.GetThumbnails | Lister les thumbnails d'un fichier | +| POST | `/api/v1/files/dedup-check` | FileHandler.CheckDuplicates | Vérifier les doublons (nom + taille) | +| POST | `/api/v1/ocr/jobs` | OCRHandler.CreateJob | **STUB** — retourne 501 | +| GET | `/api/v1/ocr/jobs/:id` | OCRHandler.GetJobStatus | **STUB** — retourne 404 | + +### Format de réponse + +Succès : `{ "data": ... }` ou `{ "data": ..., "meta": { "page": N, "total": N } }` +Erreur : `{ "error": { "code": "...", "message": "..." } }` + +## 6. Services Métier + +### FileService (service/file.go — 349 lignes) + +- `Upload(file)` — Upload multipart, checksum SHA-256, détection doublons par checksum, stockage disque +- `List()` — Tous les fichiers racine (parent_file_id IS NULL), avec tags +- `Get(id)` — Fichier par ID avec tags +- `Delete(id)` — Suppression DB (handler gère suppression disque + thumbnails) +- `GetStoragePath(id)` — Chemin disque du fichier +- `UpdateOCRText(id, text)` — Mise à jour du champ ocr_text +- `AddTags(fileID, tags, tagType)` — Création auto des tags + liaison +- `GetTagsByFileID(fileID)` — Tags d'un fichier +- `MoveFiles(fileIDs, parentFileID)` — Déplacer vers un dossier (ou racine si nil) +- `CreateFolder(name)` — Créer un dossier +- `ListFolders()` — Lister tous les dossiers +- `ListFilesByParentID(parentID)` — Fichiers d'un dossier +- `GetThumbnailsByFileID(fileID)` — Thumbnails d'un fichier +- `GetThumbnailStoragePath(id)` — Chemin disque thumbnail +- `GetBestThumbnail(fileID, label)` — Meilleure thumbnail (page 1, label préféré ou fallback) +- `FindDuplicatesByNameSize(name, size)` — Recherche doublons par nom+taille + +### OCRService (service/ocr.go — 95 lignes) + +- `Start()` — Lance le worker goroutine +- `Stop()` — Ferme le channel jobs +- `Enqueue(fileID, filePath)` — Ajoute un job OCR (channel buffer 100) +- `process(job)` — Lecture fichier → Client OCR → Flatten text → UpdateOCRText +- `RecognizeFromBytes(data)` — OCR direct (pas utilisé par les handlers) +- `HealthCheck()` — Vérifie l'OCR server + +### ConversionService (service/conversion.go — 253 lignes) + +- `Start()` / `Stop()` — Worker goroutine +- `Enqueue(fileID, filePath, mimeType)` — Conversion async (channel buffer 100) +- `process(job)` : + 1. Si document Office → conversion vers PDF via LibreOffice headless + 2. Si PDF → direct + 3. PDF → images via pdftoppm à 2 résolutions : "thumbnail" (21 DPI) et "full" (200 DPI) + 4. Création des enregistrements thumbnails en DB +- Supporte : PDF, .docx, .xlsx, .pptx, formats OpenDocument + +### URLService (service/url.go — 67 lignes) + +- `GenerateDownloadURL(fileUUID)` — URL signée HMAC avec expiry (défaut 60 min) +- `GenerateThumbnailURL(thumbUUID)` — Idem pour thumbnails +- `Validate(fileID, sig, expires)` — Vérifie signature HMAC + expiry + +### Auth (auth/ — 250+135+41 lignes) + +- **Register** : validation username (3-30 chars) + password (8+ chars), Argon2id hash, PASETO v4 tokens +- **Login** : vérification credentials, émission token pair +- **Refresh** : révoque l'ancien refresh token, émet un nouveau pair +- **Logout** : révoque le refresh token +- **Middleware** : Bearer token extraction → ValidateAccessToken → set userID in context +- Access token TTL : 30 min | Refresh token TTL : 7 jours + +## 7. OCR Server (Python) + +**ocr-server/server.py** — FastAPI + PaddleOCR + +- `GET /health` — health check +- `POST /ocr` — OCR via JSON base64 +- `POST /ocr/upload` — OCR via upload fichier +- Supporte images (JPEG/PNG) et PDF (conversion vers images via pypdfium2) +- Langue par défaut : français (`OCR_LANG=fr`) +- Timeout client Go : 120 secondes + +## 8. Détection de Doublons + +Deux mécanismes : + +1. **Par checksum (SHA-256)** : lors de l'upload, si un fichier avec le même checksum existe déjà, le fichier uploadé est supprimé et l'ID existant retourné +2. **Par nom+taille** : endpoint `POST /files/dedup-check` pour vérifier avant upload + +## 9. Variables d'Environnement + +| Variable | Défaut | Description | +|---|---|---| +| PORT | `8080` | Port du serveur | +| DATABASE_URL | `postgres://localhost:5432/vaultdrop?sslmode=disable` | URL PostgreSQL | +| OCR_ENDPOINT | `http://localhost:9090` | URL du microservice OCR | +| UPLOAD_DIR | `./uploads` | Répertoire de stockage | +| HMAC_SECRET | `thisismyrandomstring` | Secret pour URLs signées | +| SERVER_HOST | `http://192.168.1.17:8080` | Host public du serveur | +| PASETO_KEY | (clé hex 64 car.) | Clé symétrique PASETO v4 | +| LIBREOFFICE_PATH | `/usr/bin/libreoffice` | Chemin LibreOffice | +| PDFTOPPM_PATH | `/usr/bin/pdftoppm` | Chemin pdftoppm | +| THUMBNAIL_DIR | `./uploads/thumbnails` | Répertoire thumbnails | +| URL_EXPIRY_MINUTES | `60` | Durée de vie des URLs signées | + +## 10. Docker + +**Backend** : Multi-stage build (golang:1.24-alpine → alpine:3.21 avec libreoffice-core + poppler-utils) + +**OCR Server** : Dockerfile séparé dans `ocr-server/` + +## 11. Ce qui manque / Stubs + +- `OCRHandler.CreateJob` → retourne 501 NOT_IMPLEMENTED +- `OCRHandler.GetJobStatus` → retourne 404 JOB_NOT_FOUND +- Pas de recherche full-text (pas de FTS5/PostgreSQL tsvector) +- Pas de pagination côté DB (tout est chargé en mémoire) +- Pas de gestion d'erreurs robuste dans le worker OCR (logs seulement) +- Les fichiers n'ont pas de lien avec les utilisateurs (pas de user_id sur files) +- Le health check ne vérifie pas la DB ni l'OCR server diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ab1e0e7..9c69489 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -30,8 +30,8 @@ func main() { queries := db.New(database) - fileSvc := service.NewFileService(queries, cfg) - ocrSvc := service.NewOCRService(cfg, fileSvc) + resourceSvc := service.NewResourceService(queries, cfg) + ocrSvc := service.NewOCRService(cfg, resourceSvc) conversionSvc := service.NewConversionService(queries, cfg) urlSvc := service.NewURLService(cfg.HMACSecret, cfg.ServerHost, cfg.URLExpiryMinutes) authSvc, err := auth.NewAuthService(queries, cfg) @@ -39,13 +39,17 @@ func main() { log.Fatalf("Failed to create auth service: %v", err) } + rebacSvc := service.NewRebacService(queries) + placementSvc := service.NewPlacementService(queries) + syncSvc := service.NewSyncService(queries) + ocrSvc.Start() defer ocrSvc.Stop() conversionSvc.Start() defer conversionSvc.Stop() - h := handler.New(fileSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc), conversionSvc) + h := handler.New(resourceSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc), conversionSvc, rebacSvc, placementSvc, syncSvc) r := gin.Default() handler.SetupRoutes(r, h, authSvc) diff --git a/backend/internal/db/auth.sql.go b/backend/internal/db/auth.sql.go index 95b3853..ea5c7e5 100644 --- a/backend/internal/db/auth.sql.go +++ b/backend/internal/db/auth.sql.go @@ -39,7 +39,7 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken const createUser = `-- name: CreateUser :one INSERT INTO users (username, password_hash) VALUES ($1, $2) -RETURNING id, username, password_hash, created_at, updated_at +RETURNING id, username, password_hash, created_at, updated_at, parent_user_id ` type CreateUserParams struct { @@ -56,6 +56,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.PasswordHash, &i.CreatedAt, &i.UpdatedAt, + &i.ParentUserID, ) return i, err } @@ -80,7 +81,7 @@ func (q *Queries) GetRefreshToken(ctx context.Context, tokenHash string) (Refres } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1 +SELECT id, username, password_hash, created_at, updated_at, parent_user_id FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { @@ -92,12 +93,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { &i.PasswordHash, &i.CreatedAt, &i.UpdatedAt, + &i.ParentUserID, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1 +SELECT id, username, password_hash, created_at, updated_at, parent_user_id FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -109,6 +111,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.PasswordHash, &i.CreatedAt, &i.UpdatedAt, + &i.ParentUserID, ) return i, err } diff --git a/backend/internal/db/files.sql.go b/backend/internal/db/files.sql.go deleted file mode 100644 index b8f21d1..0000000 --- a/backend/internal/db/files.sql.go +++ /dev/null @@ -1,390 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: files.sql - -package db - -import ( - "context" - "database/sql" - "time" - - "github.com/lib/pq" -) - -const createFile = `-- name: CreateFile :one -INSERT INTO files (name, mime_type, size, storage_key, checksum, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -RETURNING id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder -` - -type CreateFileParams struct { - Name string `json:"name"` - MimeType string `json:"mime_type"` - Size int64 `json:"size"` - StorageKey string `json:"storage_key"` - Checksum string `json:"checksum"` -} - -func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) { - row := q.db.QueryRowContext(ctx, createFile, - arg.Name, - arg.MimeType, - arg.Size, - arg.StorageKey, - arg.Checksum, - ) - var i File - err := row.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ) - return i, err -} - -const createFolder = `-- name: CreateFolder :one -INSERT INTO files (name, is_folder, created_at, updated_at) -VALUES ($1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -RETURNING id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder -` - -func (q *Queries) CreateFolder(ctx context.Context, name string) (File, error) { - row := q.db.QueryRowContext(ctx, createFolder, name) - var i File - err := row.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ) - return i, err -} - -const deleteFile = `-- name: DeleteFile :exec -DELETE FROM files -WHERE id = $1 -` - -func (q *Queries) DeleteFile(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteFile, id) - return err -} - -const findDuplicateByChecksum = `-- name: FindDuplicateByChecksum :one -SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files -WHERE checksum = $1 AND is_folder = false -LIMIT 1 -` - -func (q *Queries) FindDuplicateByChecksum(ctx context.Context, checksum string) (File, error) { - row := q.db.QueryRowContext(ctx, findDuplicateByChecksum, checksum) - var i File - err := row.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ) - return i, err -} - -const findDuplicatesByNameSize = `-- name: FindDuplicatesByNameSize :many -SELECT id, name, mime_type, size, checksum, created_at FROM files -WHERE name = $1 AND size = $2 AND is_folder = false -ORDER BY created_at DESC -` - -type FindDuplicatesByNameSizeParams struct { - Name string `json:"name"` - Size int64 `json:"size"` -} - -type FindDuplicatesByNameSizeRow struct { - ID string `json:"id"` - Name string `json:"name"` - MimeType string `json:"mime_type"` - Size int64 `json:"size"` - Checksum string `json:"checksum"` - CreatedAt time.Time `json:"created_at"` -} - -func (q *Queries) FindDuplicatesByNameSize(ctx context.Context, arg FindDuplicatesByNameSizeParams) ([]FindDuplicatesByNameSizeRow, error) { - rows, err := q.db.QueryContext(ctx, findDuplicatesByNameSize, arg.Name, arg.Size) - if err != nil { - return nil, err - } - defer rows.Close() - var items []FindDuplicatesByNameSizeRow - for rows.Next() { - var i FindDuplicatesByNameSizeRow - if err := rows.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.Checksum, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getFile = `-- name: GetFile :one -SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files -WHERE id = $1 LIMIT 1 -` - -func (q *Queries) GetFile(ctx context.Context, id string) (File, error) { - row := q.db.QueryRowContext(ctx, getFile, id) - var i File - err := row.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ) - return i, err -} - -const listFiles = `-- name: ListFiles :many -SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files -WHERE parent_file_id IS NULL -ORDER BY is_folder DESC, created_at DESC -` - -func (q *Queries) ListFiles(ctx context.Context) ([]File, error) { - rows, err := q.db.QueryContext(ctx, listFiles) - if err != nil { - return nil, err - } - defer rows.Close() - var items []File - for rows.Next() { - var i File - if err := rows.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listFilesByID = `-- name: ListFilesByID :many -SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files -WHERE id = ANY($1::text[]) -ORDER BY created_at DESC -` - -func (q *Queries) ListFilesByID(ctx context.Context, dollar_1 []string) ([]File, error) { - rows, err := q.db.QueryContext(ctx, listFilesByID, pq.Array(dollar_1)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []File - for rows.Next() { - var i File - if err := rows.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listFilesByParentID = `-- name: ListFilesByParentID :many -SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files -WHERE parent_file_id = $1 -ORDER BY is_folder DESC, created_at DESC -` - -func (q *Queries) ListFilesByParentID(ctx context.Context, parentFileID sql.NullString) ([]File, error) { - rows, err := q.db.QueryContext(ctx, listFilesByParentID, parentFileID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []File - for rows.Next() { - var i File - if err := rows.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listFolders = `-- name: ListFolders :many -SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files -WHERE is_folder = true -ORDER BY created_at DESC -` - -func (q *Queries) ListFolders(ctx context.Context) ([]File, error) { - rows, err := q.db.QueryContext(ctx, listFolders) - if err != nil { - return nil, err - } - defer rows.Close() - var items []File - for rows.Next() { - var i File - if err := rows.Scan( - &i.ID, - &i.Name, - &i.MimeType, - &i.Size, - &i.StorageKey, - &i.Checksum, - &i.OcrText, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const moveFiles = `-- name: MoveFiles :exec -UPDATE files -SET parent_file_id = $1, updated_at = CURRENT_TIMESTAMP -WHERE id = ANY($2::text[]) -` - -type MoveFilesParams struct { - ParentFileID sql.NullString `json:"parent_file_id"` - Column2 []string `json:"column_2"` -} - -func (q *Queries) MoveFiles(ctx context.Context, arg MoveFilesParams) error { - _, err := q.db.ExecContext(ctx, moveFiles, arg.ParentFileID, pq.Array(arg.Column2)) - return err -} - -const updateFile = `-- name: UpdateFile :exec -UPDATE files -SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP -WHERE id = $4 -` - -type UpdateFileParams struct { - Name string `json:"name"` - MimeType string `json:"mime_type"` - OcrText string `json:"ocr_text"` - ID string `json:"id"` -} - -func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) error { - _, err := q.db.ExecContext(ctx, updateFile, - arg.Name, - arg.MimeType, - arg.OcrText, - arg.ID, - ) - return err -} diff --git a/backend/internal/db/migrations/012_v3_new_tables.down.sql b/backend/internal/db/migrations/012_v3_new_tables.down.sql new file mode 100644 index 0000000..f810c14 --- /dev/null +++ b/backend/internal/db/migrations/012_v3_new_tables.down.sql @@ -0,0 +1,10 @@ +-- VaultDrop 012 down: drop V3 new tables +DROP FUNCTION IF EXISTS resolve_effective_role; +DROP TABLE IF EXISTS rebac_relations; +DROP TABLE IF EXISTS sync_queue; +DROP TABLE IF EXISTS retention_policies; +DROP TABLE IF EXISTS resource_placements; +DROP TABLE IF EXISTS storage_locations; +DROP TABLE IF EXISTS resource_variants; +DROP TABLE IF EXISTS resources; +ALTER TABLE users DROP COLUMN IF EXISTS parent_user_id; diff --git a/backend/internal/db/migrations/012_v3_new_tables.up.sql b/backend/internal/db/migrations/012_v3_new_tables.up.sql new file mode 100644 index 0000000..14c7a39 --- /dev/null +++ b/backend/internal/db/migrations/012_v3_new_tables.up.sql @@ -0,0 +1,145 @@ +-- VaultDrop 012: V3 new tables - resources, storage_locations, resource_placements, etc. + +-- Add parent_user_id to users for groups/organizations +ALTER TABLE users ADD COLUMN parent_user_id UUID REFERENCES users(id); + +-- Resources (replaces files) +CREATE TABLE resources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + checksum TEXT NOT NULL DEFAULT '', + ocr_text TEXT NOT NULL DEFAULT '', + is_folder BOOLEAN NOT NULL DEFAULT false, + parent_resource_id UUID REFERENCES resources(id), + owner_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_resources_owner ON resources(owner_id); +CREATE INDEX idx_resources_parent ON resources(parent_resource_id); +CREATE INDEX idx_resources_checksum_owner ON resources(checksum, owner_id); + +-- Resource variants (replaces thumbnails) +CREATE TABLE resource_variants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + variant_type TEXT NOT NULL, + page_number INTEGER NOT NULL DEFAULT 1, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + mime_type TEXT NOT NULL DEFAULT 'image/jpeg', + generated_by TEXT NOT NULL DEFAULT 'server' CHECK (generated_by IN ('server', 'client')), + storage_key TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_variants_resource ON resource_variants(resource_id); +CREATE UNIQUE INDEX idx_variants_resource_type_page ON resource_variants(resource_id, variant_type, page_number); + +-- Storage locations (devices, server, backup) +CREATE TABLE storage_locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + device_name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('primary', 'device', 'backup', 'server')), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP +); + +CREATE INDEX idx_locations_user ON storage_locations(user_id); + +-- Resource placements (pivot resource × storage_location) +CREATE TABLE resource_placements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + storage_location_id UUID NOT NULL REFERENCES storage_locations(id), + status TEXT NOT NULL DEFAULT 'synced' CHECK (status IN ('local_only', 'synced', 'cloud_only', 'pending_upload', 'pending_download')), + storage_key TEXT, + synced_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(resource_id, storage_location_id) +); + +CREATE INDEX idx_placements_resource ON resource_placements(resource_id); +CREATE INDEX idx_placements_location ON resource_placements(storage_location_id); +CREATE INDEX idx_placements_status ON resource_placements(status); + +-- Retention policies +CREATE TABLE retention_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + storage_location_id UUID NOT NULL REFERENCES storage_locations(id), + rule_type TEXT NOT NULL, + rule_value JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_policies_user ON retention_policies(user_id); +CREATE INDEX idx_policies_location ON retention_policies(storage_location_id); + +-- Sync queue +CREATE TABLE sync_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + storage_location_id UUID NOT NULL REFERENCES storage_locations(id), + operation TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_queue_status ON sync_queue(status); +CREATE INDEX idx_queue_resource ON sync_queue(resource_id); +CREATE INDEX idx_queue_location ON sync_queue(storage_location_id); + +-- ReBAC relations +CREATE TABLE rebac_relations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + subject_user_id UUID NOT NULL REFERENCES users(id), + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'editor', 'viewer')), + granted_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(resource_id, subject_user_id) +); + +CREATE INDEX idx_rebac_resource ON rebac_relations(resource_id); +CREATE INDEX idx_rebac_subject ON rebac_relations(subject_user_id); + +-- Function to resolve effective role with recursive parent/group traversal +CREATE OR REPLACE FUNCTION resolve_effective_role(p_user_id UUID, p_resource_id UUID) +RETURNS TEXT AS $$ +DECLARE + v_role TEXT; +BEGIN + WITH RECURSIVE rtree AS ( + SELECT r.id, r.parent_resource_id, r.owner_id + FROM resources r + WHERE r.id = p_resource_id + UNION ALL + SELECT r.id, r.parent_resource_id, r.owner_id + FROM resources r + JOIN rtree ON r.id = rtree.parent_resource_id + ) + SELECT CASE + WHEN EXISTS(SELECT 1 FROM rtree WHERE owner_id = p_user_id) THEN 'owner' + ELSE COALESCE( + (SELECT rr.role::text FROM rebac_relations rr + JOIN rtree ON rr.resource_id = rtree.id + WHERE rr.subject_user_id = p_user_id + ORDER BY CASE rr.role + WHEN 'owner' THEN 0 + WHEN 'admin' THEN 1 + WHEN 'editor' THEN 2 + WHEN 'viewer' THEN 3 + END ASC LIMIT 1), + '' + ) + END INTO v_role; + RETURN v_role; +END; +$$ LANGUAGE plpgsql; diff --git a/backend/internal/db/migrations/013_v3_migrate_data.down.sql b/backend/internal/db/migrations/013_v3_migrate_data.down.sql new file mode 100644 index 0000000..6a7820a --- /dev/null +++ b/backend/internal/db/migrations/013_v3_migrate_data.down.sql @@ -0,0 +1,15 @@ +-- VaultDrop 013 down: revert data migration +-- Re-add tag_type to tags +ALTER TABLE tags ADD COLUMN IF NOT EXISTS tag_type TEXT NOT NULL DEFAULT 'none'; + +DROP TABLE IF EXISTS resource_tags; + +DELETE FROM resource_variants; + +DELETE FROM rebac_relations; + +DELETE FROM resource_placements; + +DELETE FROM storage_locations; + +DELETE FROM resources; diff --git a/backend/internal/db/migrations/013_v3_migrate_data.up.sql b/backend/internal/db/migrations/013_v3_migrate_data.up.sql new file mode 100644 index 0000000..b6ba94c --- /dev/null +++ b/backend/internal/db/migrations/013_v3_migrate_data.up.sql @@ -0,0 +1,100 @@ +-- VaultDrop 013: Migrate data from V1 to V3 +-- Assigns all existing files to user 'pixel' + +-- 1. Ensure user 'pixel' exists (create if not) +INSERT INTO users (username, password_hash) +SELECT 'pixel', '$argon2id$v=19$m=65536,t=1,p=4$placeholder$placeholder' +WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'pixel'); + +-- 2. Migrate files -> resources +INSERT INTO resources (id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at) +SELECT + f.id::uuid, + f.name, + f.mime_type, + f.size, + f.checksum, + f.ocr_text, + f.is_folder, + f.parent_file_id::uuid, + (SELECT id FROM users WHERE username = 'pixel'), + f.created_at, + f.updated_at +FROM files f +ON CONFLICT (id) DO NOTHING; + +-- 3. Create server storage_location for user 'pixel' +INSERT INTO storage_locations (id, user_id, device_name, role) +SELECT + gen_random_uuid(), + (SELECT id FROM users WHERE username = 'pixel'), + 'VaultDrop Server', + 'server' +WHERE NOT EXISTS ( + SELECT 1 FROM storage_locations + WHERE user_id = (SELECT id FROM users WHERE username = 'pixel') + AND role = 'server' +); + +-- 4. Create resource_placements for migrated resources +INSERT INTO resource_placements (resource_id, storage_location_id, status, storage_key, synced_at) +SELECT + r.id, + sl.id, + 'synced', + f.storage_key, + CURRENT_TIMESTAMP +FROM resources r +JOIN files f ON f.id::uuid = r.id +CROSS JOIN storage_locations sl +WHERE sl.user_id = (SELECT id FROM users WHERE username = 'pixel') +AND sl.role = 'server' +ON CONFLICT (resource_id, storage_location_id) DO NOTHING; + +-- 5. Create rebac_relations (owner) for all migrated resources +INSERT INTO rebac_relations (resource_id, subject_user_id, role, granted_by) +SELECT + r.id, + u.id, + 'owner', + u.id +FROM resources r +CROSS JOIN (SELECT id FROM users WHERE username = 'pixel') u +ON CONFLICT (resource_id, subject_user_id) DO NOTHING; + +-- 6. Migrate thumbnails -> resource_variants +INSERT INTO resource_variants (id, resource_id, variant_type, page_number, width, height, mime_type, generated_by, storage_key, created_at) +SELECT + t.id::uuid, + t.file_id::uuid, + CASE + WHEN t.resolution_label = 'thumbnail' THEN 'thumbnail_small' + WHEN t.resolution_label = 'full' THEN 'thumbnail_full' + ELSE t.resolution_label + END, + t.page_number, + t.width, + t.height, + t.mime_type, + 'server', + t.storage_key, + t.created_at +FROM thumbnails t +ON CONFLICT (id) DO NOTHING; + +-- 7. Create resource_tags from file_tags (keep old file_tags for now) +CREATE TABLE IF NOT EXISTS resource_tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tag_id TEXT, + resource_id TEXT +); + +INSERT INTO resource_tags (id, tag_id, resource_id) +SELECT + gen_random_uuid(), + ft.tag_id, + ft.file_id +FROM file_tags ft; + +-- 8. Remove tag_type column from tags +ALTER TABLE tags DROP COLUMN IF EXISTS tag_type; diff --git a/backend/internal/db/migrations/014_v3_cleanup.down.sql b/backend/internal/db/migrations/014_v3_cleanup.down.sql new file mode 100644 index 0000000..892a087 --- /dev/null +++ b/backend/internal/db/migrations/014_v3_cleanup.down.sql @@ -0,0 +1,33 @@ +-- VaultDrop 014 down: restore legacy tables + +CREATE TABLE files ( + id TEXT PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + storage_key TEXT NOT NULL DEFAULT '', + checksum TEXT NOT NULL DEFAULT '', + ocr_text TEXT NOT NULL DEFAULT '', + is_folder BOOLEAN NOT NULL DEFAULT false, + parent_file_id TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE thumbnails ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, + page_number INTEGER NOT NULL, + resolution_label TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + storage_key TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'image/jpeg', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE file_tags ( + id TEXT PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + tag_id TEXT, + file_id TEXT +); diff --git a/backend/internal/db/migrations/014_v3_cleanup.up.sql b/backend/internal/db/migrations/014_v3_cleanup.up.sql new file mode 100644 index 0000000..54215a4 --- /dev/null +++ b/backend/internal/db/migrations/014_v3_cleanup.up.sql @@ -0,0 +1,10 @@ +-- VaultDrop 014: Drop legacy V1 tables + +-- Tags now use resource_tags instead of file_tags +DROP TABLE IF EXISTS file_tags; + +-- Thumbnails migrated to resource_variants +DROP TABLE IF EXISTS thumbnails; + +-- Files migrated to resources +DROP TABLE IF EXISTS files; diff --git a/backend/internal/db/models.go b/backend/internal/db/models.go index 53ad078..3b8880d 100644 --- a/backend/internal/db/models.go +++ b/backend/internal/db/models.go @@ -6,27 +6,19 @@ package db import ( "database/sql" + "encoding/json" "time" + + "github.com/google/uuid" ) -type File struct { - ID string `json:"id"` - Name string `json:"name"` - MimeType string `json:"mime_type"` - Size int64 `json:"size"` - StorageKey string `json:"storage_key"` - Checksum string `json:"checksum"` - OcrText string `json:"ocr_text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ParentFileID sql.NullString `json:"parent_file_id"` - IsFolder bool `json:"is_folder"` -} - -type FileTag struct { - ID string `json:"id"` - TagID sql.NullString `json:"tag_id"` - FileID sql.NullString `json:"file_id"` +type RebacRelation struct { + ID uuid.UUID `json:"id"` + ResourceID uuid.UUID `json:"resource_id"` + SubjectUserID uuid.UUID `json:"subject_user_id"` + Role string `json:"role"` + GrantedBy uuid.UUID `json:"granted_by"` + CreatedAt time.Time `json:"created_at"` } type RefreshToken struct { @@ -38,31 +30,91 @@ type RefreshToken struct { CreatedAt time.Time `json:"created_at"` } +type Resource struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + OcrText string `json:"ocr_text"` + IsFolder bool `json:"is_folder"` + ParentResourceID uuid.NullUUID `json:"parent_resource_id"` + OwnerID uuid.UUID `json:"owner_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ResourcePlacement struct { + ID uuid.UUID `json:"id"` + ResourceID uuid.UUID `json:"resource_id"` + StorageLocationID uuid.UUID `json:"storage_location_id"` + Status string `json:"status"` + StorageKey sql.NullString `json:"storage_key"` + SyncedAt sql.NullTime `json:"synced_at"` + CreatedAt time.Time `json:"created_at"` +} + +type ResourceTag struct { + ID uuid.UUID `json:"id"` + TagID sql.NullString `json:"tag_id"` + ResourceID sql.NullString `json:"resource_id"` +} + +type ResourceVariant struct { + ID uuid.UUID `json:"id"` + ResourceID uuid.UUID `json:"resource_id"` + VariantType string `json:"variant_type"` + PageNumber int32 `json:"page_number"` + Width int32 `json:"width"` + Height int32 `json:"height"` + MimeType string `json:"mime_type"` + GeneratedBy string `json:"generated_by"` + StorageKey string `json:"storage_key"` + CreatedAt time.Time `json:"created_at"` +} + +type RetentionPolicy struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + StorageLocationID uuid.UUID `json:"storage_location_id"` + RuleType string `json:"rule_type"` + RuleValue json.RawMessage `json:"rule_value"` + CreatedAt time.Time `json:"created_at"` +} + +type StorageLocation struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + DeviceName string `json:"device_name"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt sql.NullTime `json:"last_seen_at"` +} + +type SyncQueue struct { + ID uuid.UUID `json:"id"` + ResourceID uuid.UUID `json:"resource_id"` + StorageLocationID uuid.UUID `json:"storage_location_id"` + Operation string `json:"operation"` + Status string `json:"status"` + Attempts int32 `json:"attempts"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Tag struct { ID string `json:"id"` ParentTagID sql.NullString `json:"parent_tag_id"` TagName string `json:"tag_name"` - TagType string `json:"tag_type"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -type Thumbnail struct { - ID string `json:"id"` - FileID string `json:"file_id"` - PageNumber int32 `json:"page_number"` - ResolutionLabel string `json:"resolution_label"` - Width int32 `json:"width"` - Height int32 `json:"height"` - StorageKey string `json:"storage_key"` - MimeType string `json:"mime_type"` - CreatedAt time.Time `json:"created_at"` -} - type User struct { - ID string `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"password_hash"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ParentUserID uuid.NullUUID `json:"parent_user_id"` } diff --git a/backend/internal/db/queries/files.sql b/backend/internal/db/queries/files.sql deleted file mode 100644 index da0b409..0000000 --- a/backend/internal/db/queries/files.sql +++ /dev/null @@ -1,57 +0,0 @@ --- name: GetFile :one -SELECT * FROM files -WHERE id = $1 LIMIT 1; - --- name: ListFiles :many -SELECT * FROM files -WHERE parent_file_id IS NULL -ORDER BY is_folder DESC, created_at DESC; - --- name: ListFolders :many -SELECT * FROM files -WHERE is_folder = true -ORDER BY created_at DESC; - --- name: ListFilesByID :many -SELECT * FROM files -WHERE id = ANY($1::text[]) -ORDER BY created_at DESC; - --- name: CreateFile :one -INSERT INTO files (name, mime_type, size, storage_key, checksum, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -RETURNING *; - --- name: CreateFolder :one -INSERT INTO files (name, is_folder, created_at, updated_at) -VALUES ($1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -RETURNING *; - --- name: ListFilesByParentID :many -SELECT * FROM files -WHERE parent_file_id = $1 -ORDER BY is_folder DESC, created_at DESC; - --- name: MoveFiles :exec -UPDATE files -SET parent_file_id = $1, updated_at = CURRENT_TIMESTAMP -WHERE id = ANY($2::text[]); - --- name: UpdateFile :exec -UPDATE files -SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP -WHERE id = $4; - --- name: DeleteFile :exec -DELETE FROM files -WHERE id = $1; - --- name: FindDuplicatesByNameSize :many -SELECT id, name, mime_type, size, checksum, created_at FROM files -WHERE name = $1 AND size = $2 AND is_folder = false -ORDER BY created_at DESC; - --- name: FindDuplicateByChecksum :one -SELECT * FROM files -WHERE checksum = $1 AND is_folder = false -LIMIT 1; diff --git a/backend/internal/db/queries/rebac_relations.sql b/backend/internal/db/queries/rebac_relations.sql new file mode 100644 index 0000000..6616f70 --- /dev/null +++ b/backend/internal/db/queries/rebac_relations.sql @@ -0,0 +1,36 @@ +-- name: CreateRebacRelation :one +INSERT INTO rebac_relations (resource_id, subject_user_id, role, granted_by) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetRebacRelation :one +SELECT * FROM rebac_relations +WHERE resource_id = $1 AND subject_user_id = $2 +LIMIT 1; + +-- name: ListRebacRelationsByResource :many +SELECT * FROM rebac_relations +WHERE resource_id = $1 +ORDER BY created_at ASC; + +-- name: ListRebacRelationsBySubject :many +SELECT * FROM rebac_relations +WHERE subject_user_id = $1 +ORDER BY created_at ASC; + +-- name: DeleteRebacRelation :exec +DELETE FROM rebac_relations +WHERE resource_id = $1 AND subject_user_id = $2; + +-- name: DeleteRebacRelationsByResource :exec +DELETE FROM rebac_relations +WHERE resource_id = $1; + +-- name: HasRebacRelation :one +SELECT EXISTS( + SELECT 1 FROM rebac_relations + WHERE resource_id = $1 AND subject_user_id = $2 AND role = $3 +); + +-- name: ResolveEffectiveRole :one +SELECT resolve_effective_role($1, $2) AS role; diff --git a/backend/internal/db/queries/resource_placements.sql b/backend/internal/db/queries/resource_placements.sql new file mode 100644 index 0000000..d4e5799 --- /dev/null +++ b/backend/internal/db/queries/resource_placements.sql @@ -0,0 +1,32 @@ +-- name: CreatePlacement :one +INSERT INTO resource_placements (resource_id, storage_location_id, status, storage_key, synced_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: GetPlacement :one +SELECT * FROM resource_placements +WHERE resource_id = $1 AND storage_location_id = $2 +LIMIT 1; + +-- name: ListPlacementsByResource :many +SELECT * FROM resource_placements +WHERE resource_id = $1; + +-- name: ListPlacementsByLocation :many +SELECT * FROM resource_placements +WHERE storage_location_id = $1; + +-- name: UpdatePlacementStatus :exec +UPDATE resource_placements +SET status = $1, synced_at = CURRENT_TIMESTAMP +WHERE id = $2; + +-- name: DeletePlacement :exec +DELETE FROM resource_placements +WHERE id = $1; + +-- name: GetServerPlacementByResource :one +SELECT rp.* FROM resource_placements rp +JOIN storage_locations sl ON sl.id = rp.storage_location_id +WHERE rp.resource_id = $1 AND sl.role = 'server' +LIMIT 1; diff --git a/backend/internal/db/queries/resource_variants.sql b/backend/internal/db/queries/resource_variants.sql new file mode 100644 index 0000000..2b4d4f6 --- /dev/null +++ b/backend/internal/db/queries/resource_variants.sql @@ -0,0 +1,21 @@ +-- name: CreateResourceVariant :one +INSERT INTO resource_variants (resource_id, variant_type, page_number, width, height, mime_type, generated_by, storage_key) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: GetVariantsByResourceID :many +SELECT * FROM resource_variants +WHERE resource_id = $1 +ORDER BY page_number ASC, variant_type ASC; + +-- name: GetVariantByID :one +SELECT * FROM resource_variants +WHERE id = $1 LIMIT 1; + +-- name: DeleteVariantsByResourceID :exec +DELETE FROM resource_variants WHERE resource_id = $1; + +-- name: GetBestVariant :one +SELECT * FROM resource_variants +WHERE resource_id = $1 AND variant_type = $2 AND page_number = 1 +LIMIT 1; diff --git a/backend/internal/db/queries/resources.sql b/backend/internal/db/queries/resources.sql new file mode 100644 index 0000000..66e4971 --- /dev/null +++ b/backend/internal/db/queries/resources.sql @@ -0,0 +1,67 @@ +-- name: GetResource :one +SELECT * FROM resources +WHERE id = $1 LIMIT 1; + +-- name: ListResources :many +SELECT * FROM resources +WHERE parent_resource_id IS NULL +ORDER BY is_folder DESC, created_at DESC; + +-- name: ListFolders :many +SELECT * FROM resources +WHERE is_folder = true +ORDER BY created_at DESC; + +-- name: ListResourcesByID :many +SELECT * FROM resources +WHERE id = ANY($1::uuid[]) +ORDER BY created_at DESC; + +-- name: CreateResource :one +INSERT INTO resources (name, mime_type, size, checksum, owner_id, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING *; + +-- name: CreateFolder :one +INSERT INTO resources (name, is_folder, owner_id, created_at, updated_at) +VALUES ($1, true, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING *; + +-- name: ListResourcesByParentID :many +SELECT * FROM resources +WHERE parent_resource_id = $1 +ORDER BY is_folder DESC, created_at DESC; + +-- name: MoveResources :exec +UPDATE resources +SET parent_resource_id = $1, updated_at = CURRENT_TIMESTAMP +WHERE id = ANY($2::uuid[]); + +-- name: UpdateResource :exec +UPDATE resources +SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP +WHERE id = $4; + +-- name: DeleteResource :exec +DELETE FROM resources +WHERE id = $1; + +-- name: FindDuplicatesByNameSize :many +SELECT id, name, mime_type, size, checksum, created_at FROM resources +WHERE name = $1 AND size = $2 AND is_folder = false +ORDER BY created_at DESC; + +-- name: FindDuplicateByChecksum :one +SELECT * FROM resources +WHERE checksum = $1 AND is_folder = false AND owner_id = $2 +LIMIT 1; + +-- name: ListResourcesByOwner :many +SELECT * FROM resources +WHERE owner_id = $1 AND parent_resource_id IS NULL +ORDER BY created_at DESC; + +-- name: ListResourcesByParentAndOwner :many +SELECT * FROM resources +WHERE parent_resource_id = $1 AND owner_id = $2 +ORDER BY is_folder DESC, created_at DESC; diff --git a/backend/internal/db/queries/retention_policies.sql b/backend/internal/db/queries/retention_policies.sql new file mode 100644 index 0000000..2963483 --- /dev/null +++ b/backend/internal/db/queries/retention_policies.sql @@ -0,0 +1,20 @@ +-- name: CreateRetentionPolicy :one +INSERT INTO retention_policies (user_id, storage_location_id, rule_type, rule_value) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetRetentionPolicy :one +SELECT * FROM retention_policies +WHERE id = $1; + +-- name: ListRetentionPoliciesByUser :many +SELECT * FROM retention_policies +WHERE user_id = $1; + +-- name: ListRetentionPoliciesByLocation :many +SELECT * FROM retention_policies +WHERE storage_location_id = $1; + +-- name: DeleteRetentionPolicy :exec +DELETE FROM retention_policies +WHERE id = $1; diff --git a/backend/internal/db/queries/storage_locations.sql b/backend/internal/db/queries/storage_locations.sql new file mode 100644 index 0000000..bbfc378 --- /dev/null +++ b/backend/internal/db/queries/storage_locations.sql @@ -0,0 +1,27 @@ +-- name: CreateStorageLocation :one +INSERT INTO storage_locations (user_id, device_name, role) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetStorageLocation :one +SELECT * FROM storage_locations +WHERE id = $1; + +-- name: ListStorageLocationsByUser :many +SELECT * FROM storage_locations +WHERE user_id = $1 +ORDER BY created_at ASC; + +-- name: GetServerStorageLocation :one +SELECT * FROM storage_locations +WHERE user_id = $1 AND role = 'server' +LIMIT 1; + +-- name: UpdateStorageLocationLastSeen :exec +UPDATE storage_locations +SET last_seen_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: DeleteStorageLocation :exec +DELETE FROM storage_locations +WHERE id = $1; diff --git a/backend/internal/db/queries/sync_queue.sql b/backend/internal/db/queries/sync_queue.sql new file mode 100644 index 0000000..7a22011 --- /dev/null +++ b/backend/internal/db/queries/sync_queue.sql @@ -0,0 +1,27 @@ +-- name: CreateSyncQueueItem :one +INSERT INTO sync_queue (resource_id, storage_location_id, operation, status, attempts) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: GetSyncQueueItem :one +SELECT * FROM sync_queue +WHERE id = $1; + +-- name: ListPendingSyncItems :many +SELECT * FROM sync_queue +WHERE status = 'pending' +ORDER BY created_at ASC; + +-- name: ListPendingSyncItemsByLocation :many +SELECT * FROM sync_queue +WHERE storage_location_id = $1 AND status = 'pending' +ORDER BY created_at ASC; + +-- name: UpdateSyncQueueStatus :exec +UPDATE sync_queue +SET status = $1, attempts = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $3; + +-- name: DeleteSyncQueueItem :exec +DELETE FROM sync_queue +WHERE id = $1; diff --git a/backend/internal/db/queries/tags.sql b/backend/internal/db/queries/tags.sql index a85a9dc..2d1f2cf 100644 --- a/backend/internal/db/queries/tags.sql +++ b/backend/internal/db/queries/tags.sql @@ -1,6 +1,6 @@ -- name: CreateTag :one -INSERT INTO tags (tag_name, tag_type) -VALUES ($1, $2) +INSERT INTO tags (tag_name) +VALUES ($1) RETURNING *; -- name: GetTag :one @@ -19,23 +19,23 @@ ORDER BY tag_name ASC; DELETE FROM tags WHERE id = $1; --- name: AddTagToFile :exec -INSERT INTO file_tags (tag_id, file_id) +-- name: AddTagToResource :exec +INSERT INTO resource_tags (tag_id, resource_id) VALUES ($1, $2) ON CONFLICT DO NOTHING; --- name: RemoveTagFromFile :exec -DELETE FROM file_tags -WHERE tag_id = $1 AND file_id = $2; +-- name: RemoveTagFromResource :exec +DELETE FROM resource_tags +WHERE tag_id = $1 AND resource_id = $2; --- name: GetTagsByFileID :many +-- name: GetTagsByResourceID :many SELECT t.* FROM tags t -JOIN file_tags ft ON t.id = ft.tag_id -WHERE ft.file_id = $1 +JOIN resource_tags rt ON t.id = rt.tag_id +WHERE rt.resource_id = $1 ORDER BY t.tag_name ASC; --- name: GetFilesByTagID :many -SELECT f.* FROM files f -JOIN file_tags ft ON f.id = ft.file_id -WHERE ft.tag_id = $1 -ORDER BY f.created_at DESC; +-- name: GetResourcesByTagID :many +SELECT r.* FROM resources r +JOIN resource_tags rt ON r.id = rt.resource_id +WHERE rt.tag_id = $1 +ORDER BY r.created_at DESC; diff --git a/backend/internal/db/queries/thumbnails.sql b/backend/internal/db/queries/thumbnails.sql deleted file mode 100644 index fe7c9e2..0000000 --- a/backend/internal/db/queries/thumbnails.sql +++ /dev/null @@ -1,21 +0,0 @@ --- name: CreateThumbnail :one -INSERT INTO thumbnails (file_id, page_number, resolution_label, width, height, storage_key, mime_type) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING *; - --- name: GetThumbnailsByFileID :many -SELECT * FROM thumbnails -WHERE file_id = $1 -ORDER BY page_number ASC, resolution_label ASC; - --- name: GetThumbnailByID :one -SELECT * FROM thumbnails -WHERE id = $1 LIMIT 1; - --- name: DeleteThumbnailsByFileID :exec -DELETE FROM thumbnails WHERE file_id = $1; - --- name: GetThumbnailByFilePageResolution :one -SELECT * FROM thumbnails -WHERE file_id = $1 AND page_number = $2 AND resolution_label = $3 -LIMIT 1; diff --git a/backend/internal/db/rebac_relations.sql.go b/backend/internal/db/rebac_relations.sql.go new file mode 100644 index 0000000..9230cc2 --- /dev/null +++ b/backend/internal/db/rebac_relations.sql.go @@ -0,0 +1,202 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: rebac_relations.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createRebacRelation = `-- name: CreateRebacRelation :one +INSERT INTO rebac_relations (resource_id, subject_user_id, role, granted_by) +VALUES ($1, $2, $3, $4) +RETURNING id, resource_id, subject_user_id, role, granted_by, created_at +` + +type CreateRebacRelationParams struct { + ResourceID uuid.UUID `json:"resource_id"` + SubjectUserID uuid.UUID `json:"subject_user_id"` + Role string `json:"role"` + GrantedBy uuid.UUID `json:"granted_by"` +} + +func (q *Queries) CreateRebacRelation(ctx context.Context, arg CreateRebacRelationParams) (RebacRelation, error) { + row := q.db.QueryRowContext(ctx, createRebacRelation, + arg.ResourceID, + arg.SubjectUserID, + arg.Role, + arg.GrantedBy, + ) + var i RebacRelation + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.SubjectUserID, + &i.Role, + &i.GrantedBy, + &i.CreatedAt, + ) + return i, err +} + +const deleteRebacRelation = `-- name: DeleteRebacRelation :exec +DELETE FROM rebac_relations +WHERE resource_id = $1 AND subject_user_id = $2 +` + +type DeleteRebacRelationParams struct { + ResourceID uuid.UUID `json:"resource_id"` + SubjectUserID uuid.UUID `json:"subject_user_id"` +} + +func (q *Queries) DeleteRebacRelation(ctx context.Context, arg DeleteRebacRelationParams) error { + _, err := q.db.ExecContext(ctx, deleteRebacRelation, arg.ResourceID, arg.SubjectUserID) + return err +} + +const deleteRebacRelationsByResource = `-- name: DeleteRebacRelationsByResource :exec +DELETE FROM rebac_relations +WHERE resource_id = $1 +` + +func (q *Queries) DeleteRebacRelationsByResource(ctx context.Context, resourceID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteRebacRelationsByResource, resourceID) + return err +} + +const getRebacRelation = `-- name: GetRebacRelation :one +SELECT id, resource_id, subject_user_id, role, granted_by, created_at FROM rebac_relations +WHERE resource_id = $1 AND subject_user_id = $2 +LIMIT 1 +` + +type GetRebacRelationParams struct { + ResourceID uuid.UUID `json:"resource_id"` + SubjectUserID uuid.UUID `json:"subject_user_id"` +} + +func (q *Queries) GetRebacRelation(ctx context.Context, arg GetRebacRelationParams) (RebacRelation, error) { + row := q.db.QueryRowContext(ctx, getRebacRelation, arg.ResourceID, arg.SubjectUserID) + var i RebacRelation + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.SubjectUserID, + &i.Role, + &i.GrantedBy, + &i.CreatedAt, + ) + return i, err +} + +const hasRebacRelation = `-- name: HasRebacRelation :one +SELECT EXISTS( + SELECT 1 FROM rebac_relations + WHERE resource_id = $1 AND subject_user_id = $2 AND role = $3 +) +` + +type HasRebacRelationParams struct { + ResourceID uuid.UUID `json:"resource_id"` + SubjectUserID uuid.UUID `json:"subject_user_id"` + Role string `json:"role"` +} + +func (q *Queries) HasRebacRelation(ctx context.Context, arg HasRebacRelationParams) (bool, error) { + row := q.db.QueryRowContext(ctx, hasRebacRelation, arg.ResourceID, arg.SubjectUserID, arg.Role) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const listRebacRelationsByResource = `-- name: ListRebacRelationsByResource :many +SELECT id, resource_id, subject_user_id, role, granted_by, created_at FROM rebac_relations +WHERE resource_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListRebacRelationsByResource(ctx context.Context, resourceID uuid.UUID) ([]RebacRelation, error) { + rows, err := q.db.QueryContext(ctx, listRebacRelationsByResource, resourceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RebacRelation + for rows.Next() { + var i RebacRelation + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.SubjectUserID, + &i.Role, + &i.GrantedBy, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRebacRelationsBySubject = `-- name: ListRebacRelationsBySubject :many +SELECT id, resource_id, subject_user_id, role, granted_by, created_at FROM rebac_relations +WHERE subject_user_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListRebacRelationsBySubject(ctx context.Context, subjectUserID uuid.UUID) ([]RebacRelation, error) { + rows, err := q.db.QueryContext(ctx, listRebacRelationsBySubject, subjectUserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RebacRelation + for rows.Next() { + var i RebacRelation + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.SubjectUserID, + &i.Role, + &i.GrantedBy, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resolveEffectiveRole = `-- name: ResolveEffectiveRole :one +SELECT resolve_effective_role($1, $2) AS role +` + +type ResolveEffectiveRoleParams struct { + PUserID uuid.UUID `json:"p_user_id"` + PResourceID uuid.UUID `json:"p_resource_id"` +} + +func (q *Queries) ResolveEffectiveRole(ctx context.Context, arg ResolveEffectiveRoleParams) (string, error) { + row := q.db.QueryRowContext(ctx, resolveEffectiveRole, arg.PUserID, arg.PResourceID) + var role string + err := row.Scan(&role) + return role, err +} diff --git a/backend/internal/db/resource_placements.sql.go b/backend/internal/db/resource_placements.sql.go new file mode 100644 index 0000000..f484207 --- /dev/null +++ b/backend/internal/db/resource_placements.sql.go @@ -0,0 +1,194 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: resource_placements.sql + +package db + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +const createPlacement = `-- name: CreatePlacement :one +INSERT INTO resource_placements (resource_id, storage_location_id, status, storage_key, synced_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, resource_id, storage_location_id, status, storage_key, synced_at, created_at +` + +type CreatePlacementParams struct { + ResourceID uuid.UUID `json:"resource_id"` + StorageLocationID uuid.UUID `json:"storage_location_id"` + Status string `json:"status"` + StorageKey sql.NullString `json:"storage_key"` + SyncedAt sql.NullTime `json:"synced_at"` +} + +func (q *Queries) CreatePlacement(ctx context.Context, arg CreatePlacementParams) (ResourcePlacement, error) { + row := q.db.QueryRowContext(ctx, createPlacement, + arg.ResourceID, + arg.StorageLocationID, + arg.Status, + arg.StorageKey, + arg.SyncedAt, + ) + var i ResourcePlacement + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Status, + &i.StorageKey, + &i.SyncedAt, + &i.CreatedAt, + ) + return i, err +} + +const deletePlacement = `-- name: DeletePlacement :exec +DELETE FROM resource_placements +WHERE id = $1 +` + +func (q *Queries) DeletePlacement(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deletePlacement, id) + return err +} + +const getPlacement = `-- name: GetPlacement :one +SELECT id, resource_id, storage_location_id, status, storage_key, synced_at, created_at FROM resource_placements +WHERE resource_id = $1 AND storage_location_id = $2 +LIMIT 1 +` + +type GetPlacementParams struct { + ResourceID uuid.UUID `json:"resource_id"` + StorageLocationID uuid.UUID `json:"storage_location_id"` +} + +func (q *Queries) GetPlacement(ctx context.Context, arg GetPlacementParams) (ResourcePlacement, error) { + row := q.db.QueryRowContext(ctx, getPlacement, arg.ResourceID, arg.StorageLocationID) + var i ResourcePlacement + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Status, + &i.StorageKey, + &i.SyncedAt, + &i.CreatedAt, + ) + return i, err +} + +const getServerPlacementByResource = `-- name: GetServerPlacementByResource :one +SELECT rp.id, rp.resource_id, rp.storage_location_id, rp.status, rp.storage_key, rp.synced_at, rp.created_at FROM resource_placements rp +JOIN storage_locations sl ON sl.id = rp.storage_location_id +WHERE rp.resource_id = $1 AND sl.role = 'server' +LIMIT 1 +` + +func (q *Queries) GetServerPlacementByResource(ctx context.Context, resourceID uuid.UUID) (ResourcePlacement, error) { + row := q.db.QueryRowContext(ctx, getServerPlacementByResource, resourceID) + var i ResourcePlacement + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Status, + &i.StorageKey, + &i.SyncedAt, + &i.CreatedAt, + ) + return i, err +} + +const listPlacementsByLocation = `-- name: ListPlacementsByLocation :many +SELECT id, resource_id, storage_location_id, status, storage_key, synced_at, created_at FROM resource_placements +WHERE storage_location_id = $1 +` + +func (q *Queries) ListPlacementsByLocation(ctx context.Context, storageLocationID uuid.UUID) ([]ResourcePlacement, error) { + rows, err := q.db.QueryContext(ctx, listPlacementsByLocation, storageLocationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ResourcePlacement + for rows.Next() { + var i ResourcePlacement + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Status, + &i.StorageKey, + &i.SyncedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlacementsByResource = `-- name: ListPlacementsByResource :many +SELECT id, resource_id, storage_location_id, status, storage_key, synced_at, created_at FROM resource_placements +WHERE resource_id = $1 +` + +func (q *Queries) ListPlacementsByResource(ctx context.Context, resourceID uuid.UUID) ([]ResourcePlacement, error) { + rows, err := q.db.QueryContext(ctx, listPlacementsByResource, resourceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ResourcePlacement + for rows.Next() { + var i ResourcePlacement + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Status, + &i.StorageKey, + &i.SyncedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePlacementStatus = `-- name: UpdatePlacementStatus :exec +UPDATE resource_placements +SET status = $1, synced_at = CURRENT_TIMESTAMP +WHERE id = $2 +` + +type UpdatePlacementStatusParams struct { + Status string `json:"status"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdatePlacementStatus(ctx context.Context, arg UpdatePlacementStatusParams) error { + _, err := q.db.ExecContext(ctx, updatePlacementStatus, arg.Status, arg.ID) + return err +} diff --git a/backend/internal/db/resource_variants.sql.go b/backend/internal/db/resource_variants.sql.go new file mode 100644 index 0000000..317ae28 --- /dev/null +++ b/backend/internal/db/resource_variants.sql.go @@ -0,0 +1,157 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: resource_variants.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createResourceVariant = `-- name: CreateResourceVariant :one +INSERT INTO resource_variants (resource_id, variant_type, page_number, width, height, mime_type, generated_by, storage_key) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, resource_id, variant_type, page_number, width, height, mime_type, generated_by, storage_key, created_at +` + +type CreateResourceVariantParams struct { + ResourceID uuid.UUID `json:"resource_id"` + VariantType string `json:"variant_type"` + PageNumber int32 `json:"page_number"` + Width int32 `json:"width"` + Height int32 `json:"height"` + MimeType string `json:"mime_type"` + GeneratedBy string `json:"generated_by"` + StorageKey string `json:"storage_key"` +} + +func (q *Queries) CreateResourceVariant(ctx context.Context, arg CreateResourceVariantParams) (ResourceVariant, error) { + row := q.db.QueryRowContext(ctx, createResourceVariant, + arg.ResourceID, + arg.VariantType, + arg.PageNumber, + arg.Width, + arg.Height, + arg.MimeType, + arg.GeneratedBy, + arg.StorageKey, + ) + var i ResourceVariant + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.VariantType, + &i.PageNumber, + &i.Width, + &i.Height, + &i.MimeType, + &i.GeneratedBy, + &i.StorageKey, + &i.CreatedAt, + ) + return i, err +} + +const deleteVariantsByResourceID = `-- name: DeleteVariantsByResourceID :exec +DELETE FROM resource_variants WHERE resource_id = $1 +` + +func (q *Queries) DeleteVariantsByResourceID(ctx context.Context, resourceID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteVariantsByResourceID, resourceID) + return err +} + +const getBestVariant = `-- name: GetBestVariant :one +SELECT id, resource_id, variant_type, page_number, width, height, mime_type, generated_by, storage_key, created_at FROM resource_variants +WHERE resource_id = $1 AND variant_type = $2 AND page_number = 1 +LIMIT 1 +` + +type GetBestVariantParams struct { + ResourceID uuid.UUID `json:"resource_id"` + VariantType string `json:"variant_type"` +} + +func (q *Queries) GetBestVariant(ctx context.Context, arg GetBestVariantParams) (ResourceVariant, error) { + row := q.db.QueryRowContext(ctx, getBestVariant, arg.ResourceID, arg.VariantType) + var i ResourceVariant + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.VariantType, + &i.PageNumber, + &i.Width, + &i.Height, + &i.MimeType, + &i.GeneratedBy, + &i.StorageKey, + &i.CreatedAt, + ) + return i, err +} + +const getVariantByID = `-- name: GetVariantByID :one +SELECT id, resource_id, variant_type, page_number, width, height, mime_type, generated_by, storage_key, created_at FROM resource_variants +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetVariantByID(ctx context.Context, id uuid.UUID) (ResourceVariant, error) { + row := q.db.QueryRowContext(ctx, getVariantByID, id) + var i ResourceVariant + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.VariantType, + &i.PageNumber, + &i.Width, + &i.Height, + &i.MimeType, + &i.GeneratedBy, + &i.StorageKey, + &i.CreatedAt, + ) + return i, err +} + +const getVariantsByResourceID = `-- name: GetVariantsByResourceID :many +SELECT id, resource_id, variant_type, page_number, width, height, mime_type, generated_by, storage_key, created_at FROM resource_variants +WHERE resource_id = $1 +ORDER BY page_number ASC, variant_type ASC +` + +func (q *Queries) GetVariantsByResourceID(ctx context.Context, resourceID uuid.UUID) ([]ResourceVariant, error) { + rows, err := q.db.QueryContext(ctx, getVariantsByResourceID, resourceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ResourceVariant + for rows.Next() { + var i ResourceVariant + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.VariantType, + &i.PageNumber, + &i.Width, + &i.Height, + &i.MimeType, + &i.GeneratedBy, + &i.StorageKey, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/db/resources.sql.go b/backend/internal/db/resources.sql.go new file mode 100644 index 0000000..9428403 --- /dev/null +++ b/backend/internal/db/resources.sql.go @@ -0,0 +1,487 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: resources.sql + +package db + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" +) + +const createFolder = `-- name: CreateFolder :one +INSERT INTO resources (name, is_folder, owner_id, created_at, updated_at) +VALUES ($1, true, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at +` + +type CreateFolderParams struct { + Name string `json:"name"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) CreateFolder(ctx context.Context, arg CreateFolderParams) (Resource, error) { + row := q.db.QueryRowContext(ctx, createFolder, arg.Name, arg.OwnerID) + var i Resource + err := row.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createResource = `-- name: CreateResource :one +INSERT INTO resources (name, mime_type, size, checksum, owner_id, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at +` + +type CreateResourceParams struct { + Name string `json:"name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) CreateResource(ctx context.Context, arg CreateResourceParams) (Resource, error) { + row := q.db.QueryRowContext(ctx, createResource, + arg.Name, + arg.MimeType, + arg.Size, + arg.Checksum, + arg.OwnerID, + ) + var i Resource + err := row.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteResource = `-- name: DeleteResource :exec +DELETE FROM resources +WHERE id = $1 +` + +func (q *Queries) DeleteResource(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteResource, id) + return err +} + +const findDuplicateByChecksum = `-- name: FindDuplicateByChecksum :one +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE checksum = $1 AND is_folder = false AND owner_id = $2 +LIMIT 1 +` + +type FindDuplicateByChecksumParams struct { + Checksum string `json:"checksum"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) FindDuplicateByChecksum(ctx context.Context, arg FindDuplicateByChecksumParams) (Resource, error) { + row := q.db.QueryRowContext(ctx, findDuplicateByChecksum, arg.Checksum, arg.OwnerID) + var i Resource + err := row.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const findDuplicatesByNameSize = `-- name: FindDuplicatesByNameSize :many +SELECT id, name, mime_type, size, checksum, created_at FROM resources +WHERE name = $1 AND size = $2 AND is_folder = false +ORDER BY created_at DESC +` + +type FindDuplicatesByNameSizeParams struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +type FindDuplicatesByNameSizeRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + CreatedAt time.Time `json:"created_at"` +} + +func (q *Queries) FindDuplicatesByNameSize(ctx context.Context, arg FindDuplicatesByNameSizeParams) ([]FindDuplicatesByNameSizeRow, error) { + rows, err := q.db.QueryContext(ctx, findDuplicatesByNameSize, arg.Name, arg.Size) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FindDuplicatesByNameSizeRow + for rows.Next() { + var i FindDuplicatesByNameSizeRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getResource = `-- name: GetResource :one +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetResource(ctx context.Context, id uuid.UUID) (Resource, error) { + row := q.db.QueryRowContext(ctx, getResource, id) + var i Resource + err := row.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listFolders = `-- name: ListFolders :many +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE is_folder = true +ORDER BY created_at DESC +` + +func (q *Queries) ListFolders(ctx context.Context) ([]Resource, error) { + rows, err := q.db.QueryContext(ctx, listFolders) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Resource + for rows.Next() { + var i Resource + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listResources = `-- name: ListResources :many +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE parent_resource_id IS NULL +ORDER BY is_folder DESC, created_at DESC +` + +func (q *Queries) ListResources(ctx context.Context) ([]Resource, error) { + rows, err := q.db.QueryContext(ctx, listResources) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Resource + for rows.Next() { + var i Resource + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listResourcesByID = `-- name: ListResourcesByID :many +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE id = ANY($1::uuid[]) +ORDER BY created_at DESC +` + +func (q *Queries) ListResourcesByID(ctx context.Context, dollar_1 []uuid.UUID) ([]Resource, error) { + rows, err := q.db.QueryContext(ctx, listResourcesByID, pq.Array(dollar_1)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Resource + for rows.Next() { + var i Resource + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listResourcesByOwner = `-- name: ListResourcesByOwner :many +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE owner_id = $1 AND parent_resource_id IS NULL +ORDER BY created_at DESC +` + +func (q *Queries) ListResourcesByOwner(ctx context.Context, ownerID uuid.UUID) ([]Resource, error) { + rows, err := q.db.QueryContext(ctx, listResourcesByOwner, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Resource + for rows.Next() { + var i Resource + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listResourcesByParentAndOwner = `-- name: ListResourcesByParentAndOwner :many +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE parent_resource_id = $1 AND owner_id = $2 +ORDER BY is_folder DESC, created_at DESC +` + +type ListResourcesByParentAndOwnerParams struct { + ParentResourceID uuid.NullUUID `json:"parent_resource_id"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) ListResourcesByParentAndOwner(ctx context.Context, arg ListResourcesByParentAndOwnerParams) ([]Resource, error) { + rows, err := q.db.QueryContext(ctx, listResourcesByParentAndOwner, arg.ParentResourceID, arg.OwnerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Resource + for rows.Next() { + var i Resource + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listResourcesByParentID = `-- name: ListResourcesByParentID :many +SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources +WHERE parent_resource_id = $1 +ORDER BY is_folder DESC, created_at DESC +` + +func (q *Queries) ListResourcesByParentID(ctx context.Context, parentResourceID uuid.NullUUID) ([]Resource, error) { + rows, err := q.db.QueryContext(ctx, listResourcesByParentID, parentResourceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Resource + for rows.Next() { + var i Resource + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MimeType, + &i.Size, + &i.Checksum, + &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const moveResources = `-- name: MoveResources :exec +UPDATE resources +SET parent_resource_id = $1, updated_at = CURRENT_TIMESTAMP +WHERE id = ANY($2::uuid[]) +` + +type MoveResourcesParams struct { + ParentResourceID uuid.NullUUID `json:"parent_resource_id"` + Column2 []uuid.UUID `json:"column_2"` +} + +func (q *Queries) MoveResources(ctx context.Context, arg MoveResourcesParams) error { + _, err := q.db.ExecContext(ctx, moveResources, arg.ParentResourceID, pq.Array(arg.Column2)) + return err +} + +const updateResource = `-- name: UpdateResource :exec +UPDATE resources +SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP +WHERE id = $4 +` + +type UpdateResourceParams struct { + Name string `json:"name"` + MimeType string `json:"mime_type"` + OcrText string `json:"ocr_text"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateResource(ctx context.Context, arg UpdateResourceParams) error { + _, err := q.db.ExecContext(ctx, updateResource, + arg.Name, + arg.MimeType, + arg.OcrText, + arg.ID, + ) + return err +} diff --git a/backend/internal/db/retention_policies.sql.go b/backend/internal/db/retention_policies.sql.go new file mode 100644 index 0000000..237efdd --- /dev/null +++ b/backend/internal/db/retention_policies.sql.go @@ -0,0 +1,144 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: retention_policies.sql + +package db + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" +) + +const createRetentionPolicy = `-- name: CreateRetentionPolicy :one +INSERT INTO retention_policies (user_id, storage_location_id, rule_type, rule_value) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, storage_location_id, rule_type, rule_value, created_at +` + +type CreateRetentionPolicyParams struct { + UserID uuid.UUID `json:"user_id"` + StorageLocationID uuid.UUID `json:"storage_location_id"` + RuleType string `json:"rule_type"` + RuleValue json.RawMessage `json:"rule_value"` +} + +func (q *Queries) CreateRetentionPolicy(ctx context.Context, arg CreateRetentionPolicyParams) (RetentionPolicy, error) { + row := q.db.QueryRowContext(ctx, createRetentionPolicy, + arg.UserID, + arg.StorageLocationID, + arg.RuleType, + arg.RuleValue, + ) + var i RetentionPolicy + err := row.Scan( + &i.ID, + &i.UserID, + &i.StorageLocationID, + &i.RuleType, + &i.RuleValue, + &i.CreatedAt, + ) + return i, err +} + +const deleteRetentionPolicy = `-- name: DeleteRetentionPolicy :exec +DELETE FROM retention_policies +WHERE id = $1 +` + +func (q *Queries) DeleteRetentionPolicy(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteRetentionPolicy, id) + return err +} + +const getRetentionPolicy = `-- name: GetRetentionPolicy :one +SELECT id, user_id, storage_location_id, rule_type, rule_value, created_at FROM retention_policies +WHERE id = $1 +` + +func (q *Queries) GetRetentionPolicy(ctx context.Context, id uuid.UUID) (RetentionPolicy, error) { + row := q.db.QueryRowContext(ctx, getRetentionPolicy, id) + var i RetentionPolicy + err := row.Scan( + &i.ID, + &i.UserID, + &i.StorageLocationID, + &i.RuleType, + &i.RuleValue, + &i.CreatedAt, + ) + return i, err +} + +const listRetentionPoliciesByLocation = `-- name: ListRetentionPoliciesByLocation :many +SELECT id, user_id, storage_location_id, rule_type, rule_value, created_at FROM retention_policies +WHERE storage_location_id = $1 +` + +func (q *Queries) ListRetentionPoliciesByLocation(ctx context.Context, storageLocationID uuid.UUID) ([]RetentionPolicy, error) { + rows, err := q.db.QueryContext(ctx, listRetentionPoliciesByLocation, storageLocationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RetentionPolicy + for rows.Next() { + var i RetentionPolicy + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.StorageLocationID, + &i.RuleType, + &i.RuleValue, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRetentionPoliciesByUser = `-- name: ListRetentionPoliciesByUser :many +SELECT id, user_id, storage_location_id, rule_type, rule_value, created_at FROM retention_policies +WHERE user_id = $1 +` + +func (q *Queries) ListRetentionPoliciesByUser(ctx context.Context, userID uuid.UUID) ([]RetentionPolicy, error) { + rows, err := q.db.QueryContext(ctx, listRetentionPoliciesByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RetentionPolicy + for rows.Next() { + var i RetentionPolicy + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.StorageLocationID, + &i.RuleType, + &i.RuleValue, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/db/storage_locations.sql.go b/backend/internal/db/storage_locations.sql.go new file mode 100644 index 0000000..6295366 --- /dev/null +++ b/backend/internal/db/storage_locations.sql.go @@ -0,0 +1,134 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: storage_locations.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createStorageLocation = `-- name: CreateStorageLocation :one +INSERT INTO storage_locations (user_id, device_name, role) +VALUES ($1, $2, $3) +RETURNING id, user_id, device_name, role, created_at, last_seen_at +` + +type CreateStorageLocationParams struct { + UserID uuid.UUID `json:"user_id"` + DeviceName string `json:"device_name"` + Role string `json:"role"` +} + +func (q *Queries) CreateStorageLocation(ctx context.Context, arg CreateStorageLocationParams) (StorageLocation, error) { + row := q.db.QueryRowContext(ctx, createStorageLocation, arg.UserID, arg.DeviceName, arg.Role) + var i StorageLocation + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.Role, + &i.CreatedAt, + &i.LastSeenAt, + ) + return i, err +} + +const deleteStorageLocation = `-- name: DeleteStorageLocation :exec +DELETE FROM storage_locations +WHERE id = $1 +` + +func (q *Queries) DeleteStorageLocation(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteStorageLocation, id) + return err +} + +const getServerStorageLocation = `-- name: GetServerStorageLocation :one +SELECT id, user_id, device_name, role, created_at, last_seen_at FROM storage_locations +WHERE user_id = $1 AND role = 'server' +LIMIT 1 +` + +func (q *Queries) GetServerStorageLocation(ctx context.Context, userID uuid.UUID) (StorageLocation, error) { + row := q.db.QueryRowContext(ctx, getServerStorageLocation, userID) + var i StorageLocation + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.Role, + &i.CreatedAt, + &i.LastSeenAt, + ) + return i, err +} + +const getStorageLocation = `-- name: GetStorageLocation :one +SELECT id, user_id, device_name, role, created_at, last_seen_at FROM storage_locations +WHERE id = $1 +` + +func (q *Queries) GetStorageLocation(ctx context.Context, id uuid.UUID) (StorageLocation, error) { + row := q.db.QueryRowContext(ctx, getStorageLocation, id) + var i StorageLocation + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.Role, + &i.CreatedAt, + &i.LastSeenAt, + ) + return i, err +} + +const listStorageLocationsByUser = `-- name: ListStorageLocationsByUser :many +SELECT id, user_id, device_name, role, created_at, last_seen_at FROM storage_locations +WHERE user_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListStorageLocationsByUser(ctx context.Context, userID uuid.UUID) ([]StorageLocation, error) { + rows, err := q.db.QueryContext(ctx, listStorageLocationsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []StorageLocation + for rows.Next() { + var i StorageLocation + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.Role, + &i.CreatedAt, + &i.LastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateStorageLocationLastSeen = `-- name: UpdateStorageLocationLastSeen :exec +UPDATE storage_locations +SET last_seen_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) UpdateStorageLocationLastSeen(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, updateStorageLocationLastSeen, id) + return err +} diff --git a/backend/internal/db/sync_queue.sql.go b/backend/internal/db/sync_queue.sql.go new file mode 100644 index 0000000..507f97c --- /dev/null +++ b/backend/internal/db/sync_queue.sql.go @@ -0,0 +1,172 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: sync_queue.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createSyncQueueItem = `-- name: CreateSyncQueueItem :one +INSERT INTO sync_queue (resource_id, storage_location_id, operation, status, attempts) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, resource_id, storage_location_id, operation, status, attempts, created_at, updated_at +` + +type CreateSyncQueueItemParams struct { + ResourceID uuid.UUID `json:"resource_id"` + StorageLocationID uuid.UUID `json:"storage_location_id"` + Operation string `json:"operation"` + Status string `json:"status"` + Attempts int32 `json:"attempts"` +} + +func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) { + row := q.db.QueryRowContext(ctx, createSyncQueueItem, + arg.ResourceID, + arg.StorageLocationID, + arg.Operation, + arg.Status, + arg.Attempts, + ) + var i SyncQueue + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Operation, + &i.Status, + &i.Attempts, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteSyncQueueItem = `-- name: DeleteSyncQueueItem :exec +DELETE FROM sync_queue +WHERE id = $1 +` + +func (q *Queries) DeleteSyncQueueItem(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteSyncQueueItem, id) + return err +} + +const getSyncQueueItem = `-- name: GetSyncQueueItem :one +SELECT id, resource_id, storage_location_id, operation, status, attempts, created_at, updated_at FROM sync_queue +WHERE id = $1 +` + +func (q *Queries) GetSyncQueueItem(ctx context.Context, id uuid.UUID) (SyncQueue, error) { + row := q.db.QueryRowContext(ctx, getSyncQueueItem, id) + var i SyncQueue + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Operation, + &i.Status, + &i.Attempts, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listPendingSyncItems = `-- name: ListPendingSyncItems :many +SELECT id, resource_id, storage_location_id, operation, status, attempts, created_at, updated_at FROM sync_queue +WHERE status = 'pending' +ORDER BY created_at ASC +` + +func (q *Queries) ListPendingSyncItems(ctx context.Context) ([]SyncQueue, error) { + rows, err := q.db.QueryContext(ctx, listPendingSyncItems) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SyncQueue + for rows.Next() { + var i SyncQueue + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Operation, + &i.Status, + &i.Attempts, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPendingSyncItemsByLocation = `-- name: ListPendingSyncItemsByLocation :many +SELECT id, resource_id, storage_location_id, operation, status, attempts, created_at, updated_at FROM sync_queue +WHERE storage_location_id = $1 AND status = 'pending' +ORDER BY created_at ASC +` + +func (q *Queries) ListPendingSyncItemsByLocation(ctx context.Context, storageLocationID uuid.UUID) ([]SyncQueue, error) { + rows, err := q.db.QueryContext(ctx, listPendingSyncItemsByLocation, storageLocationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SyncQueue + for rows.Next() { + var i SyncQueue + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.StorageLocationID, + &i.Operation, + &i.Status, + &i.Attempts, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateSyncQueueStatus = `-- name: UpdateSyncQueueStatus :exec +UPDATE sync_queue +SET status = $1, attempts = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $3 +` + +type UpdateSyncQueueStatusParams struct { + Status string `json:"status"` + Attempts int32 `json:"attempts"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateSyncQueueStatus(ctx context.Context, arg UpdateSyncQueueStatusParams) error { + _, err := q.db.ExecContext(ctx, updateSyncQueueStatus, arg.Status, arg.Attempts, arg.ID) + return err +} diff --git a/backend/internal/db/tags.sql.go b/backend/internal/db/tags.sql.go index cee51be..8e853a0 100644 --- a/backend/internal/db/tags.sql.go +++ b/backend/internal/db/tags.sql.go @@ -10,41 +10,35 @@ import ( "database/sql" ) -const addTagToFile = `-- name: AddTagToFile :exec -INSERT INTO file_tags (tag_id, file_id) +const addTagToResource = `-- name: AddTagToResource :exec +INSERT INTO resource_tags (tag_id, resource_id) VALUES ($1, $2) ON CONFLICT DO NOTHING ` -type AddTagToFileParams struct { - TagID sql.NullString `json:"tag_id"` - FileID sql.NullString `json:"file_id"` +type AddTagToResourceParams struct { + TagID sql.NullString `json:"tag_id"` + ResourceID sql.NullString `json:"resource_id"` } -func (q *Queries) AddTagToFile(ctx context.Context, arg AddTagToFileParams) error { - _, err := q.db.ExecContext(ctx, addTagToFile, arg.TagID, arg.FileID) +func (q *Queries) AddTagToResource(ctx context.Context, arg AddTagToResourceParams) error { + _, err := q.db.ExecContext(ctx, addTagToResource, arg.TagID, arg.ResourceID) return err } const createTag = `-- name: CreateTag :one -INSERT INTO tags (tag_name, tag_type) -VALUES ($1, $2) -RETURNING id, parent_tag_id, tag_name, tag_type, created_at, updated_at +INSERT INTO tags (tag_name) +VALUES ($1) +RETURNING id, parent_tag_id, tag_name, created_at, updated_at ` -type CreateTagParams struct { - TagName string `json:"tag_name"` - TagType string `json:"tag_type"` -} - -func (q *Queries) CreateTag(ctx context.Context, arg CreateTagParams) (Tag, error) { - row := q.db.QueryRowContext(ctx, createTag, arg.TagName, arg.TagType) +func (q *Queries) CreateTag(ctx context.Context, tagName string) (Tag, error) { + row := q.db.QueryRowContext(ctx, createTag, tagName) var i Tag err := row.Scan( &i.ID, &i.ParentTagID, &i.TagName, - &i.TagType, &i.CreatedAt, &i.UpdatedAt, ) @@ -61,34 +55,34 @@ func (q *Queries) DeleteTag(ctx context.Context, id string) error { return err } -const getFilesByTagID = `-- name: GetFilesByTagID :many -SELECT f.id, f.name, f.mime_type, f.size, f.storage_key, f.checksum, f.ocr_text, f.created_at, f.updated_at, f.parent_file_id, f.is_folder FROM files f -JOIN file_tags ft ON f.id = ft.file_id -WHERE ft.tag_id = $1 -ORDER BY f.created_at DESC +const getResourcesByTagID = `-- name: GetResourcesByTagID :many +SELECT r.id, r.name, r.mime_type, r.size, r.checksum, r.ocr_text, r.is_folder, r.parent_resource_id, r.owner_id, r.created_at, r.updated_at FROM resources r +JOIN resource_tags rt ON r.id = rt.resource_id +WHERE rt.tag_id = $1 +ORDER BY r.created_at DESC ` -func (q *Queries) GetFilesByTagID(ctx context.Context, tagID sql.NullString) ([]File, error) { - rows, err := q.db.QueryContext(ctx, getFilesByTagID, tagID) +func (q *Queries) GetResourcesByTagID(ctx context.Context, tagID sql.NullString) ([]Resource, error) { + rows, err := q.db.QueryContext(ctx, getResourcesByTagID, tagID) if err != nil { return nil, err } defer rows.Close() - var items []File + var items []Resource for rows.Next() { - var i File + var i Resource if err := rows.Scan( &i.ID, &i.Name, &i.MimeType, &i.Size, - &i.StorageKey, &i.Checksum, &i.OcrText, + &i.IsFolder, + &i.ParentResourceID, + &i.OwnerID, &i.CreatedAt, &i.UpdatedAt, - &i.ParentFileID, - &i.IsFolder, ); err != nil { return nil, err } @@ -104,7 +98,7 @@ func (q *Queries) GetFilesByTagID(ctx context.Context, tagID sql.NullString) ([] } const getTag = `-- name: GetTag :one -SELECT id, parent_tag_id, tag_name, tag_type, created_at, updated_at FROM tags +SELECT id, parent_tag_id, tag_name, created_at, updated_at FROM tags WHERE id = $1 ` @@ -115,7 +109,6 @@ func (q *Queries) GetTag(ctx context.Context, id string) (Tag, error) { &i.ID, &i.ParentTagID, &i.TagName, - &i.TagType, &i.CreatedAt, &i.UpdatedAt, ) @@ -123,7 +116,7 @@ func (q *Queries) GetTag(ctx context.Context, id string) (Tag, error) { } const getTagByName = `-- name: GetTagByName :one -SELECT id, parent_tag_id, tag_name, tag_type, created_at, updated_at FROM tags +SELECT id, parent_tag_id, tag_name, created_at, updated_at FROM tags WHERE tag_name = $1 ` @@ -134,22 +127,21 @@ func (q *Queries) GetTagByName(ctx context.Context, tagName string) (Tag, error) &i.ID, &i.ParentTagID, &i.TagName, - &i.TagType, &i.CreatedAt, &i.UpdatedAt, ) return i, err } -const getTagsByFileID = `-- name: GetTagsByFileID :many -SELECT t.id, t.parent_tag_id, t.tag_name, t.tag_type, t.created_at, t.updated_at FROM tags t -JOIN file_tags ft ON t.id = ft.tag_id -WHERE ft.file_id = $1 +const getTagsByResourceID = `-- name: GetTagsByResourceID :many +SELECT t.id, t.parent_tag_id, t.tag_name, t.created_at, t.updated_at FROM tags t +JOIN resource_tags rt ON t.id = rt.tag_id +WHERE rt.resource_id = $1 ORDER BY t.tag_name ASC ` -func (q *Queries) GetTagsByFileID(ctx context.Context, fileID sql.NullString) ([]Tag, error) { - rows, err := q.db.QueryContext(ctx, getTagsByFileID, fileID) +func (q *Queries) GetTagsByResourceID(ctx context.Context, resourceID sql.NullString) ([]Tag, error) { + rows, err := q.db.QueryContext(ctx, getTagsByResourceID, resourceID) if err != nil { return nil, err } @@ -161,7 +153,6 @@ func (q *Queries) GetTagsByFileID(ctx context.Context, fileID sql.NullString) ([ &i.ID, &i.ParentTagID, &i.TagName, - &i.TagType, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -179,7 +170,7 @@ func (q *Queries) GetTagsByFileID(ctx context.Context, fileID sql.NullString) ([ } const listTags = `-- name: ListTags :many -SELECT id, parent_tag_id, tag_name, tag_type, created_at, updated_at FROM tags +SELECT id, parent_tag_id, tag_name, created_at, updated_at FROM tags ORDER BY tag_name ASC ` @@ -196,7 +187,6 @@ func (q *Queries) ListTags(ctx context.Context) ([]Tag, error) { &i.ID, &i.ParentTagID, &i.TagName, - &i.TagType, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -213,17 +203,17 @@ func (q *Queries) ListTags(ctx context.Context) ([]Tag, error) { return items, nil } -const removeTagFromFile = `-- name: RemoveTagFromFile :exec -DELETE FROM file_tags -WHERE tag_id = $1 AND file_id = $2 +const removeTagFromResource = `-- name: RemoveTagFromResource :exec +DELETE FROM resource_tags +WHERE tag_id = $1 AND resource_id = $2 ` -type RemoveTagFromFileParams struct { - TagID sql.NullString `json:"tag_id"` - FileID sql.NullString `json:"file_id"` +type RemoveTagFromResourceParams struct { + TagID sql.NullString `json:"tag_id"` + ResourceID sql.NullString `json:"resource_id"` } -func (q *Queries) RemoveTagFromFile(ctx context.Context, arg RemoveTagFromFileParams) error { - _, err := q.db.ExecContext(ctx, removeTagFromFile, arg.TagID, arg.FileID) +func (q *Queries) RemoveTagFromResource(ctx context.Context, arg RemoveTagFromResourceParams) error { + _, err := q.db.ExecContext(ctx, removeTagFromResource, arg.TagID, arg.ResourceID) return err } diff --git a/backend/internal/db/thumbnails.sql.go b/backend/internal/db/thumbnails.sql.go deleted file mode 100644 index 603a7ca..0000000 --- a/backend/internal/db/thumbnails.sql.go +++ /dev/null @@ -1,150 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: thumbnails.sql - -package db - -import ( - "context" -) - -const createThumbnail = `-- name: CreateThumbnail :one -INSERT INTO thumbnails (file_id, page_number, resolution_label, width, height, storage_key, mime_type) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at -` - -type CreateThumbnailParams struct { - FileID string `json:"file_id"` - PageNumber int32 `json:"page_number"` - ResolutionLabel string `json:"resolution_label"` - Width int32 `json:"width"` - Height int32 `json:"height"` - StorageKey string `json:"storage_key"` - MimeType string `json:"mime_type"` -} - -func (q *Queries) CreateThumbnail(ctx context.Context, arg CreateThumbnailParams) (Thumbnail, error) { - row := q.db.QueryRowContext(ctx, createThumbnail, - arg.FileID, - arg.PageNumber, - arg.ResolutionLabel, - arg.Width, - arg.Height, - arg.StorageKey, - arg.MimeType, - ) - var i Thumbnail - err := row.Scan( - &i.ID, - &i.FileID, - &i.PageNumber, - &i.ResolutionLabel, - &i.Width, - &i.Height, - &i.StorageKey, - &i.MimeType, - &i.CreatedAt, - ) - return i, err -} - -const deleteThumbnailsByFileID = `-- name: DeleteThumbnailsByFileID :exec -DELETE FROM thumbnails WHERE file_id = $1 -` - -func (q *Queries) DeleteThumbnailsByFileID(ctx context.Context, fileID string) error { - _, err := q.db.ExecContext(ctx, deleteThumbnailsByFileID, fileID) - return err -} - -const getThumbnailByFilePageResolution = `-- name: GetThumbnailByFilePageResolution :one -SELECT id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at FROM thumbnails -WHERE file_id = $1 AND page_number = $2 AND resolution_label = $3 -LIMIT 1 -` - -type GetThumbnailByFilePageResolutionParams struct { - FileID string `json:"file_id"` - PageNumber int32 `json:"page_number"` - ResolutionLabel string `json:"resolution_label"` -} - -func (q *Queries) GetThumbnailByFilePageResolution(ctx context.Context, arg GetThumbnailByFilePageResolutionParams) (Thumbnail, error) { - row := q.db.QueryRowContext(ctx, getThumbnailByFilePageResolution, arg.FileID, arg.PageNumber, arg.ResolutionLabel) - var i Thumbnail - err := row.Scan( - &i.ID, - &i.FileID, - &i.PageNumber, - &i.ResolutionLabel, - &i.Width, - &i.Height, - &i.StorageKey, - &i.MimeType, - &i.CreatedAt, - ) - return i, err -} - -const getThumbnailByID = `-- name: GetThumbnailByID :one -SELECT id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at FROM thumbnails -WHERE id = $1 LIMIT 1 -` - -func (q *Queries) GetThumbnailByID(ctx context.Context, id string) (Thumbnail, error) { - row := q.db.QueryRowContext(ctx, getThumbnailByID, id) - var i Thumbnail - err := row.Scan( - &i.ID, - &i.FileID, - &i.PageNumber, - &i.ResolutionLabel, - &i.Width, - &i.Height, - &i.StorageKey, - &i.MimeType, - &i.CreatedAt, - ) - return i, err -} - -const getThumbnailsByFileID = `-- name: GetThumbnailsByFileID :many -SELECT id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at FROM thumbnails -WHERE file_id = $1 -ORDER BY page_number ASC, resolution_label ASC -` - -func (q *Queries) GetThumbnailsByFileID(ctx context.Context, fileID string) ([]Thumbnail, error) { - rows, err := q.db.QueryContext(ctx, getThumbnailsByFileID, fileID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Thumbnail - for rows.Next() { - var i Thumbnail - if err := rows.Scan( - &i.ID, - &i.FileID, - &i.PageNumber, - &i.ResolutionLabel, - &i.Width, - &i.Height, - &i.StorageKey, - &i.MimeType, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/backend/internal/handler/device.go b/backend/internal/handler/device.go new file mode 100644 index 0000000..632e4e2 --- /dev/null +++ b/backend/internal/handler/device.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/internal/auth" + "github.com/vaultdrop/backend/internal/service" + "github.com/vaultdrop/backend/pkg/api" +) + +type DeviceHandler struct { + placement *service.PlacementService +} + +func (h *DeviceHandler) List(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + + locations, err := h.placement.ListUserLocations(userID) + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list devices") + return + } + + type deviceResponse struct { + ID string `json:"id"` + DeviceName string `json:"device_name"` + Role string `json:"role"` + } + + resp := make([]deviceResponse, len(locations)) + for i, l := range locations { + resp[i] = deviceResponse{ + ID: l.ID.String(), + DeviceName: l.DeviceName, + Role: l.Role, + } + } + + api.Success(c, resp) +} + +func (h *DeviceHandler) Register(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + + var body struct { + DeviceName string `json:"device_name" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_BODY", "device_name is required") + return + } + + loc, err := h.placement.CreateDeviceLocation(userID, body.DeviceName) + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to register device") + return + } + + api.Created(c, gin.H{ + "id": loc.ID.String(), + "device_name": loc.DeviceName, + "role": loc.Role, + }) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 61a83fa..6f2a1c6 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -6,17 +6,37 @@ import ( ) type Handler struct { - File *FileHandler - OCR *OCRHandler - Health *HealthHandler - Auth *auth.AuthHandler + Resource *ResourceHandler + OCR *OCRHandler + Health *HealthHandler + Auth *auth.AuthHandler + Share *ShareHandler + Device *DeviceHandler + Sync *SyncHandler } -func New(fileSvc *service.FileService, ocrSvc *service.OCRService, urlSvc *service.URLService, authHandler *auth.AuthHandler, conversionSvc *service.ConversionService) *Handler { +func New( + resourceSvc *service.ResourceService, + ocrSvc *service.OCRService, + urlSvc *service.URLService, + authHandler *auth.AuthHandler, + conversionSvc *service.ConversionService, + rebacSvc *service.RebacService, + placementSvc *service.PlacementService, + syncSvc *service.SyncService, +) *Handler { return &Handler{ - File: &FileHandler{files: fileSvc, urls: urlSvc, ocr: ocrSvc, conversion: conversionSvc}, - OCR: &OCRHandler{ocr: ocrSvc, files: fileSvc}, + Resource: &ResourceHandler{ + resources: resourceSvc, + urls: urlSvc, + ocr: ocrSvc, + conversion: conversionSvc, + }, + OCR: &OCRHandler{ocr: ocrSvc, resources: resourceSvc}, Health: &HealthHandler{ocr: ocrSvc}, Auth: authHandler, + Share: &ShareHandler{rebac: rebacSvc}, + Device: &DeviceHandler{placement: placementSvc}, + Sync: &SyncHandler{sync: syncSvc}, } } diff --git a/backend/internal/handler/ocr.go b/backend/internal/handler/ocr.go index cb3938a..3838cb5 100644 --- a/backend/internal/handler/ocr.go +++ b/backend/internal/handler/ocr.go @@ -9,8 +9,8 @@ import ( ) type OCRHandler struct { - ocr *service.OCRService - files *service.FileService + ocr *service.OCRService + resources *service.ResourceService } func (h *OCRHandler) CreateJob(c *gin.Context) { diff --git a/backend/internal/handler/files.go b/backend/internal/handler/resource.go similarity index 53% rename from backend/internal/handler/files.go rename to backend/internal/handler/resource.go index db6d729..214ff6b 100644 --- a/backend/internal/handler/files.go +++ b/backend/internal/handler/resource.go @@ -8,18 +8,21 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/internal/auth" "github.com/vaultdrop/backend/internal/service" "github.com/vaultdrop/backend/pkg/api" ) -type FileHandler struct { - files *service.FileService +type ResourceHandler struct { + resources *service.ResourceService urls *service.URLService ocr *service.OCRService conversion *service.ConversionService } -func (h *FileHandler) Upload(c *gin.Context) { +func (h *ResourceHandler) Upload(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + form, err := c.MultipartForm() if err != nil { api.Error(c, http.StatusBadRequest, "ERROR_PARSING", "Error while parsing multipart form") @@ -34,7 +37,7 @@ func (h *FileHandler) Upload(c *gin.Context) { results := make([]gin.H, 0, len(files)) for _, file := range files { - result, err := h.files.Upload(file) + result, err := h.resources.Upload(file, userID) if err != nil { api.Error(c, http.StatusInternalServerError, "UPLOAD_ERROR", err.Error()) return @@ -55,23 +58,23 @@ func (h *FileHandler) Upload(c *gin.Context) { api.Success(c, results) } -func (h *FileHandler) List(c *gin.Context) { +func (h *ResourceHandler) List(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) thumbnailQuality := c.Query("thumbnail") - files, err := h.files.List() + resources, err := h.resources.List(userID) if err != nil { - log.Printf("ERROR List files: %v", err) - api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list files") + log.Printf("ERROR List resources: %v", err) + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources") return } type tagResponse struct { ID string `json:"id"` TagName string `json:"tag_name"` - TagType string `json:"tag_type"` } - type fileResponse struct { + type resourceResponse struct { ID string `json:"id"` URL string `json:"url"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` @@ -81,50 +84,51 @@ func (h *FileHandler) List(c *gin.Context) { CreatedAt string `json:"createdAt"` MimeType string `json:"mimeType"` OcrText string `json:"ocrText,omitempty"` - ParentFileID string `json:"parentFileID,omitempty"` + ParentID string `json:"parentResourceId,omitempty"` IsFolder bool `json:"isFolder"` UpdatedAt string `json:"updatedAt"` + OwnerID string `json:"ownerId"` } - resp := make([]fileResponse, len(files)) - for i, f := range files { + resp := make([]resourceResponse, len(resources)) + for i, r := range resources { tags := []tagResponse{} - for _, tag := range f.Tags { + for _, tag := range r.Tags { tags = append(tags, tagResponse{ ID: tag.ID, TagName: tag.Name, - TagType: tag.TagType, }) } - downloadURL := h.urls.GenerateDownloadURL(f.ID) + downloadURL := h.urls.GenerateDownloadURL(r.ID) thumbURL := downloadURL if thumbnailQuality != "" { - if best := h.files.GetBestThumbnail(f.ID, thumbnailQuality); best != nil { - thumbURL = h.urls.GenerateThumbnailURL(best.ID) + if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil { + thumbURL = h.urls.GenerateVariantURL(best.ID) } } - resp[i] = fileResponse{ - ID: f.ID, + resp[i] = resourceResponse{ + ID: r.ID, URL: downloadURL, ThumbnailURL: thumbURL, - Name: f.Name, - Size: f.Size, + Name: r.Name, + Size: r.Size, Tags: tags, - CreatedAt: f.CreatedAt, - ParentFileID: f.ParentFileID, - OcrText: f.OcrText, - IsFolder: f.IsFolder, - UpdatedAt: f.UpdatedAt, - MimeType: f.MimeType, + CreatedAt: r.CreatedAt.String(), + ParentID: r.ParentResourceID, + OcrText: r.OcrText, + IsFolder: r.IsFolder, + UpdatedAt: r.UpdatedAt.String(), + MimeType: r.MimeType, + OwnerID: r.OwnerID, } } api.Paginated(c, resp, 1, len(resp)) } -func (h *FileHandler) Download(c *gin.Context) { +func (h *ResourceHandler) Download(c *gin.Context) { id := c.Param("id") exp, _ := strconv.ParseInt(c.Query("expires"), 10, 64) sig := c.Query("sig") @@ -134,133 +138,126 @@ func (h *FileHandler) Download(c *gin.Context) { return } - storagePath, err := h.files.GetStoragePath(id) + storagePath, err := h.resources.GetStoragePath(id) if err != nil { - api.Error(c, http.StatusNotFound, "FILE_NOT_FOUND", "File not found") + api.Error(c, http.StatusNotFound, "RESOURCE_NOT_FOUND", "Resource not found") return } c.File(path.Clean(storagePath)) } -func (h *FileHandler) Get(c *gin.Context) { +func (h *ResourceHandler) Get(c *gin.Context) { id := c.Param("id") thumbnailQuality := c.Query("thumbnail") - file, err := h.files.Get(id) + resource, err := h.resources.Get(id) if err != nil { - api.Error(c, http.StatusNotFound, "FILE_NOT_FOUND", "File not found") + api.Error(c, http.StatusNotFound, "RESOURCE_NOT_FOUND", "Resource not found") return } type tagResponse struct { ID string `json:"id"` TagName string `json:"tag_name"` - TagType string `json:"tag_type"` } - type thumbnailResponse struct { - ID string `json:"id"` - PageNumber int `json:"pageNumber"` - ResolutionLabel string `json:"resolutionLabel"` - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - MimeType string `json:"mimeType"` + type variantResponse struct { + ID string `json:"id"` + PageNumber int `json:"pageNumber"` + VariantType string `json:"variantType"` + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + MimeType string `json:"mimeType"` } tags := []tagResponse{} - for _, tag := range file.Tags { + for _, tag := range resource.Tags { tags = append(tags, tagResponse{ ID: tag.ID, TagName: tag.Name, - TagType: tag.TagType, }) } - downloadURL := h.urls.GenerateDownloadURL(file.ID) + downloadURL := h.urls.GenerateDownloadURL(resource.ID) thumbURL := downloadURL if thumbnailQuality != "" { - if best := h.files.GetBestThumbnail(file.ID, thumbnailQuality); best != nil { - thumbURL = h.urls.GenerateThumbnailURL(best.ID) + if best := h.resources.GetBestVariant(resource.ID, thumbnailQuality); best != nil { + thumbURL = h.urls.GenerateVariantURL(best.ID) } } - dbThumbnails, _ := h.files.GetThumbnailsByFileID(file.ID) - thumbnails := make([]thumbnailResponse, len(dbThumbnails)) - for i, t := range dbThumbnails { - thumbnails[i] = thumbnailResponse{ - ID: t.ID, - PageNumber: t.PageNumber, - ResolutionLabel: t.ResolutionLabel, - Width: t.Width, - Height: t.Height, - URL: h.urls.GenerateThumbnailURL(t.ID), - MimeType: t.MimeType, + dbVariants, _ := h.resources.GetVariantsByResourceID(resource.ID) + variants := make([]variantResponse, len(dbVariants)) + for i, v := range dbVariants { + variants[i] = variantResponse{ + ID: v.ID, + PageNumber: v.PageNumber, + VariantType: v.VariantType, + Width: v.Width, + Height: v.Height, + URL: h.urls.GenerateVariantURL(v.ID), + MimeType: v.MimeType, } } api.Success(c, gin.H{ - "id": file.ID, - "name": file.Name, + "id": resource.ID, + "name": resource.Name, "url": downloadURL, "thumbnailUrl": thumbURL, - "size": file.Size, - "mimeType": file.MimeType, + "size": resource.Size, + "mimeType": resource.MimeType, "tags": tags, - "createdAt": file.CreatedAt, - "updatedAt": file.UpdatedAt, - "ocrText": file.OcrText, - "isFolder": file.IsFolder, - "parentFileID": file.ParentFileID, - "thumbnails": thumbnails, + "createdAt": resource.CreatedAt, + "updatedAt": resource.UpdatedAt, + "ocrText": resource.OcrText, + "isFolder": resource.IsFolder, + "parentResourceId": resource.ParentResourceID, + "ownerId": resource.OwnerID, + "variants": variants, }) } -func (h *FileHandler) Delete(c *gin.Context) { +func (h *ResourceHandler) Delete(c *gin.Context) { id := c.Param("id") - storagePath, _ := h.files.GetStoragePath(id) - thumbnails, _ := h.files.GetThumbnailsByFileID(id) + storagePath, _ := h.resources.GetStoragePath(id) + variants, _ := h.resources.GetVariantsByResourceID(id) - if err := h.files.Delete(id); err != nil { - api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to delete file") + if err := h.resources.Delete(id); err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to delete resource") return } if storagePath != "" { os.Remove(path.Clean(storagePath)) } - for _, t := range thumbnails { - os.Remove(path.Clean(t.StorageKey)) + for _, v := range variants { + os.Remove(path.Clean(v.StorageKey)) } api.Success(c, gin.H{"deleted": true}) } -func (h *FileHandler) AddTags(c *gin.Context) { +func (h *ResourceHandler) AddTags(c *gin.Context) { id := c.Param("id") var body struct { - Tags []string `json:"tags" binding:"required"` - TagType string `json:"tag_type"` + Tags []string `json:"tags" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { api.Error(c, http.StatusBadRequest, "INVALID_BODY", "Body must contain a 'tags' array") return } - tagType := body.TagType - if tagType == "" { - tagType = "none" - } - - if err := h.files.AddTags(id, body.Tags, tagType); err != nil { + if err := h.resources.AddTags(id, body.Tags); err != nil { api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to add tags") return } - tags, err := h.files.GetTagsByFileID(id) + tags, err := h.resources.GetTagsByResourceID(id) if err != nil { api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to fetch tags") return @@ -269,9 +266,9 @@ func (h *FileHandler) AddTags(c *gin.Context) { api.Success(c, tags) } -func (h *FileHandler) GetTags(c *gin.Context) { +func (h *ResourceHandler) GetTags(c *gin.Context) { id := c.Param("id") - tags, err := h.files.GetTagsByFileID(id) + tags, err := h.resources.GetTagsByResourceID(id) if err != nil { api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to fetch tags") return @@ -279,26 +276,27 @@ func (h *FileHandler) GetTags(c *gin.Context) { api.Success(c, tags) } -func (h *FileHandler) MoveFiles(c *gin.Context) { +func (h *ResourceHandler) MoveResources(c *gin.Context) { var body struct { - FileIDs []string `json:"file_ids" binding:"required"` - ParentFileID *string `json:"parent_file_id"` + ResourceIDs []string `json:"resource_ids" binding:"required"` + ParentResourceID *string `json:"parent_resource_id"` } if err := c.ShouldBindJSON(&body); err != nil { - api.Error(c, http.StatusBadRequest, "INVALID_BODY", "Body must contain 'file_ids' array") + api.Error(c, http.StatusBadRequest, "INVALID_BODY", "Body must contain 'resource_ids' array") return } - if err := h.files.MoveFiles(body.FileIDs, body.ParentFileID); err != nil { - api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to move files") + if err := h.resources.MoveResources(body.ResourceIDs, body.ParentResourceID); err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to move resources") return } - api.Success(c, gin.H{"moved": len(body.FileIDs)}) + api.Success(c, gin.H{"moved": len(body.ResourceIDs)}) } -func (h *FileHandler) ListFolders(c *gin.Context) { - folders, err := h.files.ListFolders() +func (h *ResourceHandler) ListFolders(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + folders, err := h.resources.ListFolders(userID) if err != nil { api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list folders") return @@ -306,7 +304,9 @@ func (h *FileHandler) ListFolders(c *gin.Context) { api.Success(c, folders) } -func (h *FileHandler) CreateFolder(c *gin.Context) { +func (h *ResourceHandler) CreateFolder(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + var body struct { Name string `json:"name" binding:"required"` } @@ -315,7 +315,7 @@ func (h *FileHandler) CreateFolder(c *gin.Context) { return } - folder, err := h.files.CreateFolder(body.Name) + folder, err := h.resources.CreateFolder(body.Name, userID) if err != nil { api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to create folder") return @@ -324,23 +324,23 @@ func (h *FileHandler) CreateFolder(c *gin.Context) { api.Success(c, folder) } -func (h *FileHandler) ListFilesByParent(c *gin.Context) { +func (h *ResourceHandler) ListByParent(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) parentID := c.Param("id") thumbnailQuality := c.Query("thumbnail") - files, err := h.files.ListFilesByParentID(parentID) + resources, err := h.resources.ListResourcesByParentID(parentID, userID) if err != nil { - api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list files in folder") + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources in folder") return } type tagResponse struct { ID string `json:"id"` TagName string `json:"tag_name"` - TagType string `json:"tag_type"` } - type fileResponse struct { + type resourceResponse struct { ID string `json:"id"` URL string `json:"url"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` @@ -350,84 +350,83 @@ func (h *FileHandler) ListFilesByParent(c *gin.Context) { CreatedAt string `json:"createdAt"` MimeType string `json:"mimeType"` OcrText string `json:"ocrText,omitempty"` - ParentFileID string `json:"parentFileID,omitempty"` + ParentID string `json:"parentResourceId,omitempty"` IsFolder bool `json:"isFolder"` UpdatedAt string `json:"updatedAt"` } - resp := make([]fileResponse, len(files)) - for i, f := range files { + resp := make([]resourceResponse, len(resources)) + for i, r := range resources { tags := []tagResponse{} - for _, tag := range f.Tags { + for _, tag := range r.Tags { tags = append(tags, tagResponse{ ID: tag.ID, TagName: tag.Name, - TagType: tag.TagType, }) } - downloadURL := h.urls.GenerateDownloadURL(f.ID) + downloadURL := h.urls.GenerateDownloadURL(r.ID) thumbURL := downloadURL if thumbnailQuality != "" { - if best := h.files.GetBestThumbnail(f.ID, thumbnailQuality); best != nil { - thumbURL = h.urls.GenerateThumbnailURL(best.ID) + if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil { + thumbURL = h.urls.GenerateVariantURL(best.ID) } } - resp[i] = fileResponse{ - ID: f.ID, + resp[i] = resourceResponse{ + ID: r.ID, URL: downloadURL, ThumbnailURL: thumbURL, - Name: f.Name, - Size: f.Size, + Name: r.Name, + Size: r.Size, Tags: tags, - CreatedAt: f.CreatedAt, - ParentFileID: f.ParentFileID, - OcrText: f.OcrText, - IsFolder: f.IsFolder, - UpdatedAt: f.UpdatedAt, - MimeType: f.MimeType, + CreatedAt: r.CreatedAt.String(), + ParentID: r.ParentResourceID, + OcrText: r.OcrText, + IsFolder: r.IsFolder, + UpdatedAt: r.UpdatedAt.String(), + MimeType: r.MimeType, } } api.Success(c, resp) } -func (h *FileHandler) GetThumbnails(c *gin.Context) { +func (h *ResourceHandler) GetVariants(c *gin.Context) { id := c.Param("id") - thumbnails, err := h.files.GetThumbnailsByFileID(id) + variants, err := h.resources.GetVariantsByResourceID(id) if err != nil { - api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to fetch thumbnails") + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to fetch variants") return } - type thumbnailResponse struct { - ID string `json:"id"` - PageNumber int `json:"pageNumber"` - ResolutionLabel string `json:"resolutionLabel"` - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - MimeType string `json:"mimeType"` + type variantResponse struct { + ID string `json:"id"` + PageNumber int `json:"pageNumber"` + VariantType string `json:"variantType"` + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + MimeType string `json:"mimeType"` } - resp := make([]thumbnailResponse, len(thumbnails)) - for i, t := range thumbnails { - resp[i] = thumbnailResponse{ - ID: t.ID, - PageNumber: t.PageNumber, - ResolutionLabel: t.ResolutionLabel, - Width: t.Width, - Height: t.Height, - URL: h.urls.GenerateThumbnailURL(t.ID), - MimeType: t.MimeType, + resp := make([]variantResponse, len(variants)) + for i, v := range variants { + resp[i] = variantResponse{ + ID: v.ID, + PageNumber: v.PageNumber, + VariantType: v.VariantType, + Width: v.Width, + Height: v.Height, + URL: h.urls.GenerateVariantURL(v.ID), + MimeType: v.MimeType, } } api.Success(c, resp) } -func (h *FileHandler) ServeThumbnail(c *gin.Context) { +func (h *ResourceHandler) ServeVariant(c *gin.Context) { id := c.Param("id") exp, _ := strconv.ParseInt(c.Query("expires"), 10, 64) sig := c.Query("sig") @@ -437,16 +436,16 @@ func (h *FileHandler) ServeThumbnail(c *gin.Context) { return } - storagePath, err := h.files.GetThumbnailStoragePath(id) + storagePath, err := h.resources.GetVariantStoragePath(id) if err != nil { - api.Error(c, http.StatusNotFound, "THUMBNAIL_NOT_FOUND", "Thumbnail not found") + api.Error(c, http.StatusNotFound, "VARIANT_NOT_FOUND", "Variant not found") return } c.File(path.Clean(storagePath)) } -func (h *FileHandler) CheckDuplicates(c *gin.Context) { +func (h *ResourceHandler) CheckDuplicates(c *gin.Context) { var body struct { Name string `json:"name" binding:"required"` Size int64 `json:"size" binding:"required"` @@ -457,7 +456,7 @@ func (h *FileHandler) CheckDuplicates(c *gin.Context) { return } - duplicates, err := h.files.FindDuplicatesByNameSize(body.Name, body.Size) + duplicates, err := h.resources.FindDuplicatesByNameSize(body.Name, body.Size) if err != nil { api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to check duplicates") return @@ -475,7 +474,7 @@ func (h *FileHandler) CheckDuplicates(c *gin.Context) { resp := make([]dupResponse, len(duplicates)) for i, d := range duplicates { resp[i] = dupResponse{ - ID: d.ID, + ID: d.ID.String(), Name: d.Name, MimeType: d.MimeType, Size: d.Size, diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index a5a2596..d54c1ca 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -14,29 +14,65 @@ func SetupRoutes(r *gin.Engine, h *Handler, authMiddleware *auth.AuthService) { api.POST("/auth/login", h.Auth.Login) api.POST("/auth/refresh", h.Auth.Refresh) api.POST("/auth/logout", h.Auth.Logout) - api.GET("/files/download/:id", h.File.Download) - api.GET("/thumbnails/:id", h.File.ServeThumbnail) + api.GET("/resources/download/:id", h.Resource.Download) + api.GET("/variants/:id", h.Resource.ServeVariant) // Protected protected := api.Group("") protected.Use(authMiddleware.RequireAuth()) - protected.GET("/files", h.File.List) - protected.POST("/files/upload", h.File.Upload) - protected.POST("/files/move", h.File.MoveFiles) - protected.POST("/files/folders", h.File.CreateFolder) - protected.GET("/files/folders", h.File.ListFolders) - protected.GET("/files/folders/:id/files", h.File.ListFilesByParent) - protected.DELETE("/files/:id", h.File.Delete) - protected.GET("/files/:id", h.File.Get) + // Resources (replaces /files) + protected.GET("/resources", h.Resource.List) + protected.POST("/resources/upload", h.Resource.Upload) + protected.POST("/resources/move", h.Resource.MoveResources) + protected.POST("/resources/folders", h.Resource.CreateFolder) + protected.GET("/resources/folders", h.Resource.ListFolders) + protected.GET("/resources/folders/:id/resources", h.Resource.ListByParent) + protected.DELETE("/resources/:id", h.Resource.Delete) + protected.GET("/resources/:id", h.Resource.Get) - protected.POST("/files/:id/tags", h.File.AddTags) - protected.GET("/files/:id/tags", h.File.GetTags) + // Tags on resources + protected.POST("/resources/:id/tags", h.Resource.AddTags) + protected.GET("/resources/:id/tags", h.Resource.GetTags) + // Variants + protected.GET("/resources/:id/variants", h.Resource.GetVariants) + + // Dedup check + protected.POST("/resources/dedup-check", h.Resource.CheckDuplicates) + + // Sharing (ReBAC) + protected.POST("/resources/:id/share", h.Share.Grant) + protected.DELETE("/resources/:id/share/:userId", h.Share.Revoke) + protected.GET("/resources/:id/share", h.Share.List) + protected.GET("/resources/:id/access", h.Share.Check) + + // Device management + protected.GET("/devices", h.Device.List) + protected.POST("/devices", h.Device.Register) + + // Placements + protected.GET("/resources/:id/placements", h.Resource.GetVariants) + + // Sync + protected.POST("/sync/pull", h.Sync.Pull) + protected.POST("/sync/push", h.Sync.Push) + + // OCR protected.POST("/ocr/jobs", h.OCR.CreateJob) protected.GET("/ocr/jobs/:id", h.OCR.GetJobStatus) - protected.GET("/files/:id/thumbnails", h.File.GetThumbnails) - - protected.POST("/files/dedup-check", h.File.CheckDuplicates) + // Legacy /files/* endpoints (maintain backward compatibility) + protected.GET("/files", h.Resource.List) + protected.POST("/files/upload", h.Resource.Upload) + protected.POST("/files/move", h.Resource.MoveResources) + protected.POST("/files/folders", h.Resource.CreateFolder) + protected.GET("/files/folders", h.Resource.ListFolders) + protected.GET("/files/folders/:id/files", h.Resource.ListByParent) + protected.DELETE("/files/:id", h.Resource.Delete) + protected.GET("/files/:id", h.Resource.Get) + protected.POST("/files/:id/tags", h.Resource.AddTags) + protected.GET("/files/:id/tags", h.Resource.GetTags) + protected.GET("/files/:id/thumbnails", h.Resource.GetVariants) + protected.POST("/files/dedup-check", h.Resource.CheckDuplicates) } diff --git a/backend/internal/handler/share.go b/backend/internal/handler/share.go new file mode 100644 index 0000000..54bee37 --- /dev/null +++ b/backend/internal/handler/share.go @@ -0,0 +1,89 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/internal/auth" + "github.com/vaultdrop/backend/internal/service" + "github.com/vaultdrop/backend/pkg/api" +) + +type ShareHandler struct { + rebac *service.RebacService +} + +func (h *ShareHandler) Grant(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + resourceID := c.Param("id") + + var body struct { + SubjectUserID string `json:"subject_user_id" binding:"required"` + Role string `json:"role" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_BODY", "subject_user_id and role are required") + return + } + + if err := h.rebac.GrantRole(userID, resourceID, body.SubjectUserID, body.Role); err != nil { + api.Error(c, http.StatusForbidden, "FORBIDDEN", err.Error()) + return + } + + api.Success(c, gin.H{"granted": true}) +} + +func (h *ShareHandler) Revoke(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + resourceID := c.Param("id") + subjectID := c.Param("userId") + + if err := h.rebac.RevokeRole(userID, resourceID, subjectID); err != nil { + api.Error(c, http.StatusForbidden, "FORBIDDEN", err.Error()) + return + } + + api.Success(c, gin.H{"revoked": true}) +} + +func (h *ShareHandler) List(c *gin.Context) { + resourceID := c.Param("id") + + relations, err := h.rebac.ListShares(resourceID) + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list shares") + return + } + + type shareResponse struct { + UserID string `json:"user_id"` + Role string `json:"role"` + } + + resp := make([]shareResponse, len(relations)) + for i, r := range relations { + resp[i] = shareResponse{ + UserID: r.SubjectUserID.String(), + Role: r.Role, + } + } + + api.Success(c, resp) +} + +func (h *ShareHandler) Check(c *gin.Context) { + userID := c.GetString(auth.UserIDKey) + resourceID := c.Param("id") + + role, err := h.rebac.ResolveEffectiveRole(userID, resourceID) + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to resolve role") + return + } + + api.Success(c, gin.H{ + "role": role, + "access": role != "", + }) +} diff --git a/backend/internal/handler/sync.go b/backend/internal/handler/sync.go new file mode 100644 index 0000000..686e7c3 --- /dev/null +++ b/backend/internal/handler/sync.go @@ -0,0 +1,56 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/internal/service" + "github.com/vaultdrop/backend/pkg/api" +) + +type SyncHandler struct { + sync *service.SyncService +} + +func (h *SyncHandler) Pull(c *gin.Context) { + var body struct { + LocationID string `json:"location_id"` + } + c.ShouldBindJSON(&body) + + var err error + var items interface{} + if body.LocationID != "" { + items, err = h.sync.ListPending(body.LocationID) + } else { + items, err = h.sync.ListAllPending() + } + + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list pending sync items") + return + } + + api.Success(c, items) +} + +func (h *SyncHandler) Push(c *gin.Context) { + var body struct { + LocationID string `json:"location_id" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_BODY", "location_id is required") + return + } + + items, err := h.sync.ListPending(body.LocationID) + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list pending sync items") + return + } + + api.Created(c, gin.H{ + "pending": len(items), + "message": "Push initiated", + }) +} diff --git a/backend/internal/model/file.go b/backend/internal/model/file.go index e99195f..84e47e1 100644 --- a/backend/internal/model/file.go +++ b/backend/internal/model/file.go @@ -1,24 +1,39 @@ package model +import "time" + type Tag struct { - ID string `json:"id"` - Name string `json:"name"` - TagType string `json:"tagType"` + ID string `json:"id"` + Name string `json:"name"` } -type File struct { - ID string `json:"id"` - Name string `json:"name"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - StorageKey string `json:"-"` - Checksum string `json:"-"` - OcrText string `json:"ocrText,omitempty"` - Tags []Tag `json:"tags"` - IsFolder bool `json:"isFolder"` - ParentFileID string `json:"parentFileId,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` +type Resource struct { + ID string `json:"id"` + Name string `json:"name"` + MimeType string `json:"mimeType"` + Size int64 `json:"size"` + StorageKey string `json:"-"` + Checksum string `json:"-"` + OcrText string `json:"ocrText,omitempty"` + Tags []Tag `json:"tags"` + IsFolder bool `json:"isFolder"` + ParentResourceID string `json:"parentResourceId,omitempty"` + OwnerID string `json:"ownerId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Variant struct { + ID string `json:"id"` + ResourceID string `json:"resourceId"` + VariantType string `json:"variantType"` + PageNumber int `json:"pageNumber"` + Width int `json:"width"` + Height int `json:"height"` + StorageKey string `json:"-"` + MimeType string `json:"mimeType"` + GeneratedBy string `json:"generatedBy"` + CreatedAt string `json:"createdAt"` } type UploadResult struct { diff --git a/backend/internal/model/thumbnail.go b/backend/internal/model/thumbnail.go index a31b9d1..8b53790 100644 --- a/backend/internal/model/thumbnail.go +++ b/backend/internal/model/thumbnail.go @@ -1,13 +1 @@ package model - -type Thumbnail struct { - ID string `json:"id"` - FileID string `json:"fileId"` - PageNumber int `json:"pageNumber"` - ResolutionLabel string `json:"resolutionLabel"` - Width int `json:"width"` - Height int `json:"height"` - StorageKey string `json:"-"` - MimeType string `json:"mimeType"` - CreatedAt string `json:"createdAt"` -} diff --git a/backend/internal/service/checksum.go b/backend/internal/service/checksum.go index e15f508..3bcd0ef 100644 --- a/backend/internal/service/checksum.go +++ b/backend/internal/service/checksum.go @@ -2,18 +2,9 @@ package service import ( "crypto/sha256" - "crypto/subtle" ) func CreateSHA256Hash(data []byte) []byte { - hasher := sha256.New() - hasher.Write(data) - return hasher.Sum(nil) -} - -func CompareHash(x, y []byte) bool { - if len(x) != len(y) { - return false - } - return subtle.ConstantTimeCompare(x, y) == 1 + h := sha256.Sum256(data) + return h[:] } diff --git a/backend/internal/service/conversion.go b/backend/internal/service/conversion.go index 011b62b..27e0d20 100644 --- a/backend/internal/service/conversion.go +++ b/backend/internal/service/conversion.go @@ -17,9 +17,9 @@ import ( ) type ConversionJob struct { - FileID string - FilePath string - MimeType string + ResourceID string + FilePath string + MimeType string } type ConversionService struct { @@ -46,9 +46,9 @@ func (s *ConversionService) Stop() { log.Println("[Conversion] Worker stopped") } -func (s *ConversionService) Enqueue(fileID, filePath, mimeType string) { - s.jobs <- ConversionJob{FileID: fileID, FilePath: filePath, MimeType: mimeType} - log.Printf("[Conversion] Enqueued file %s", fileID) +func (s *ConversionService) Enqueue(resourceID, filePath, mimeType string) { + s.jobs <- ConversionJob{ResourceID: resourceID, FilePath: filePath, MimeType: mimeType} + log.Printf("[Conversion] Enqueued resource %s", resourceID) } func (s *ConversionService) worker() { @@ -58,7 +58,7 @@ func (s *ConversionService) worker() { } func (s *ConversionService) process(job ConversionJob) { - log.Printf("[Conversion] Processing file %s (mime: %s)", job.FileID, job.MimeType) + log.Printf("[Conversion] Processing resource %s (mime: %s)", job.ResourceID, job.MimeType) pdfPath := job.FilePath tmpDir := "" @@ -67,18 +67,18 @@ func (s *ConversionService) process(job ConversionJob) { var err error pdfPath, tmpDir, err = s.convertToPDF(job.FilePath) if err != nil { - log.Printf("[Conversion] Failed to convert file %s to PDF: %v", job.FileID, err) + log.Printf("[Conversion] Failed to convert resource %s to PDF: %v", job.ResourceID, err) return } defer os.RemoveAll(tmpDir) } else if !isPDF(job.MimeType) { - log.Printf("[Conversion] Skipping file %s: unsupported mime type %s", job.FileID, job.MimeType) + log.Printf("[Conversion] Skipping resource %s: unsupported mime type %s", job.ResourceID, job.MimeType) return } - thumbDir := filepath.Join(s.cfg.ThumbnailDir, job.FileID) + thumbDir := filepath.Join(s.cfg.ThumbnailDir, job.ResourceID) if err := os.MkdirAll(thumbDir, 0o755); err != nil { - log.Printf("[Conversion] Failed to create thumbnail dir for %s: %v", job.FileID, err) + log.Printf("[Conversion] Failed to create thumbnail dir for %s: %v", job.ResourceID, err) return } @@ -86,14 +86,14 @@ func (s *ConversionService) process(job ConversionJob) { label string dpi int }{ - {"thumbnail", 21}, - {"full", 200}, + {"thumbnail_small", 21}, + {"thumbnail_full", 200}, } for _, res := range resolutions { pages, err := s.convertPDFToImages(pdfPath, thumbDir, res.dpi) if err != nil { - log.Printf("[Conversion] Failed to convert file %s to images (res=%s): %v", job.FileID, res.label, err) + log.Printf("[Conversion] Failed to convert resource %s to images (res=%s): %v", job.ResourceID, res.label, err) continue } @@ -104,32 +104,33 @@ func (s *ConversionService) process(job ConversionJob) { width, height = 0, 0 } - thumbUUID := uuid.New().String() - dstPath := filepath.Join(thumbDir, thumbUUID+".jpg") + dstPath := filepath.Join(thumbDir, uuid.New().String()+".jpg") if err := os.Rename(page.path, dstPath); err != nil { log.Printf("[Conversion] Failed to move %s to %s: %v", page.path, dstPath, err) continue } - _, err = s.queries.CreateThumbnail(context.Background(), db.CreateThumbnailParams{ - FileID: job.FileID, - PageNumber: int32(page.number), - ResolutionLabel: res.label, - Width: int32(width), - Height: int32(height), - StorageKey: dstPath, - MimeType: "image/jpeg", + resourceUUID, _ := uuid.Parse(job.ResourceID) + _, err = s.queries.CreateResourceVariant(context.Background(), db.CreateResourceVariantParams{ + ResourceID: resourceUUID, + VariantType: res.label, + PageNumber: int32(page.number), + Width: int32(width), + Height: int32(height), + MimeType: "image/jpeg", + GeneratedBy: "server", + StorageKey: dstPath, }) if err != nil { - log.Printf("[Conversion] Failed to create thumbnail record for file %s page %d: %v", job.FileID, page.number, err) + log.Printf("[Conversion] Failed to create variant record for resource %s page %d: %v", job.ResourceID, page.number, err) continue } } - log.Printf("[Conversion] Generated %d %s images for file %s", len(pages), res.label, job.FileID) + log.Printf("[Conversion] Generated %d %s images for resource %s", len(pages), res.label, job.ResourceID) } - log.Printf("[Conversion] Completed file %s", job.FileID) + log.Printf("[Conversion] Completed resource %s", job.ResourceID) } func (s *ConversionService) convertToPDF(inputPath string) (string, string, error) { diff --git a/backend/internal/service/file.go b/backend/internal/service/file.go deleted file mode 100644 index d078599..0000000 --- a/backend/internal/service/file.go +++ /dev/null @@ -1,349 +0,0 @@ -package service - -import ( - "context" - "database/sql" - "encoding/hex" - "fmt" - "mime/multipart" - "os" - "path/filepath" - - "github.com/google/uuid" - "github.com/vaultdrop/backend/internal/config" - "github.com/vaultdrop/backend/internal/db" - "github.com/vaultdrop/backend/internal/model" -) - -type FileService struct { - queries *db.Queries - cfg *config.Config -} - -func NewFileService(queries *db.Queries, cfg *config.Config) *FileService { - return &FileService{queries: queries, cfg: cfg} -} - -func (s *FileService) Upload(file *multipart.FileHeader) (*model.UploadResult, error) { - dst := filepath.Join(s.cfg.UploadDir, uuid.New().String()+filepath.Ext(file.Filename)) - - if err := os.MkdirAll(s.cfg.UploadDir, 0o755); err != nil { - return nil, fmt.Errorf("create upload dir: %w", err) - } - - if err := saveUploadedFile(file, dst); err != nil { - return nil, fmt.Errorf("save file: %w", err) - } - - data, err := os.ReadFile(dst) - if err != nil { - return nil, fmt.Errorf("read saved file: %w", err) - } - - info, err := os.Stat(dst) - if err != nil { - return nil, fmt.Errorf("stat file: %w", err) - } - - checksum := hex.EncodeToString(CreateSHA256Hash(data)) - - existing, err := s.queries.FindDuplicateByChecksum(context.Background(), checksum) - if err == nil && existing.ID != "" { - os.Remove(dst) - return &model.UploadResult{ - ID: existing.ID, - Name: existing.Name, - Path: existing.StorageKey, - MimeType: existing.MimeType, - }, nil - } - - dbFile, err := s.queries.CreateFile(context.Background(), db.CreateFileParams{ - Name: file.Filename, - MimeType: file.Header.Get("Content-Type"), - Size: info.Size(), - StorageKey: dst, - Checksum: checksum, - }) - if err != nil { - return nil, fmt.Errorf("create file in db: %w", err) - } - - return &model.UploadResult{ - ID: dbFile.ID, - Name: dbFile.Name, - Path: dst, - MimeType: dbFile.MimeType, - }, nil -} - -func (s *FileService) List() ([]model.File, error) { - dbFiles, err := s.queries.ListFiles(context.Background()) - if err != nil { - return nil, fmt.Errorf("list files: %w", err) - } - - files := make([]model.File, len(dbFiles)) - for i, f := range dbFiles { - tags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: f.ID, Valid: true}) - if err != nil { - return nil, fmt.Errorf("get tags for file %s: %w", f.ID, err) - } - files[i] = dbToModel(f, tags) - } - return files, nil -} - -func (s *FileService) Get(id string) (*model.File, error) { - f, err := s.queries.GetFile(context.Background(), id) - if err != nil { - return nil, fmt.Errorf("get file: %w", err) - } - tags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: f.ID, Valid: true}) - if err != nil { - return nil, fmt.Errorf("get tags: %w", err) - } - m := dbToModel(f, tags) - return &m, nil -} - -func (s *FileService) Delete(id string) error { - return s.queries.DeleteFile(context.Background(), id) -} - -func (s *FileService) GetStoragePath(id string) (string, error) { - f, err := s.queries.GetFile(context.Background(), id) - if err != nil { - return "", fmt.Errorf("get file: %w", err) - } - return f.StorageKey, nil -} - -func (s *FileService) UpdateOCRText(id, text string) error { - f, err := s.queries.GetFile(context.Background(), id) - if err != nil { - return fmt.Errorf("get file: %w", err) - } - return s.queries.UpdateFile(context.Background(), db.UpdateFileParams{ - Name: f.Name, - MimeType: f.MimeType, - OcrText: text, - ID: id, - }) -} - -func (s *FileService) AddTags(fileID string, tagNames []string, tagType string) error { - for _, name := range tagNames { - tag, err := s.queries.GetTagByName(context.Background(), name) - if err == sql.ErrNoRows { - tag, err = s.queries.CreateTag(context.Background(), db.CreateTagParams{ - TagName: name, - TagType: tagType, - }) - if err != nil { - return fmt.Errorf("create tag %q: %w", name, err) - } - } else if err != nil { - return fmt.Errorf("get tag %q: %w", name, err) - } - - err = s.queries.AddTagToFile(context.Background(), db.AddTagToFileParams{ - TagID: sql.NullString{String: tag.ID, Valid: true}, - FileID: sql.NullString{String: fileID, Valid: true}, - }) - if err != nil { - return fmt.Errorf("link tag %q to file: %w", name, err) - } - } - return nil -} - -func (s *FileService) GetTagsByFileID(fileID string) ([]model.Tag, error) { - dbTags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: fileID, Valid: true}) - if err != nil { - return nil, fmt.Errorf("get tags: %w", err) - } - tags := make([]model.Tag, len(dbTags)) - for i, t := range dbTags { - tags[i] = model.Tag{ID: t.ID, Name: t.TagName, TagType: t.TagType} - } - return tags, nil -} - -func (s *FileService) MoveFiles(fileIDs []string, parentFileID *string) error { - var parentID sql.NullString - if parentFileID != nil { - parentID = sql.NullString{String: *parentFileID, Valid: true} - } - return s.queries.MoveFiles(context.Background(), db.MoveFilesParams{ - ParentFileID: parentID, - Column2: fileIDs, - }) -} - -func (s *FileService) CreateFolder(name string) (*model.File, error) { - f, err := s.queries.CreateFolder(context.Background(), name) - if err != nil { - return nil, fmt.Errorf("create folder: %w", err) - } - m := dbToModel(f, nil) - return &m, nil -} - -func (s *FileService) ListFolders() ([]model.File, error) { - dbFiles, err := s.queries.ListFolders(context.Background()) - if err != nil { - return nil, fmt.Errorf("list folders: %w", err) - } - folders := make([]model.File, len(dbFiles)) - for i, f := range dbFiles { - folders[i] = dbToModel(f, nil) - } - return folders, nil -} - -func (s *FileService) ListFilesByParentID(parentID string) ([]model.File, error) { - dbFiles, err := s.queries.ListFilesByParentID(context.Background(), sql.NullString{String: parentID, Valid: true}) - if err != nil { - return nil, fmt.Errorf("list files by parent: %w", err) - } - files := make([]model.File, len(dbFiles)) - for i, f := range dbFiles { - tags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: f.ID, Valid: true}) - if err != nil { - return nil, fmt.Errorf("get tags for file %s: %w", f.ID, err) - } - files[i] = dbToModel(f, tags) - } - return files, nil -} - -func (s *FileService) GetThumbnailsByFileID(fileID string) ([]model.Thumbnail, error) { - dbThumbnails, err := s.queries.GetThumbnailsByFileID(context.Background(), fileID) - if err != nil { - return nil, fmt.Errorf("get thumbnails: %w", err) - } - - thumbnails := make([]model.Thumbnail, len(dbThumbnails)) - for i, t := range dbThumbnails { - thumbnails[i] = model.Thumbnail{ - ID: t.ID, - FileID: t.FileID, - PageNumber: int(t.PageNumber), - ResolutionLabel: t.ResolutionLabel, - Width: int(t.Width), - Height: int(t.Height), - StorageKey: t.StorageKey, - MimeType: t.MimeType, - CreatedAt: t.CreatedAt.String(), - } - } - return thumbnails, nil -} - -func (s *FileService) GetThumbnailStoragePath(id string) (string, error) { - t, err := s.queries.GetThumbnailByID(context.Background(), id) - if err != nil { - return "", fmt.Errorf("get thumbnail: %w", err) - } - return t.StorageKey, nil -} - -func (s *FileService) GetBestThumbnail(fileID, preferredLabel string) *model.Thumbnail { - dbThumbnails, err := s.queries.GetThumbnailsByFileID(context.Background(), fileID) - if err != nil || len(dbThumbnails) == 0 { - return nil - } - - var fallback *model.Thumbnail - for _, t := range dbThumbnails { - if t.PageNumber != 1 { - continue - } - if t.ResolutionLabel == preferredLabel { - return &model.Thumbnail{ - ID: t.ID, - FileID: t.FileID, - PageNumber: int(t.PageNumber), - ResolutionLabel: t.ResolutionLabel, - Width: int(t.Width), - Height: int(t.Height), - StorageKey: t.StorageKey, - MimeType: t.MimeType, - CreatedAt: t.CreatedAt.String(), - } - } - if fallback == nil { - fallback = &model.Thumbnail{ - ID: t.ID, - FileID: t.FileID, - PageNumber: int(t.PageNumber), - ResolutionLabel: t.ResolutionLabel, - Width: int(t.Width), - Height: int(t.Height), - StorageKey: t.StorageKey, - MimeType: t.MimeType, - CreatedAt: t.CreatedAt.String(), - } - } - } - - return fallback -} - -func (s *FileService) FindDuplicatesByNameSize(name string, size int64) ([]db.FindDuplicatesByNameSizeRow, error) { - return s.queries.FindDuplicatesByNameSize(context.Background(), db.FindDuplicatesByNameSizeParams{ - Name: name, - Size: size, - }) -} - -func dbToModel(f db.File, dbTags []db.Tag) model.File { - tags := make([]model.Tag, len(dbTags)) - for i, t := range dbTags { - tags[i] = model.Tag{ID: t.ID, Name: t.TagName, TagType: t.TagType} - } - - return model.File{ - ID: f.ID, - Name: f.Name, - MimeType: f.MimeType, - Size: f.Size, - StorageKey: f.StorageKey, - Checksum: f.Checksum, - OcrText: f.OcrText, - IsFolder: f.IsFolder, - ParentFileID: f.ParentFileID.String, - Tags: tags, - CreatedAt: f.CreatedAt.String(), - UpdatedAt: f.UpdatedAt.String(), - } -} - -func saveUploadedFile(file *multipart.FileHeader, dst string) error { - src, err := file.Open() - if err != nil { - return err - } - defer src.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - buf := make([]byte, 32*1024) - for { - n, readErr := src.Read(buf) - if n > 0 { - if _, writeErr := out.Write(buf[:n]); writeErr != nil { - return writeErr - } - } - if readErr != nil { - break - } - } - return nil -} diff --git a/backend/internal/service/ocr.go b/backend/internal/service/ocr.go index 98ade16..bf7ac13 100644 --- a/backend/internal/service/ocr.go +++ b/backend/internal/service/ocr.go @@ -10,21 +10,21 @@ import ( ) type OCRJob struct { - FileID string - FilePath string + ResourceID string + FilePath string } type OCRService struct { - client *ocr.Client - fileSvc *FileService - jobs chan OCRJob + client *ocr.Client + resourceSvc *ResourceService + jobs chan OCRJob } -func NewOCRService(cfg *config.Config, fileSvc *FileService) *OCRService { +func NewOCRService(cfg *config.Config, resourceSvc *ResourceService) *OCRService { return &OCRService{ - client: ocr.NewClient(cfg.OCREndpoint), - fileSvc: fileSvc, - jobs: make(chan OCRJob, 100), + client: ocr.NewClient(cfg.OCREndpoint), + resourceSvc: resourceSvc, + jobs: make(chan OCRJob, 100), } } @@ -38,9 +38,9 @@ func (s *OCRService) Stop() { log.Println("[OCR] Worker stopped") } -func (s *OCRService) Enqueue(fileID, filePath string) { - s.jobs <- OCRJob{FileID: fileID, FilePath: filePath} - log.Printf("[OCR] Enqueued file %s", fileID) +func (s *OCRService) Enqueue(resourceID, filePath string) { + s.jobs <- OCRJob{ResourceID: resourceID, FilePath: filePath} + log.Printf("[OCR] Enqueued resource %s", resourceID) } func (s *OCRService) worker() { @@ -50,28 +50,28 @@ func (s *OCRService) worker() { } func (s *OCRService) process(job OCRJob) { - log.Printf("[OCR] Processing file %s", job.FileID) + log.Printf("[OCR] Processing resource %s", job.ResourceID) data, err := os.ReadFile(job.FilePath) if err != nil { - log.Printf("[OCR] Failed to read file %s: %v", job.FileID, err) + log.Printf("[OCR] Failed to read resource %s: %v", job.ResourceID, err) return } blocks, err := s.client.Recognize(data) if err != nil { - log.Printf("[OCR] Failed to recognize file %s: %v", job.FileID, err) + log.Printf("[OCR] Failed to recognize resource %s: %v", job.ResourceID, err) return } text := s.FlattenResults(blocks) - if err := s.fileSvc.UpdateOCRText(job.FileID, text); err != nil { - log.Printf("[OCR] Failed to update ocr_text for file %s: %v", job.FileID, err) + if err := s.resourceSvc.UpdateOCRText(job.ResourceID, text); err != nil { + log.Printf("[OCR] Failed to update ocr_text for resource %s: %v", job.ResourceID, err) return } - log.Printf("[OCR] Completed file %s (%d chars)", job.FileID, len(text)) + log.Printf("[OCR] Completed resource %s (%d chars)", job.ResourceID, len(text)) } func (s *OCRService) RecognizeFromBytes(data []byte) ([]ocr.TextBlock, error) { diff --git a/backend/internal/service/placement.go b/backend/internal/service/placement.go new file mode 100644 index 0000000..af5931b --- /dev/null +++ b/backend/internal/service/placement.go @@ -0,0 +1,65 @@ +package service + +import ( + "context" + + "github.com/google/uuid" + "github.com/vaultdrop/backend/internal/db" +) + +type PlacementService struct { + queries *db.Queries +} + +func NewPlacementService(queries *db.Queries) *PlacementService { + return &PlacementService{queries: queries} +} + +func (s *PlacementService) GetPlacementsForResource(resourceID string) ([]db.ResourcePlacement, error) { + resourceUUID, _ := uuid.Parse(resourceID) + return s.queries.ListPlacementsByResource(context.Background(), resourceUUID) +} + +func (s *PlacementService) GetPlacementsForLocation(locationID string) ([]db.ResourcePlacement, error) { + locationUUID, _ := uuid.Parse(locationID) + return s.queries.ListPlacementsByLocation(context.Background(), locationUUID) +} + +func (s *PlacementService) UpdatePlacementStatus(placementID, status string) error { + placementUUID, _ := uuid.Parse(placementID) + return s.queries.UpdatePlacementStatus(context.Background(), db.UpdatePlacementStatusParams{ + Status: status, + ID: placementUUID, + }) +} + +func (s *PlacementService) DeletePlacement(placementID string) error { + placementUUID, _ := uuid.Parse(placementID) + return s.queries.DeletePlacement(context.Background(), placementUUID) +} + +func (s *PlacementService) CreateDeviceLocation(userID, deviceName string) (db.StorageLocation, error) { + userUUID, _ := uuid.Parse(userID) + return s.queries.CreateStorageLocation(context.Background(), db.CreateStorageLocationParams{ + UserID: userUUID, + DeviceName: deviceName, + Role: "device", + }) +} + +func (s *PlacementService) ListUserLocations(userID string) ([]db.StorageLocation, error) { + userUUID, _ := uuid.Parse(userID) + return s.queries.ListStorageLocationsByUser(context.Background(), userUUID) +} + +func (s *PlacementService) ensureServerLocation(userID uuid.UUID) (db.StorageLocation, error) { + loc, err := s.queries.GetServerStorageLocation(context.Background(), userID) + if err != nil { + return s.queries.CreateStorageLocation(context.Background(), db.CreateStorageLocationParams{ + UserID: userID, + DeviceName: "VaultDrop Server", + Role: "server", + }) + } + return loc, nil +} diff --git a/backend/internal/service/rebac.go b/backend/internal/service/rebac.go new file mode 100644 index 0000000..938154c --- /dev/null +++ b/backend/internal/service/rebac.go @@ -0,0 +1,89 @@ +package service + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/vaultdrop/backend/internal/db" +) + +type RebacService struct { + queries *db.Queries +} + +func NewRebacService(queries *db.Queries) *RebacService { + return &RebacService{queries: queries} +} + +func (s *RebacService) ResolveEffectiveRole(userID, resourceID string) (string, error) { + userUUID, _ := uuid.Parse(userID) + resourceUUID, _ := uuid.Parse(resourceID) + role, err := s.queries.ResolveEffectiveRole(context.Background(), db.ResolveEffectiveRoleParams{ + PUserID: userUUID, + PResourceID: resourceUUID, + }) + if err != nil { + return "", fmt.Errorf("resolve effective role: %w", err) + } + return role, nil +} + +func (s *RebacService) HasRole(userID, resourceID, requiredRole string) (bool, error) { + role, err := s.ResolveEffectiveRole(userID, resourceID) + if err != nil { + return false, err + } + return role == requiredRole, nil +} + +func (s *RebacService) canGrant(granterRole string) bool { + return granterRole == "owner" || granterRole == "admin" +} + +func (s *RebacService) GrantRole(granterID, resourceID, subjectID, role string) error { + granterUUID, _ := uuid.Parse(granterID) + resourceUUID, _ := uuid.Parse(resourceID) + subjectUUID, _ := uuid.Parse(subjectID) + + granterRole, err := s.ResolveEffectiveRole(granterID, resourceID) + if err != nil { + return fmt.Errorf("resolve granter role: %w", err) + } + if !s.canGrant(granterRole) { + return fmt.Errorf("granter does not have permission to grant roles") + } + + _, err = s.queries.CreateRebacRelation(context.Background(), db.CreateRebacRelationParams{ + ResourceID: resourceUUID, + SubjectUserID: subjectUUID, + Role: role, + GrantedBy: granterUUID, + }) + if err != nil { + return fmt.Errorf("create rebac relation: %w", err) + } + return nil +} + +func (s *RebacService) RevokeRole(granterID, resourceID, subjectID string) error { + granterRole, err := s.ResolveEffectiveRole(granterID, resourceID) + if err != nil { + return fmt.Errorf("resolve granter role: %w", err) + } + if !s.canGrant(granterRole) { + return fmt.Errorf("granter does not have permission to revoke roles") + } + + resourceUUID, _ := uuid.Parse(resourceID) + subjectUUID, _ := uuid.Parse(subjectID) + return s.queries.DeleteRebacRelation(context.Background(), db.DeleteRebacRelationParams{ + ResourceID: resourceUUID, + SubjectUserID: subjectUUID, + }) +} + +func (s *RebacService) ListShares(resourceID string) ([]db.RebacRelation, error) { + resourceUUID, _ := uuid.Parse(resourceID) + return s.queries.ListRebacRelationsByResource(context.Background(), resourceUUID) +} diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go new file mode 100644 index 0000000..33355ea --- /dev/null +++ b/backend/internal/service/resource.go @@ -0,0 +1,423 @@ +package service + +import ( + "context" + "database/sql" + "encoding/hex" + "fmt" + "mime/multipart" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + "github.com/vaultdrop/backend/internal/config" + "github.com/vaultdrop/backend/internal/db" + "github.com/vaultdrop/backend/internal/model" +) + +type ResourceService struct { + queries *db.Queries + cfg *config.Config +} + +func NewResourceService(queries *db.Queries, cfg *config.Config) *ResourceService { + return &ResourceService{queries: queries, cfg: cfg} +} + +func (s *ResourceService) Upload(file *multipart.FileHeader, ownerID string) (*model.UploadResult, error) { + dst := filepath.Join(s.cfg.UploadDir, uuid.New().String()+filepath.Ext(file.Filename)) + + if err := os.MkdirAll(s.cfg.UploadDir, 0o755); err != nil { + return nil, fmt.Errorf("create upload dir: %w", err) + } + + if err := saveUploadedFile(file, dst); err != nil { + return nil, fmt.Errorf("save file: %w", err) + } + + data, err := os.ReadFile(dst) + if err != nil { + return nil, fmt.Errorf("read saved file: %w", err) + } + + info, err := os.Stat(dst) + if err != nil { + return nil, fmt.Errorf("stat file: %w", err) + } + + checksum := hex.EncodeToString(CreateSHA256Hash(data)) + + ownerUUID, err := uuid.Parse(ownerID) + if err != nil { + return nil, fmt.Errorf("parse owner id: %w", err) + } + + existing, err := s.queries.FindDuplicateByChecksum(context.Background(), db.FindDuplicateByChecksumParams{ + Checksum: checksum, + OwnerID: ownerUUID, + }) + if err == nil && existing.ID != uuid.Nil { + os.Remove(dst) + placement, err := s.queries.GetServerPlacementByResource(context.Background(), existing.ID) + if err == nil { + return &model.UploadResult{ + ID: existing.ID.String(), + Name: existing.Name, + Path: placement.StorageKey.String, + MimeType: existing.MimeType, + }, nil + } + } + + dbResource, err := s.queries.CreateResource(context.Background(), db.CreateResourceParams{ + Name: file.Filename, + MimeType: file.Header.Get("Content-Type"), + Size: info.Size(), + Checksum: checksum, + OwnerID: ownerUUID, + }) + if err != nil { + return nil, fmt.Errorf("create resource in db: %w", err) + } + + placement, err := s.ensureServerPlacement(dbResource.ID, dst) + if err != nil { + return nil, fmt.Errorf("create server placement: %w", err) + } + + if err := s.ensureOwnerRebac(dbResource.ID, ownerUUID); err != nil { + return nil, fmt.Errorf("create owner rebac: %w", err) + } + + return &model.UploadResult{ + ID: dbResource.ID.String(), + Name: dbResource.Name, + Path: placement.StorageKey.String, + MimeType: dbResource.MimeType, + }, nil +} + +func (s *ResourceService) ensureServerPlacement(resourceID uuid.UUID, dst string) (db.ResourcePlacement, error) { + serverLoc, err := s.queries.GetServerStorageLocation(context.Background(), uuid.Nil) + if err != nil { + return db.ResourcePlacement{}, fmt.Errorf("get server location: %w", err) + } + + placement, err := s.queries.CreatePlacement(context.Background(), db.CreatePlacementParams{ + ResourceID: resourceID, + StorageLocationID: serverLoc.ID, + Status: "synced", + StorageKey: sql.NullString{String: dst, Valid: true}, + SyncedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + if err != nil { + return db.ResourcePlacement{}, fmt.Errorf("create placement: %w", err) + } + + return placement, nil +} + +func (s *ResourceService) ensureOwnerRebac(resourceID, ownerID uuid.UUID) error { + _, err := s.queries.CreateRebacRelation(context.Background(), db.CreateRebacRelationParams{ + ResourceID: resourceID, + SubjectUserID: ownerID, + Role: "owner", + GrantedBy: ownerID, + }) + return err +} + +func (s *ResourceService) List(ownerID string) ([]model.Resource, error) { + ownerUUID, _ := uuid.Parse(ownerID) + dbResources, err := s.queries.ListResourcesByOwner(context.Background(), ownerUUID) + if err != nil { + return nil, fmt.Errorf("list resources: %w", err) + } + + resources := make([]model.Resource, len(dbResources)) + for i, r := range dbResources { + tags, err := s.queries.GetTagsByResourceID(context.Background(), sql.NullString{String: r.ID.String(), Valid: true}) + if err != nil { + return nil, fmt.Errorf("get tags for resource %s: %w", r.ID, err) + } + resources[i] = dbResourceToModel(r, tags) + } + return resources, nil +} + +func (s *ResourceService) Get(id string) (*model.Resource, error) { + resourceUUID, _ := uuid.Parse(id) + r, err := s.queries.GetResource(context.Background(), resourceUUID) + if err != nil { + return nil, fmt.Errorf("get resource: %w", err) + } + tags, err := s.queries.GetTagsByResourceID(context.Background(), sql.NullString{String: r.ID.String(), Valid: true}) + if err != nil { + return nil, fmt.Errorf("get tags: %w", err) + } + m := dbResourceToModel(r, tags) + return &m, nil +} + +func (s *ResourceService) Delete(id string) error { + resourceUUID, _ := uuid.Parse(id) + return s.queries.DeleteResource(context.Background(), resourceUUID) +} + +func (s *ResourceService) GetStoragePath(id string) (string, error) { + resourceUUID, _ := uuid.Parse(id) + placement, err := s.queries.GetServerPlacementByResource(context.Background(), resourceUUID) + if err != nil { + return "", fmt.Errorf("get server placement for resource %s: %w", id, err) + } + return placement.StorageKey.String, nil +} + +func (s *ResourceService) UpdateOCRText(id, text string) error { + resourceUUID, _ := uuid.Parse(id) + r, err := s.queries.GetResource(context.Background(), resourceUUID) + if err != nil { + return fmt.Errorf("get resource: %w", err) + } + return s.queries.UpdateResource(context.Background(), db.UpdateResourceParams{ + Name: r.Name, + MimeType: r.MimeType, + OcrText: text, + ID: resourceUUID, + }) +} + +func (s *ResourceService) AddTags(resourceID string, tagNames []string) error { + resourceUUID, _ := uuid.Parse(resourceID) + for _, name := range tagNames { + tag, err := s.queries.GetTagByName(context.Background(), name) + if err == sql.ErrNoRows { + tag, err = s.queries.CreateTag(context.Background(), name) + if err != nil { + return fmt.Errorf("create tag %q: %w", name, err) + } + } else if err != nil { + return fmt.Errorf("get tag %q: %w", name, err) + } + + err = s.queries.AddTagToResource(context.Background(), db.AddTagToResourceParams{ + TagID: sql.NullString{String: tag.ID, Valid: true}, + ResourceID: sql.NullString{String: resourceUUID.String(), Valid: true}, + }) + if err != nil { + return fmt.Errorf("link tag %q to resource: %w", name, err) + } + } + return nil +} + +func (s *ResourceService) GetTagsByResourceID(resourceID string) ([]model.Tag, error) { + resourceUUID, _ := uuid.Parse(resourceID) + dbTags, err := s.queries.GetTagsByResourceID(context.Background(), sql.NullString{String: resourceUUID.String(), Valid: true}) + if err != nil { + return nil, fmt.Errorf("get tags: %w", err) + } + tags := make([]model.Tag, len(dbTags)) + for i, t := range dbTags { + tags[i] = model.Tag{ID: t.ID, Name: t.TagName} + } + return tags, nil +} + +func (s *ResourceService) MoveResources(resourceIDs []string, parentResourceID *string) error { + uuids := make([]uuid.UUID, len(resourceIDs)) + for i, id := range resourceIDs { + uuids[i], _ = uuid.Parse(id) + } + var parentID uuid.NullUUID + if parentResourceID != nil { + pid, _ := uuid.Parse(*parentResourceID) + parentID = uuid.NullUUID{UUID: pid, Valid: true} + } + return s.queries.MoveResources(context.Background(), db.MoveResourcesParams{ + ParentResourceID: parentID, + Column2: uuids, + }) +} + +func (s *ResourceService) CreateFolder(name, ownerID string) (*model.Resource, error) { + ownerUUID, _ := uuid.Parse(ownerID) + r, err := s.queries.CreateFolder(context.Background(), db.CreateFolderParams{ + Name: name, + OwnerID: ownerUUID, + }) + if err != nil { + return nil, fmt.Errorf("create folder: %w", err) + } + + if err := s.ensureOwnerRebac(r.ID, ownerUUID); err != nil { + return nil, fmt.Errorf("create owner rebac: %w", err) + } + + m := dbResourceToModel(r, nil) + return &m, nil +} + +func (s *ResourceService) ListFolders(ownerID string) ([]model.Resource, error) { + dbResources, err := s.queries.ListFolders(context.Background()) + if err != nil { + return nil, fmt.Errorf("list folders: %w", err) + } + folders := make([]model.Resource, len(dbResources)) + for i, r := range dbResources { + folders[i] = dbResourceToModel(r, nil) + } + return folders, nil +} + +func (s *ResourceService) ListResourcesByParentID(parentID, ownerID string) ([]model.Resource, error) { + parentUUID, _ := uuid.Parse(parentID) + ownerUUID, _ := uuid.Parse(ownerID) + dbResources, err := s.queries.ListResourcesByParentAndOwner(context.Background(), db.ListResourcesByParentAndOwnerParams{ + ParentResourceID: uuid.NullUUID{UUID: parentUUID, Valid: true}, + OwnerID: ownerUUID, + }) + if err != nil { + return nil, fmt.Errorf("list resources by parent: %w", err) + } + resources := make([]model.Resource, len(dbResources)) + for i, r := range dbResources { + tags, err := s.queries.GetTagsByResourceID(context.Background(), sql.NullString{String: r.ID.String(), Valid: true}) + if err != nil { + return nil, fmt.Errorf("get tags for resource %s: %w", r.ID, err) + } + resources[i] = dbResourceToModel(r, tags) + } + return resources, nil +} + +func (s *ResourceService) GetVariantsByResourceID(resourceID string) ([]model.Variant, error) { + resourceUUID, _ := uuid.Parse(resourceID) + dbVariants, err := s.queries.GetVariantsByResourceID(context.Background(), resourceUUID) + if err != nil { + return nil, fmt.Errorf("get variants: %w", err) + } + + variants := make([]model.Variant, len(dbVariants)) + for i, v := range dbVariants { + variants[i] = model.Variant{ + ID: v.ID.String(), + ResourceID: v.ResourceID.String(), + VariantType: v.VariantType, + PageNumber: int(v.PageNumber), + Width: int(v.Width), + Height: int(v.Height), + StorageKey: v.StorageKey, + MimeType: v.MimeType, + GeneratedBy: v.GeneratedBy, + CreatedAt: v.CreatedAt.String(), + } + } + return variants, nil +} + +func (s *ResourceService) GetVariantStoragePath(id string) (string, error) { + variantUUID, _ := uuid.Parse(id) + v, err := s.queries.GetVariantByID(context.Background(), variantUUID) + if err != nil { + return "", fmt.Errorf("get variant: %w", err) + } + return v.StorageKey, nil +} + +func (s *ResourceService) GetBestVariant(resourceID, preferredType string) *model.Variant { + resourceUUID, _ := uuid.Parse(resourceID) + dbVariants, err := s.queries.GetVariantsByResourceID(context.Background(), resourceUUID) + if err != nil || len(dbVariants) == 0 { + return nil + } + + var fallback *model.Variant + for _, v := range dbVariants { + if v.PageNumber != 1 { + continue + } + mv := &model.Variant{ + ID: v.ID.String(), + ResourceID: v.ResourceID.String(), + VariantType: v.VariantType, + PageNumber: int(v.PageNumber), + Width: int(v.Width), + Height: int(v.Height), + StorageKey: v.StorageKey, + MimeType: v.MimeType, + GeneratedBy: v.GeneratedBy, + CreatedAt: v.CreatedAt.String(), + } + if v.VariantType == preferredType { + return mv + } + if fallback == nil { + fallback = mv + } + } + return fallback +} + +func (s *ResourceService) FindDuplicatesByNameSize(name string, size int64) ([]db.FindDuplicatesByNameSizeRow, error) { + return s.queries.FindDuplicatesByNameSize(context.Background(), db.FindDuplicatesByNameSizeParams{ + Name: name, + Size: size, + }) +} + +func dbResourceToModel(r db.Resource, dbTags []db.Tag) model.Resource { + tags := make([]model.Tag, len(dbTags)) + for i, t := range dbTags { + tags[i] = model.Tag{ID: t.ID, Name: t.TagName} + } + + parentID := "" + if r.ParentResourceID.Valid { + parentID = r.ParentResourceID.UUID.String() + } + + return model.Resource{ + ID: r.ID.String(), + Name: r.Name, + MimeType: r.MimeType, + Size: r.Size, + Checksum: r.Checksum, + OcrText: r.OcrText, + IsFolder: r.IsFolder, + ParentResourceID: parentID, + OwnerID: r.OwnerID.String(), + Tags: tags, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func saveUploadedFile(file *multipart.FileHeader, dst string) error { + src, err := file.Open() + if err != nil { + return err + } + defer src.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + buf := make([]byte, 32*1024) + for { + n, readErr := src.Read(buf) + if n > 0 { + if _, writeErr := out.Write(buf[:n]); writeErr != nil { + return writeErr + } + } + if readErr != nil { + break + } + } + return nil +} diff --git a/backend/internal/service/sync.go b/backend/internal/service/sync.go new file mode 100644 index 0000000..0d2615e --- /dev/null +++ b/backend/internal/service/sync.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/vaultdrop/backend/internal/db" +) + +type SyncService struct { + queries *db.Queries +} + +func NewSyncService(queries *db.Queries) *SyncService { + return &SyncService{queries: queries} +} + +func (s *SyncService) EnqueueUpload(resourceID, locationID string) error { + resourceUUID, _ := uuid.Parse(resourceID) + locationUUID, _ := uuid.Parse(locationID) + _, err := s.queries.CreateSyncQueueItem(context.Background(), db.CreateSyncQueueItemParams{ + ResourceID: resourceUUID, + StorageLocationID: locationUUID, + Operation: "upload", + Status: "pending", + Attempts: 0, + }) + return err +} + +func (s *SyncService) EnqueueDownload(resourceID, locationID string) error { + resourceUUID, _ := uuid.Parse(resourceID) + locationUUID, _ := uuid.Parse(locationID) + _, err := s.queries.CreateSyncQueueItem(context.Background(), db.CreateSyncQueueItemParams{ + ResourceID: resourceUUID, + StorageLocationID: locationUUID, + Operation: "download", + Status: "pending", + Attempts: 0, + }) + return err +} + +func (s *SyncService) ListPending(locationID string) ([]db.SyncQueue, error) { + locationUUID, _ := uuid.Parse(locationID) + return s.queries.ListPendingSyncItemsByLocation(context.Background(), locationUUID) +} + +func (s *SyncService) ListAllPending() ([]db.SyncQueue, error) { + return s.queries.ListPendingSyncItems(context.Background()) +} + +func (s *SyncService) MarkCompleted(queueID string) error { + id, _ := uuid.Parse(queueID) + return s.queries.UpdateSyncQueueStatus(context.Background(), db.UpdateSyncQueueStatusParams{ + Status: "completed", + Attempts: 0, + ID: id, + }) +} + +func (s *SyncService) MarkFailed(queueID string, errMsg string) error { + id, _ := uuid.Parse(queueID) + item, err := s.queries.GetSyncQueueItem(context.Background(), id) + if err != nil { + return fmt.Errorf("get sync queue item: %w", err) + } + return s.queries.UpdateSyncQueueStatus(context.Background(), db.UpdateSyncQueueStatusParams{ + Status: "failed", + Attempts: int32(item.Attempts + 1), + ID: id, + }) +} diff --git a/backend/internal/service/url.go b/backend/internal/service/url.go index 5c55db3..af868fc 100644 --- a/backend/internal/service/url.go +++ b/backend/internal/service/url.go @@ -25,43 +25,43 @@ func NewURLService(secret, serverHost string, expiryMinutes int) *URLService { } } -func (s *URLService) sign(fileID string, expires int64) string { - data := fmt.Sprintf("%s:%d", fileID, expires) +func (s *URLService) sign(id string, expires int64) string { + data := fmt.Sprintf("%s:%d", id, expires) mac := hmac.New(sha256.New, []byte(s.secret)) mac.Write([]byte(data)) return hex.EncodeToString(mac.Sum(nil)) } -func (s *URLService) GenerateDownloadURL(fileUUID string) string { +func (s *URLService) GenerateDownloadURL(resourceUUID string) string { expires := time.Now().Add(s.expiryDuration).Unix() - sig := s.sign(fileUUID, expires) + sig := s.sign(resourceUUID, expires) return fmt.Sprintf( - "%s/api/v1/files/download/%s?expires=%d&sig=%s", + "%s/api/v1/resources/download/%s?expires=%d&sig=%s", s.serverHost, - fileUUID, + resourceUUID, expires, sig, ) } -func (s *URLService) GenerateThumbnailURL(thumbUUID string) string { +func (s *URLService) GenerateVariantURL(variantUUID string) string { expires := time.Now().Add(s.expiryDuration).Unix() - sig := s.sign(thumbUUID, expires) + sig := s.sign(variantUUID, expires) return fmt.Sprintf( - "%s/api/v1/thumbnails/%s?expires=%d&sig=%s", + "%s/api/v1/variants/%s?expires=%d&sig=%s", s.serverHost, - thumbUUID, + variantUUID, expires, sig, ) } -func (s *URLService) Validate(fileID, sig string, expires int64) bool { +func (s *URLService) Validate(id, sig string, expires int64) bool { if time.Now().Unix() > expires { return false } - expected := s.sign(fileID, expires) + expected := s.sign(id, expires) return hmac.Equal([]byte(sig), []byte(expected)) }