diff --git a/AGENTS.md b/AGENTS.md index e1601f5..0a0a8d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e120e53..0ab7566 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/dbtest/dbtest.go b/backend/dbtest/dbtest.go new file mode 100644 index 0000000..201dc26 --- /dev/null +++ b/backend/dbtest/dbtest.go @@ -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) + } + } +} diff --git a/backend/handlers/auth_test.go b/backend/handlers/auth_test.go index 6c122a1..5195fff 100644 --- a/backend/handlers/auth_test.go +++ b/backend/handlers/auth_test.go @@ -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()) } } diff --git a/backend/handlers/devices.go b/backend/handlers/devices.go index 4b8ac9d..07f8aff 100644 --- a/backend/handlers/devices.go +++ b/backend/handlers/devices.go @@ -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") diff --git a/backend/handlers/files.go b/backend/handlers/files.go index ad1b6e5..8097e96 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -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) } diff --git a/backend/handlers/files_test.go b/backend/handlers/files_test.go new file mode 100644 index 0000000..2204b06 --- /dev/null +++ b/backend/handlers/files_test.go @@ -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) + } +} diff --git a/backend/handlers/folders.go b/backend/handlers/folders.go deleted file mode 100644 index 14a3623..0000000 --- a/backend/handlers/folders.go +++ /dev/null @@ -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) } diff --git a/backend/handlers/router.go b/backend/handlers/router.go new file mode 100644 index 0000000..2e4192e --- /dev/null +++ b/backend/handlers/router.go @@ -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) + } +} diff --git a/backend/handlers/store.go b/backend/handlers/store.go new file mode 100644 index 0000000..7a44830 --- /dev/null +++ b/backend/handlers/store.go @@ -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()) + } +} diff --git a/backend/repository/devices.go b/backend/repository/devices.go new file mode 100644 index 0000000..295bd56 --- /dev/null +++ b/backend/repository/devices.go @@ -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 +} diff --git a/backend/repository/repository.go b/backend/repository/repository.go new file mode 100644 index 0000000..e5efbb9 --- /dev/null +++ b/backend/repository/repository.go @@ -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}, + } +} diff --git a/backend/repository/resources.go b/backend/repository/resources.go new file mode 100644 index 0000000..e3a2644 --- /dev/null +++ b/backend/repository/resources.go @@ -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() +} diff --git a/backend/repository/resources_test.go b/backend/repository/resources_test.go new file mode 100644 index 0000000..a77ce2a --- /dev/null +++ b/backend/repository/resources_test.go @@ -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) + } +} diff --git a/backend/service/resources.go b/backend/service/resources.go new file mode 100644 index 0000000..85864b0 --- /dev/null +++ b/backend/service/resources.go @@ -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/ 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), + } +} diff --git a/docs/api-v1.md b/docs/api-v1.md index 867c5eb..96e6178 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -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 ` (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//.` ; 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_` (fallback). Le serveur doit répondre 501 `{ "error": { "code": "NOT_IMPLEMENTED", "message": "…" } }` sur toute route encore en queue. \ No newline at end of file +`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_` (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é. \ No newline at end of file