feat(api): CRUD files/folders + upload multipart réels (scope owner, soft-delete, conteurs d'erreurs)
- repository.Resources (insert/list/get/soft-delete scoping owner_id, UNIQUE(parent_id,name) → NAME_CONFLICT) + Devices.Upsert au register
- service.Resources : DTOs {id,name,size,mimeType,folderId,createdAt,updatedAt}, upload → UPLOAD_DIR/<device>/<id>.<ext> (nettoyage si métadonnée échoue)
- handlers : files list/get/delete/upload + folders racines ; routes consolidées dans handlers.RegisterRoutes
- dbtest package : test DB jetée par repo (open+reset+migrate, skip si PG down) ; tests repository + handlers end-to-end (register→token→CRUD, scoping cross-device, FILE_TOO_LARGE, NAME_CONFLICT)
- docs: /devices persiste le device, layout UPLOAD_DIR par device, codes NAME_CONFLICT/SERVICE_UNAVAILABLE
This commit is contained in:
@@ -34,8 +34,8 @@ cd mobile && npm run test:db
|
|||||||
- `config/` — env (`godotenv`, optionnel) + defaults: `PORT`, `DATABASE_URL`, `UPLOAD_DIR`, `MAX_FILE_SIZE_MB`, `OCR_LANG`, secret paseto
|
- `config/` — env (`godotenv`, optionnel) + defaults: `PORT`, `DATABASE_URL`, `UPLOAD_DIR`, `MAX_FILE_SIZE_MB`, `OCR_LANG`, secret paseto
|
||||||
- `models/` — domain entities (users, devices, documents/resources, clients)
|
- `models/` — domain entities (users, devices, documents/resources, clients)
|
||||||
- `service/` — business logic (permissions, upload, create folder, move)
|
- `service/` — business logic (permissions, upload, create folder, move)
|
||||||
- `handlers/` — HTTP handlers (bind the routes; currently 501 not-implemented stubs)
|
- `handlers/` — HTTP handlers (health, devices register + paseto, files CRUD/upload — réels ; search/OCR/sync stubs 501)
|
||||||
- `repository/` — Postgres persistence (`golang-migrate` + `lib/pq`); IDs are TEXT 32-hex (never UUID conversion, cf. `docs/api-v1.md`)
|
- `repository/` — Postgres persistence réelle (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, `repository.Devices.Upsert`) ; IDs are TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`)
|
||||||
- `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible)
|
- `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible)
|
||||||
- `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`)
|
- `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`)
|
||||||
- Response helpers: `pkg/api/response.go`
|
- Response helpers: `pkg/api/response.go`
|
||||||
|
|||||||
+17
-29
@@ -9,37 +9,14 @@ import (
|
|||||||
"github.com/vaultdrop/backend/db"
|
"github.com/vaultdrop/backend/db"
|
||||||
"github.com/vaultdrop/backend/handlers"
|
"github.com/vaultdrop/backend/handlers"
|
||||||
"github.com/vaultdrop/backend/pkg/auth"
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
"github.com/vaultdrop/backend/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRouter() *gin.Engine {
|
func newRouter() *gin.Engine {
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
handlers.RegisterRoutes(r)
|
||||||
public := r.Group("/api/v1")
|
|
||||||
{
|
|
||||||
public.GET("/health", handlers.Health)
|
|
||||||
public.POST("/devices", handlers.DevicesRegister)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected := r.Group("/api/v1")
|
|
||||||
protected.Use(handlers.RequireDevice)
|
|
||||||
{
|
|
||||||
protected.GET("/files", handlers.FilesList)
|
|
||||||
protected.GET("/files/search", handlers.FilesSearch)
|
|
||||||
protected.GET("/files/:id", handlers.FilesGet)
|
|
||||||
protected.DELETE("/files/:id", handlers.FilesDelete)
|
|
||||||
protected.GET("/files/folders", handlers.FoldersList)
|
|
||||||
protected.POST("/files/upload", handlers.FilesUpload)
|
|
||||||
|
|
||||||
protected.POST("/ocr/jobs", handlers.OcrJobsCreate)
|
|
||||||
protected.GET("/ocr/jobs/:id", handlers.OcrJobsGet)
|
|
||||||
|
|
||||||
protected.POST("/sync/ops", handlers.SyncOpsPush)
|
|
||||||
protected.GET("/sync/permissions", handlers.SyncPermissionsGet)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -50,15 +27,26 @@ func main() {
|
|||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn, err := db.Open(cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("connexion postgres: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MigrateDatabase(cfg.DatabaseURL); err != nil {
|
||||||
|
log.Fatalf("migrations postgres: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
authManager, err := auth.NewManager(cfg.AuthSecret)
|
authManager, err := auth.NewManager(cfg.AuthSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
handlers.Auth = authManager
|
handlers.Auth = authManager
|
||||||
|
|
||||||
if err := db.MigrateDatabase(cfg.DatabaseURL); err != nil {
|
handlers.Store = service.NewResources(
|
||||||
log.Fatalf("migrations postgres: %v", err)
|
repository.NewRepository(conn),
|
||||||
}
|
cfg.UploadDir,
|
||||||
|
cfg.MaxFileSizeMB*1024*1024,
|
||||||
|
)
|
||||||
|
|
||||||
if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil {
|
if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package dbtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenTestDatabase ensures the test database exists, resets its schema, runs
|
||||||
|
// all migrations, and returns a live connection (closed via t.Cleanup).
|
||||||
|
// Tests are skipped when Postgres is unreachable. `databaseURL` empty falls
|
||||||
|
// back to TEST_DATABASE_URL, then to the global default.
|
||||||
|
func OpenTestDatabase(t *testing.T, databaseURL string) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case databaseURL == "":
|
||||||
|
databaseURL = os.Getenv("TEST_DATABASE_URL")
|
||||||
|
case os.Getenv("TEST_DATABASE_URL") != "":
|
||||||
|
databaseURL = os.Getenv("TEST_DATABASE_URL")
|
||||||
|
}
|
||||||
|
if databaseURL == "" {
|
||||||
|
databaseURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_test?sslmode=disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
probe, err := sql.Open("postgres", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
if err := probe.Ping(); err != nil {
|
||||||
|
probe.Close()
|
||||||
|
t.Skipf("postgres indisponible (%v) — lancez `docker compose up postgres -d`", err)
|
||||||
|
}
|
||||||
|
probe.Close()
|
||||||
|
|
||||||
|
ensureDatabase(t, databaseURL)
|
||||||
|
|
||||||
|
resetConn, err := sql.Open("postgres", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := resetConn.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil {
|
||||||
|
t.Fatalf("reset schema: %v", err)
|
||||||
|
}
|
||||||
|
resetConn.Close()
|
||||||
|
|
||||||
|
if err := db.MigrateDatabase(databaseURL); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := sql.Open("postgres", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
t.Fatalf("ping: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDatabase(t *testing.T, databaseURL string) {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := url.Parse(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse url: %v", err)
|
||||||
|
}
|
||||||
|
dbName := strings.TrimPrefix(parsed.Path, "/")
|
||||||
|
maintenance := *parsed
|
||||||
|
maintenance.Path = "/postgres"
|
||||||
|
|
||||||
|
conn, err := sql.Open("postgres", maintenance.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open maintenance db: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
t.Fatalf("ping maintenance db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
if err := conn.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, dbName).Scan(&exists); err != nil {
|
||||||
|
t.Fatalf("check db exists: %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if _, err := conn.Exec(fmt.Sprintf(`CREATE DATABASE %s`, pq.QuoteIdentifier(dbName))); err != nil {
|
||||||
|
t.Fatalf("create database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
"github.com/vaultdrop/backend/pkg/auth"
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
"github.com/vaultdrop/backend/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const authTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_auth_test?sslmode=disable"
|
||||||
|
|
||||||
func newTestRouterForAuth() *gin.Engine {
|
func newTestRouterForAuth() *gin.Engine {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -21,10 +27,20 @@ func newTestRouterForAuth() *gin.Engine {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setTestStore(t *testing.T) *repository.Repository {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, authTestURL)
|
||||||
|
repo := repository.NewRepository(conn)
|
||||||
|
Store = service.NewResources(repo, t.TempDir(), 1_048_576)
|
||||||
|
t.Cleanup(func() { Store = nil })
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
func TestDevicesRegisterValid(t *testing.T) {
|
func TestDevicesRegisterValid(t *testing.T) {
|
||||||
m, _ := auth.NewManager("test-secret")
|
m, _ := auth.NewManager("test-secret")
|
||||||
Auth = m
|
Auth = m
|
||||||
defer func() { Auth = nil }()
|
defer func() { Auth = nil }()
|
||||||
|
repo := setTestStore(t)
|
||||||
|
|
||||||
deviceID := "0123456789abcdef0123456789abcdef"
|
deviceID := "0123456789abcdef0123456789abcdef"
|
||||||
body := `{"deviceId":"` + deviceID + `"}`
|
body := `{"deviceId":"` + deviceID + `"}`
|
||||||
@@ -54,6 +70,12 @@ func TestDevicesRegisterValid(t *testing.T) {
|
|||||||
if err != nil || verified != deviceID {
|
if err != nil || verified != deviceID {
|
||||||
t.Errorf("token invalid: %v", err)
|
t.Errorf("token invalid: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le device est bien persisté (requis par les FK resources.owner_id).
|
||||||
|
exists, err := repo.Devices.Exists(deviceID)
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Errorf("device non persisté: exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
|
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
|
||||||
@@ -98,8 +120,9 @@ func TestRequireDeviceAcceptsValidToken(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+signed)
|
req.Header.Set("Authorization", "Bearer "+signed)
|
||||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||||
|
|
||||||
// FilesList is still a 501 stub — the point is it got past the middleware.
|
// Sans Store le handler répond SERVICE_UNAVAILABLE (503) — le point est
|
||||||
if rec.Code != 501 {
|
// que la requête a dépassé le middleware (jamais 401).
|
||||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
if rec.Code == 401 {
|
||||||
|
t.Fatalf("middleware a rejeté un token valide: %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ func DevicesRegister(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Store == nil || Store.Repository == nil {
|
||||||
|
api.Error(c, 503, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := Store.Repository.Devices.Upsert(req.DeviceID); err != nil {
|
||||||
|
api.Error(c, 500, "INTERNAL", "could not persist device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
token, err := Auth.Issue(req.DeviceID)
|
token, err := Auth.Issue(req.DeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
|
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
|
||||||
|
|||||||
+118
-4
@@ -1,12 +1,126 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/vaultdrop/backend/pkg/api"
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FilesList(c *gin.Context) { api.NotImplemented(c) }
|
func intParam(value string, fallback int) int {
|
||||||
func FilesGet(c *gin.Context) { api.NotImplemented(c) }
|
if value == "" {
|
||||||
func FilesDelete(c *gin.Context) { api.NotImplemented(c) }
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil || parsed < 1 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func FilesList(c *gin.Context) {
|
||||||
|
if Store == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.GetString(DeviceIDKey)
|
||||||
|
page := intParam(c.Query("page"), 1)
|
||||||
|
pageSize := intParam(c.Query("pageSize"), 50)
|
||||||
|
if pageSize > 200 {
|
||||||
|
pageSize = 200
|
||||||
|
}
|
||||||
|
files, total, err := Store.ListFiles(deviceID, c.Query("folderId"), page, pageSize, c.Query("sort"), c.Query("order"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.OKList(c, files, page, pageSize, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FilesGet(c *gin.Context) {
|
||||||
|
if Store == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.GetString(DeviceIDKey)
|
||||||
|
id := c.Param("id")
|
||||||
|
if !deviceIDPattern.MatchString(id) {
|
||||||
|
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := Store.GetFile(deviceID, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.OK(c, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FilesDelete(c *gin.Context) {
|
||||||
|
if Store == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.GetString(DeviceIDKey)
|
||||||
|
id := c.Param("id")
|
||||||
|
if !deviceIDPattern.MatchString(id) {
|
||||||
|
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deletedID, err := Store.DeleteFile(deviceID, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.OK(c, gin.H{"id": deletedID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FilesUpload(c *gin.Context) {
|
||||||
|
if Store == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.GetString(DeviceIDKey)
|
||||||
|
folderID := c.PostForm("folderId")
|
||||||
|
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1)
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxBytesErr) {
|
||||||
|
api.Error(c, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds the maximum allowed size")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "missing multipart field `file`")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.Size > Store.MaxFileSize {
|
||||||
|
api.Error(c, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds the maximum allowed size")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dto, err := Store.Upload(deviceID, file, folderID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.OK(c, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FoldersList(c *gin.Context) {
|
||||||
|
if Store == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.GetString(DeviceIDKey)
|
||||||
|
folders, err := Store.ListRootFolders(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.OK(c, folders)
|
||||||
|
}
|
||||||
|
|
||||||
func FilesSearch(c *gin.Context) { api.NotImplemented(c) }
|
func FilesSearch(c *gin.Context) { api.NotImplemented(c) }
|
||||||
func FilesUpload(c *gin.Context) { api.NotImplemented(c) }
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
|
"github.com/vaultdrop/backend/handlers"
|
||||||
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
"github.com/vaultdrop/backend/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlersTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_test?sslmode=disable"
|
||||||
|
|
||||||
|
type envelope struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Meta *struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
FolderID string `json:"folderId"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func setup(t *testing.T) (*gin.Engine, *service.Resources, *repository.Repository) {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, handlersTestURL)
|
||||||
|
repo := repository.NewRepository(conn)
|
||||||
|
// max 10 bytes pour couvrir FILE_TOO_LARGE en test
|
||||||
|
store := service.NewResources(repo, t.TempDir(), 10)
|
||||||
|
|
||||||
|
manager, err := auth.NewManager("test-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("auth manager: %v", err)
|
||||||
|
}
|
||||||
|
handlers.Auth = manager
|
||||||
|
handlers.Store = store
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
handlers.RegisterRoutes(r)
|
||||||
|
return r, store, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func doRequest(t *testing.T, r *gin.Engine, method, path, token string, body []byte, contentType string) (*httptest.ResponseRecorder, envelope) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
return rec, envelope{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectOK(t *testing.T, rec *httptest.ResponseRecorder, path string) envelope {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: attendu 200, got %d body=%s", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var env envelope
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("%s: unmarshal: %v body=%s", path, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, code, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Fatalf("%s: attendu %d, got %d body=%s", path, status, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var e apiError
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||||
|
t.Fatalf("%s: unmarshal error: %v body=%s", path, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if e.Code != code {
|
||||||
|
t.Errorf("%s: code erreur attendu %s, got %s", path, code, e.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerDevice(t *testing.T, r *gin.Engine, deviceID string) string {
|
||||||
|
t.Helper()
|
||||||
|
body := fmt.Sprintf(`{"deviceId":%q}`, deviceID)
|
||||||
|
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(body), "application/json")
|
||||||
|
var env struct {
|
||||||
|
Data struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("register: unmarshal: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if env.Data.Token == "" {
|
||||||
|
t.Fatalf("register: pas de token (status %d)", rec.Code)
|
||||||
|
}
|
||||||
|
return env.Data.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename string, content []byte) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(content); err != nil {
|
||||||
|
t.Fatalf("write body: %v", err)
|
||||||
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
if err := writer.WriteField("folderId", folderID); err != nil {
|
||||||
|
t.Fatalf("write folderId: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close writer: %v", err)
|
||||||
|
}
|
||||||
|
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/files/upload", token, body.Bytes(), writer.FormDataContentType())
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesFlow(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
deviceA := repository.NewID()
|
||||||
|
deviceB := repository.NewID()
|
||||||
|
tokenA := registerDevice(t, r, deviceA)
|
||||||
|
tokenB := registerDevice(t, r, deviceB)
|
||||||
|
|
||||||
|
folderID := repository.NewID()
|
||||||
|
if err := repo.Resources.InsertFolder(deviceA, folderID, "Docs", ""); err != nil {
|
||||||
|
t.Fatalf("insert root folder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload root + dossier
|
||||||
|
rec := uploadMultipart(t, r, tokenA, "", "hello.txt", []byte("hello world"))
|
||||||
|
env := expectOK(t, rec, "upload")
|
||||||
|
var uploaded fileDTO
|
||||||
|
if err := json.Unmarshal(env.Data, &uploaded); err != nil {
|
||||||
|
t.Fatalf("upload: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if uploaded.Name != "hello.txt" || uploaded.Size != 11 || uploaded.FolderID != "" || uploaded.ID == "" {
|
||||||
|
t.Errorf("FileDto inattendu: %+v", uploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc"))
|
||||||
|
env = expectOK(t, rec, "upload-doc")
|
||||||
|
var doc fileDTO
|
||||||
|
if err := json.Unmarshal(env.Data, &doc); err != nil {
|
||||||
|
t.Fatalf("upload-doc: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if doc.FolderID != folderID {
|
||||||
|
t.Errorf("folderId attendu %s, got %s", folderID, doc.FolderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same name, same folder → NAME_CONFLICT
|
||||||
|
rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc"))
|
||||||
|
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
|
||||||
|
|
||||||
|
// Création de fichiers du device B
|
||||||
|
if err := repo.Resources.InsertFile(deviceB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert B file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste racine (A)
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files", tokenA, nil, "")
|
||||||
|
env = expectOK(t, rec, "list")
|
||||||
|
var files []fileDTO
|
||||||
|
if err := json.Unmarshal(env.Data, &files); err != nil {
|
||||||
|
t.Fatalf("list: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 || files[0].ID != uploaded.ID {
|
||||||
|
t.Errorf("liste racine A: %+v", files)
|
||||||
|
}
|
||||||
|
if env.Meta == nil || env.Meta.Total != 1 {
|
||||||
|
t.Errorf("meta attendu total=1, got %+v", env.Meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste dans le dossier (A)
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?folderId="+folderID, tokenA, nil, "")
|
||||||
|
env = expectOK(t, rec, "list-folder")
|
||||||
|
files = nil
|
||||||
|
if err := json.Unmarshal(env.Data, &files); err != nil {
|
||||||
|
t.Fatalf("list-folder: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 || files[0].ID != doc.ID {
|
||||||
|
t.Errorf("liste dossier: %+v", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// B ne voit pas les fichiers de A
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenB, nil, "")
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-device")
|
||||||
|
|
||||||
|
// Get + delete côté A
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenA, nil, "")
|
||||||
|
env = expectOK(t, rec, "get")
|
||||||
|
var got fileDTO
|
||||||
|
if err := json.Unmarshal(env.Data, &got); err != nil {
|
||||||
|
t.Fatalf("get: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != uploaded.ID || got.MimeType != "text/plain" || got.CreatedAt == "" {
|
||||||
|
t.Errorf("get FileDto inattendu: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, _ = doRequest(t, r, http.MethodDelete, "/api/v1/files/"+uploaded.ID, tokenA, nil, "")
|
||||||
|
env = expectOK(t, rec, "delete")
|
||||||
|
var deleted struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &deleted); err != nil {
|
||||||
|
t.Fatalf("delete: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if deleted.ID != uploaded.ID {
|
||||||
|
t.Errorf("delete id: %+v", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenA, nil, "")
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-after-delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadTooLarge(t *testing.T) {
|
||||||
|
r, _, _ := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token := registerDevice(t, r, device)
|
||||||
|
|
||||||
|
rec := uploadMultipart(t, r, token, "", "big.txt", []byte("0123456789ABCDEF"))
|
||||||
|
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldersListAndScoping(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
deviceA := repository.NewID()
|
||||||
|
deviceB := repository.NewID()
|
||||||
|
tokenA := registerDevice(t, r, deviceA)
|
||||||
|
registerDevice(t, r, deviceB)
|
||||||
|
|
||||||
|
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "AA", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "BB", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.Resources.InsertFolder(deviceB, repository.NewID(), "CC", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/folders", tokenA, nil, "")
|
||||||
|
env := expectOK(t, rec, "folders")
|
||||||
|
var folders []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &folders); err != nil {
|
||||||
|
t.Fatalf("folders: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(folders) != 2 || folders[0].Name != "AA" || folders[1].Name != "BB" {
|
||||||
|
t.Errorf("folders A: %+v", folders)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/vaultdrop/backend/pkg/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func FoldersList(c *gin.Context) { api.NotImplemented(c) }
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
||||||
|
// Public: /health, /devices. Everything else requires a device bearer token.
|
||||||
|
func RegisterRoutes(r *gin.Engine) {
|
||||||
|
public := r.Group("/api/v1")
|
||||||
|
{
|
||||||
|
public.GET("/health", Health)
|
||||||
|
public.POST("/devices", DevicesRegister)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := r.Group("/api/v1")
|
||||||
|
protected.Use(RequireDevice)
|
||||||
|
{
|
||||||
|
protected.GET("/files", FilesList)
|
||||||
|
protected.GET("/files/search", FilesSearch)
|
||||||
|
protected.GET("/files/:id", FilesGet)
|
||||||
|
protected.DELETE("/files/:id", FilesDelete)
|
||||||
|
protected.GET("/files/folders", FoldersList)
|
||||||
|
protected.POST("/files/upload", FilesUpload)
|
||||||
|
|
||||||
|
protected.POST("/ocr/jobs", OcrJobsCreate)
|
||||||
|
protected.GET("/ocr/jobs/:id", OcrJobsGet)
|
||||||
|
|
||||||
|
protected.POST("/sync/ops", SyncOpsPush)
|
||||||
|
protected.GET("/sync/permissions", SyncPermissionsGet)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
"github.com/vaultdrop/backend/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the business layer used by handlers; set once at startup
|
||||||
|
// (cmd/server). Nil until then.
|
||||||
|
var Store *service.Resources
|
||||||
|
|
||||||
|
// writeError maps repository/service sentinels to contract error codes.
|
||||||
|
func writeError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrNotFound):
|
||||||
|
api.Error(c, 404, "NOT_FOUND", "resource not found")
|
||||||
|
case errors.Is(err, repository.ErrNameConflict):
|
||||||
|
api.Error(c, 409, "NAME_CONFLICT", "a resource with this name already exists here")
|
||||||
|
case errors.Is(err, service.FileTooLargeError):
|
||||||
|
api.Error(c, 413, "FILE_TOO_LARGE", "file exceeds the maximum allowed size")
|
||||||
|
default:
|
||||||
|
api.Error(c, 500, "INTERNAL", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Devices persists registered devices (owner scope for every resource row).
|
||||||
|
type Devices struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert registers the device if absent and refreshes last_seen_at.
|
||||||
|
func (d *Devices) Upsert(deviceID string) error {
|
||||||
|
_, err := d.DB.Exec(
|
||||||
|
`INSERT INTO devices (device_id, registered_at, last_seen_at)
|
||||||
|
VALUES ($1, NOW(), NOW())
|
||||||
|
ON CONFLICT (device_id) DO UPDATE SET last_seen_at = NOW()`,
|
||||||
|
deviceID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists reports whether a device has been registered.
|
||||||
|
func (d *Devices) Exists(deviceID string) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := d.DB.QueryRow(`SELECT 1 FROM devices WHERE device_id = $1`, deviceID).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository bundles all tables' repos.
|
||||||
|
type Repository struct {
|
||||||
|
Resources *Resources
|
||||||
|
Devices *Devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(conn *sql.DB) *Repository {
|
||||||
|
return &Repository{
|
||||||
|
Resources: &Resources{DB: conn},
|
||||||
|
Devices: &Devices{DB: conn},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is returned when a row is absent (or not owned by the scoping device).
|
||||||
|
var ErrNotFound = errors.New("resource not found")
|
||||||
|
|
||||||
|
// ErrNameConflict is returned when a sibling with the same name already exists.
|
||||||
|
var ErrNameConflict = errors.New("name conflict")
|
||||||
|
|
||||||
|
// NewID returns an opaque lowercase 32-hex identifier (charset ^[0-9a-f]{32}$),
|
||||||
|
// mirroring the mobile's lower(hex(randomblob(16))). Never a UUID.
|
||||||
|
func NewID() string {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
panic(fmt.Sprintf("crypto/rand failed: %v", err))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pqErr *pq.Error
|
||||||
|
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileRow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
MimeType string
|
||||||
|
FolderID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type FolderRow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
ParentID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resources implements resource persistence, always scoped by owner device.
|
||||||
|
type Resources struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortColumns maps contract `sort` values to safe SQL columns.
|
||||||
|
var sortColumns = map[string]string{
|
||||||
|
"size": "size_bytes",
|
||||||
|
"created_at": "created_at",
|
||||||
|
"updated_at": "updated_at",
|
||||||
|
"added_at": "created_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortClause(sort, order string) (string, string) {
|
||||||
|
column, ok := sortColumns[sort]
|
||||||
|
if !ok {
|
||||||
|
column = "created_at"
|
||||||
|
}
|
||||||
|
direction := "DESC"
|
||||||
|
if order == "asc" {
|
||||||
|
direction = "ASC"
|
||||||
|
}
|
||||||
|
return column, direction
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resources) folderExists(ownerID, folderID string) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT 1 FROM resources
|
||||||
|
WHERE resource_id = $1 AND owner_id = $2 AND type = 'folder' AND deleted_at IS NULL`,
|
||||||
|
folderID, ownerID,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resources) insert(ownerID, id, name, parentResourceID, resourceType string, size int64, mimeType, extension *string) error {
|
||||||
|
if parentResourceID != "" {
|
||||||
|
ok, err := r.folderExists(ownerID, parentResourceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("folder %s: %w", parentResourceID, ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var parentID any
|
||||||
|
if parentResourceID != "" {
|
||||||
|
parentID = parentResourceID
|
||||||
|
}
|
||||||
|
_, err := r.DB.Exec(
|
||||||
|
`INSERT INTO resources (resource_id, type, name, parent_id, owner_id, size_bytes, mime_type, extension)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
id, resourceType, name, parentID, ownerID, size, mimeType, extension,
|
||||||
|
)
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return ErrNameConflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertFile persists a file metadata row for the owner device. The parent
|
||||||
|
// folder (when non-empty) must belong to the same device.
|
||||||
|
func (r *Resources) InsertFile(ownerID, id, name, folderResourceID string, size int64, mimeType, extension *string) error {
|
||||||
|
return r.insert(ownerID, id, name, folderResourceID, "file", size, mimeType, extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertFolder persists a folder metadata row.
|
||||||
|
func (r *Resources) InsertFolder(ownerID, id, name, parentResourceID string) error {
|
||||||
|
return r.insert(ownerID, id, name, parentResourceID, "folder", 0, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileColumns = `resource_id, name, size_bytes, COALESCE(mime_type, ''), COALESCE(parent_id, ''), created_at, updated_at`
|
||||||
|
|
||||||
|
func (r *Resources) scanFile(scan func(...any) error) (FileRow, error) {
|
||||||
|
var row FileRow
|
||||||
|
err := scan(&row.ID, &row.Name, &row.Size, &row.MimeType, &row.FolderID, &row.CreatedAt, &row.UpdatedAt)
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles returns the owner device's files (optionally within a folder),
|
||||||
|
// plus the total count matching the filter.
|
||||||
|
func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) {
|
||||||
|
column, direction := sortClause(sort, order)
|
||||||
|
var folderFilter any
|
||||||
|
if folderResourceID != "" {
|
||||||
|
folderFilter = folderResourceID
|
||||||
|
}
|
||||||
|
where := `type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND ($2::text IS NULL OR parent_id = $2)`
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, folderFilter).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := r.DB.Query(
|
||||||
|
fmt.Sprintf(`SELECT %s FROM resources WHERE %s ORDER BY %s %s LIMIT $3 OFFSET $4`,
|
||||||
|
fileColumns, where, column, direction),
|
||||||
|
ownerID, folderFilter, limit, offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
files := make([]FileRow, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := r.scanFile(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
files = append(files, row)
|
||||||
|
}
|
||||||
|
return files, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
|
||||||
|
row := r.DB.QueryRow(
|
||||||
|
`SELECT `+fileColumns+` FROM resources
|
||||||
|
WHERE type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND resource_id = $2`,
|
||||||
|
ownerID, resourceID,
|
||||||
|
)
|
||||||
|
file, err := r.scanFile(row.Scan)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return FileRow{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return file, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile soft-deletes the file (deleted_at), returning its id.
|
||||||
|
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND resource_id = $2`,
|
||||||
|
ownerID, resourceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return resourceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRootFolders returns the owner device's top-level folders (parent_id NULL).
|
||||||
|
func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
|
||||||
|
rows, err := r.DB.Query(
|
||||||
|
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
||||||
|
WHERE type = 'folder' AND parent_id IS NULL AND deleted_at IS NULL AND owner_id = $1
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
ownerID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
folders := make([]FolderRow, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var folder FolderRow
|
||||||
|
if err := rows.Scan(&folder.ID, &folder.Name, &folder.ParentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
folders = append(folders, folder)
|
||||||
|
}
|
||||||
|
return folders, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
const repositoryTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_repository_test?sslmode=disable"
|
||||||
|
|
||||||
|
func newTestResources(t *testing.T) *Resources {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
|
||||||
|
return &Resources{DB: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustInsertDevice(t *testing.T, repo *Resources, deviceID string) {
|
||||||
|
t.Helper()
|
||||||
|
dev := &Devices{DB: repo.DB}
|
||||||
|
if err := dev.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("upsert device: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCRUDScopedByOwner(t *testing.T) {
|
||||||
|
repo := newTestResources(t)
|
||||||
|
owner := NewID()
|
||||||
|
other := NewID()
|
||||||
|
mustInsertDevice(t, repo, owner)
|
||||||
|
mustInsertDevice(t, repo, other)
|
||||||
|
|
||||||
|
folderID := NewID()
|
||||||
|
if err := repo.InsertFolder(owner, folderID, "Docs", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := "text/plain"
|
||||||
|
ext := "txt"
|
||||||
|
fileID := NewID()
|
||||||
|
if err := repo.InsertFile(owner, fileID, "note.txt", folderID, 42, &mime, &ext); err != nil {
|
||||||
|
t.Fatalf("insert file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.GetFile(owner, fileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get file: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "note.txt" || got.FolderID != folderID || got.Size != 42 {
|
||||||
|
t.Errorf("file row inattendu: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, total, err := repo.ListFiles(owner, folderID, 10, 0, "created_at", "desc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list files: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(files) != 1 || files[0].ID != fileID {
|
||||||
|
t.Errorf("list folder: total=%d files=%+v", total, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
roots, err := repo.ListRootFolders(owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list roots: %v", err)
|
||||||
|
}
|
||||||
|
if len(roots) != 1 || roots[0].ID != folderID {
|
||||||
|
t.Errorf("roots: %+v", roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.GetFile(other, fileID); err != ErrNotFound {
|
||||||
|
t.Errorf("autre device doit voir NOT_FOUND, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := repo.DeleteFile(owner, fileID)
|
||||||
|
if err != nil || deleted != fileID {
|
||||||
|
t.Fatalf("delete file: %v %v", deleted, err)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetFile(owner, fileID); err != ErrNotFound {
|
||||||
|
t.Errorf("après suppression: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNameConflictAndUnknownFolder(t *testing.T) {
|
||||||
|
repo := newTestResources(t)
|
||||||
|
owner := NewID()
|
||||||
|
mustInsertDevice(t, repo, owner)
|
||||||
|
|
||||||
|
folderID := NewID()
|
||||||
|
if err := repo.InsertFolder(owner, folderID, "Docs", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := "txt"
|
||||||
|
if err := repo.InsertFile(owner, NewID(), "note.txt", folderID, 1, nil, &ext); err != nil {
|
||||||
|
t.Fatalf("insert first file: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.InsertFile(owner, NewID(), "note.txt", folderID, 1, nil, nil); err != ErrNameConflict {
|
||||||
|
t.Errorf("même nom dans le même dossier doit être NAME_CONFLICT, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le même nom est autorisé dans un dossier différent (UNIQUE(parent_id, name)).
|
||||||
|
otherFolder := NewID()
|
||||||
|
if err := repo.InsertFolder(owner, otherFolder, "Other", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder 2: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.InsertFile(owner, NewID(), "note.txt", otherFolder, 1, nil, nil); err != nil {
|
||||||
|
t.Errorf("même nom dans un autre dossier : %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown := NewID()
|
||||||
|
err := repo.InsertFile(owner, NewID(), "x.txt", unknown, 1, nil, nil)
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("folder inconnu doit être NOT_FOUND, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileTooLargeError signals an upload above MaxFileSize.
|
||||||
|
var FileTooLargeError = errors.New("file too large")
|
||||||
|
|
||||||
|
// FileDTO serializes exactly as mobile/api/types.ts FileDto.
|
||||||
|
type FileDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
MimeType string `json:"mimeType,omitempty"`
|
||||||
|
FolderID string `json:"folderId,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderDTO serializes exactly as mobile/api/types.ts FolderDto.
|
||||||
|
type FolderDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ParentID string `json:"parentId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resources holds the business logic for files/folders list-get-delete-upload,
|
||||||
|
// always scoped by the requesting device.
|
||||||
|
type Resources struct {
|
||||||
|
Repo *repository.Resources
|
||||||
|
Repository *repository.Repository
|
||||||
|
UploadDir string
|
||||||
|
MaxFileSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResources(repo *repository.Repository, uploadDir string, maxFileSize int64) *Resources {
|
||||||
|
return &Resources{
|
||||||
|
Repo: repo.Resources,
|
||||||
|
Repository: repo,
|
||||||
|
UploadDir: uploadDir,
|
||||||
|
MaxFileSize: maxFileSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Resources) ListFiles(ownerID, folderID string, page, pageSize int, sort, order string) ([]FileDTO, int, error) {
|
||||||
|
rows, total, err := s.Repo.ListFiles(ownerID, folderID, pageSize, (page-1)*pageSize, sort, order)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
files := make([]FileDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
files = append(files, toFileDTO(row))
|
||||||
|
}
|
||||||
|
return files, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Resources) GetFile(ownerID, id string) (FileDTO, error) {
|
||||||
|
row, err := s.Repo.GetFile(ownerID, id)
|
||||||
|
if err != nil {
|
||||||
|
return FileDTO{}, err
|
||||||
|
}
|
||||||
|
return toFileDTO(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Resources) DeleteFile(ownerID, id string) (string, error) {
|
||||||
|
return s.Repo.DeleteFile(ownerID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) {
|
||||||
|
rows, err := s.Repo.ListRootFolders(ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
folders := make([]FolderDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
folders = append(folders, FolderDTO{ID: row.ID, Name: row.Name, ParentID: row.ParentID})
|
||||||
|
}
|
||||||
|
return folders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload persists the multipart-sourced file under UploadDir/<device> and
|
||||||
|
// records its metadata, returning the FileDTO. The physical file is removed
|
||||||
|
// if metadata persistence fails (e.g. name conflict).
|
||||||
|
func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID string) (FileDTO, error) {
|
||||||
|
if file.Size > s.MaxFileSize {
|
||||||
|
return FileDTO{}, FileTooLargeError
|
||||||
|
}
|
||||||
|
|
||||||
|
id := repository.NewID()
|
||||||
|
extension := strings.TrimPrefix(filepath.Ext(file.Filename), ".")
|
||||||
|
destDir := filepath.Join(s.UploadDir, ownerID)
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return FileDTO{}, fmt.Errorf("create upload dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(destDir, id+"."+extension)
|
||||||
|
if err := copyMultipart(file, destPath); err != nil {
|
||||||
|
return FileDTO{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := file.Header.Get("Content-Type")
|
||||||
|
if err := s.Repo.InsertFile(ownerID, id, file.Filename, folderID, file.Size, &mimeType, &extension); err != nil {
|
||||||
|
_ = os.Remove(destPath)
|
||||||
|
return FileDTO{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := s.Repo.GetFile(ownerID, id)
|
||||||
|
if err != nil {
|
||||||
|
return FileDTO{}, err
|
||||||
|
}
|
||||||
|
return toFileDTO(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMultipart(file *multipart.FileHeader, destPath string) error {
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open multipart file: %w", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create file: %w", err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return fmt.Errorf("copy upload: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFileDTO(row repository.FileRow) FileDTO {
|
||||||
|
return FileDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
Size: row.Size,
|
||||||
|
MimeType: row.MimeType,
|
||||||
|
FolderID: row.FolderID,
|
||||||
|
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -17,7 +17,7 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync)
|
|||||||
## 2. Identité et identifiants (invariants)
|
## 2. Identité et identifiants (invariants)
|
||||||
|
|
||||||
- **Device-first** : le device s'enregistre (`POST /devices`) avec son identité **générée localement** (`device_user_id` 32-hex mobile) et reçoit en échange un token **paseto** v4-local qu'il stocke. Requêtes suivantes : `Authorization: Bearer <token>` (toutes les routes **sauf `/health`**), résolu en `device_id` par middleware. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`).
|
- **Device-first** : le device s'enregistre (`POST /devices`) avec son identité **générée localement** (`device_user_id` 32-hex mobile) et reçoit en échange un token **paseto** v4-local qu'il stocke. Requêtes suivantes : `Authorization: Bearer <token>` (toutes les routes **sauf `/health`**), résolu en `device_id` par middleware. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`).
|
||||||
- La ré-émission est tolérée (le server décide de ré-énoncer un token ; la déduplication/persistance des devices arrive avec la table `devices`).
|
- Au register, le device est **upserté** dans `devices` (`last_seen_at` rafraîchi) ; chaque nouvelle requête avec token est l'occasion de rafraîchir `last_seen_at`. Une ressource ne peut être créée que par un device enregistré (`resources.owner_id` → `devices.device_id`, FK).
|
||||||
- **Identifiants** : `resource_id`, `device_user_id`, `token` de share-link = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK.
|
- **Identifiants** : `resource_id`, `device_user_id`, `token` de share-link = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK.
|
||||||
- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`).
|
- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`).
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?:
|
|||||||
|
|
||||||
- Multipart : champ `file` + `folderId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme).
|
- Multipart : champ `file` + `folderId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme).
|
||||||
- Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`.
|
- Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`.
|
||||||
- Le fichier physique est stocké sous `UPLOAD_DIR` ; la métadonnée est persistée en base et renvoyée en `FileDto`.
|
- Le fichier physique est stocké sous `UPLOAD_DIR/<device_id>/<resource_id>.<ext>` ; la métadonnée est persistée en base et renvoyée en `FileDto`. Si la persistance de la métadonnée échoue (ex. `NAME_CONFLICT`), le fichier physique est supprimé.
|
||||||
|
|
||||||
## 5. OCR
|
## 5. OCR
|
||||||
|
|
||||||
@@ -127,4 +127,4 @@ type ResourcePermission = {
|
|||||||
|
|
||||||
## 7. Codes d'erreur courants
|
## 7. Codes d'erreur courants
|
||||||
|
|
||||||
`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites), `FILE_TOO_LARGE`, `NETWORK_ERROR` (côté client), `HTTP_<status>` (fallback). Le serveur doit répondre 501 `{ "error": { "code": "NOT_IMPLEMENTED", "message": "…" } }` sur toute route encore en queue.
|
`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — état actuel : files CRUD/upload, devices, health, folders sont réels ; `search`, `ocr/*`, `sync/*` en queue), `FILE_TOO_LARGE` (413), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`), `NETWORK_ERROR` (côté client), `HTTP_<status>` (fallback). Le serveur doit répondre 501 `{ "error": { "code": "NOT_IMPLEMENTED", "message": "…" } }` sur toute route encore en queue. Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.
|
||||||
Reference in New Issue
Block a user