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:
m
2026-09-10 07:44:55 +02:00
parent 484498d989
commit 14c058fddb
16 changed files with 1153 additions and 49 deletions
+2 -2
View File
@@ -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
View File
@@ -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)
+97
View File
@@ -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)
}
}
}
+26 -3
View File
@@ -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 stubthe 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())
}
}
+9
View File
@@ -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
View File
@@ -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) }
+283
View File
@@ -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)
}
}
-8
View File
@@ -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) }
+32
View File
@@ -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)
}
}
+28
View File
@@ -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())
}
}
+31
View File
@@ -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
}
+18
View File
@@ -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},
}
}
+221
View File
@@ -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()
}
+114
View File
@@ -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)
}
}
+154
View File
@@ -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
View File
@@ -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é.