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
|
||||
- `models/` — domain entities (users, devices, documents/resources, clients)
|
||||
- `service/` — business logic (permissions, upload, create folder, move)
|
||||
- `handlers/` — HTTP handlers (bind the routes; currently 501 not-implemented stubs)
|
||||
- `repository/` — Postgres persistence (`golang-migrate` + `lib/pq`); IDs are TEXT 32-hex (never UUID conversion, cf. `docs/api-v1.md`)
|
||||
- `handlers/` — HTTP handlers (health, devices register + paseto, files CRUD/upload — réels ; search/OCR/sync stubs 501)
|
||||
- `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)
|
||||
- `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`)
|
||||
- Response helpers: `pkg/api/response.go`
|
||||
|
||||
+17
-29
@@ -9,37 +9,14 @@ import (
|
||||
"github.com/vaultdrop/backend/db"
|
||||
"github.com/vaultdrop/backend/handlers"
|
||||
"github.com/vaultdrop/backend/pkg/auth"
|
||||
"github.com/vaultdrop/backend/repository"
|
||||
"github.com/vaultdrop/backend/service"
|
||||
)
|
||||
|
||||
func newRouter() *gin.Engine {
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
handlers.RegisterRoutes(r)
|
||||
return r
|
||||
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -50,15 +27,26 @@ func main() {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
handlers.Auth = authManager
|
||||
|
||||
if err := db.MigrateDatabase(cfg.DatabaseURL); err != nil {
|
||||
log.Fatalf("migrations postgres: %v", err)
|
||||
}
|
||||
handlers.Store = service.NewResources(
|
||||
repository.NewRepository(conn),
|
||||
cfg.UploadDir,
|
||||
cfg.MaxFileSizeMB*1024*1024,
|
||||
)
|
||||
|
||||
if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil {
|
||||
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"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/vaultdrop/backend/dbtest"
|
||||
"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 {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -21,10 +27,20 @@ func newTestRouterForAuth() *gin.Engine {
|
||||
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) {
|
||||
m, _ := auth.NewManager("test-secret")
|
||||
Auth = m
|
||||
defer func() { Auth = nil }()
|
||||
repo := setTestStore(t)
|
||||
|
||||
deviceID := "0123456789abcdef0123456789abcdef"
|
||||
body := `{"deviceId":"` + deviceID + `"}`
|
||||
@@ -54,6 +70,12 @@ func TestDevicesRegisterValid(t *testing.T) {
|
||||
if err != nil || verified != deviceID {
|
||||
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) {
|
||||
@@ -98,8 +120,9 @@ func TestRequireDeviceAcceptsValidToken(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+signed)
|
||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||
|
||||
// FilesList is still a 501 stub — the point is it got past the middleware.
|
||||
if rec.Code != 501 {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
// Sans Store le handler répond SERVICE_UNAVAILABLE (503) — le point est
|
||||
// que la requête a dépassé le middleware (jamais 401).
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
|
||||
|
||||
+118
-4
@@ -1,12 +1,126 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/vaultdrop/backend/pkg/api"
|
||||
)
|
||||
|
||||
func FilesList(c *gin.Context) { api.NotImplemented(c) }
|
||||
func FilesGet(c *gin.Context) { api.NotImplemented(c) }
|
||||
func FilesDelete(c *gin.Context) { api.NotImplemented(c) }
|
||||
func intParam(value string, fallback int) int {
|
||||
if value == "" {
|
||||
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 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)
|
||||
|
||||
- **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.
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
@@ -127,4 +127,4 @@ type ResourcePermission = {
|
||||
|
||||
## 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