From 37ad2d42758d95fc5aa3ebdc0d0550b5c32ffaee Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 07:45:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20GET=20/files/search=20(ILIKE=20sub?= =?UTF-8?q?string=20insensible=20casse,=20wildcards=20=C3=A9chapp=C3=A9s,?= =?UTF-8?q?=20scope=20owner,=20q=20obligatoire)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/handlers/files.go | 25 +++++++++++++++++- backend/handlers/files_test.go | 45 +++++++++++++++++++++++++++++++++ backend/repository/resources.go | 36 ++++++++++++++++++++++++++ backend/service/resources.go | 12 +++++++++ docs/api-v1.md | 2 +- 5 files changed, 118 insertions(+), 2 deletions(-) diff --git a/backend/handlers/files.go b/backend/handlers/files.go index 8097e96..1ca19f0 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/vaultdrop/backend/pkg/api" @@ -123,4 +124,26 @@ func FoldersList(c *gin.Context) { api.OK(c, folders) } -func FilesSearch(c *gin.Context) { api.NotImplemented(c) } +func FilesSearch(c *gin.Context) { + if Store == nil { + api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") + return + } + deviceID := c.GetString(DeviceIDKey) + q := strings.TrimSpace(c.Query("q")) + if q == "" { + api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "missing required query param `q`") + return + } + page := intParam(c.Query("page"), 1) + pageSize := intParam(c.Query("pageSize"), 50) + if pageSize > 200 { + pageSize = 200 + } + files, total, err := Store.SearchFiles(deviceID, q, page, pageSize) + if err != nil { + writeError(c, err) + return + } + api.OKList(c, files, page, pageSize, total) +} diff --git a/backend/handlers/files_test.go b/backend/handlers/files_test.go index 2204b06..6f31389 100644 --- a/backend/handlers/files_test.go +++ b/backend/handlers/files_test.go @@ -242,6 +242,51 @@ func TestFilesFlow(t *testing.T) { expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-after-delete") } +func TestSearchFiles(t *testing.T) { + r, _, repo := setup(t) + device := repository.NewID() + token := registerDevice(t, r, device) + + if err := repo.Resources.InsertFile(device, repository.NewID(), "vacances-août.jpg", "", 100, nil, nil); err != nil { + t.Fatalf("insert: %v", err) + } + if err := repo.Resources.InsertFile(device, repository.NewID(), "rapport-q3.pdf", "", 100, nil, nil); err != nil { + t.Fatalf("insert: %v", err) + } + if err := repo.Resources.InsertFile(device, repository.NewID(), "toto.txt", "", 100, nil, nil); err != nil { + t.Fatalf("insert: %v", err) + } + + // q obligatoire + rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/search", token, nil, "") + expectError(t, rec, http.StatusBadRequest, "INVALID_REQUEST", "search-no-q") + + // insensible à la casse + sous-chaîne + rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/search?q=APORT", token, nil, "") + env := expectOK(t, rec, "search") + var files []fileDTO + if err := json.Unmarshal(env.Data, &files); err != nil { + t.Fatalf("search: unmarshal: %v", err) + } + if len(files) != 1 || files[0].Name != "rapport-q3.pdf" { + t.Errorf("search 'APORT': %+v", files) + } + if env.Meta == nil || env.Meta.Total != 1 { + t.Errorf("meta search: %+v", env.Meta) + } + + // wildcards neutralisés (trouve que "toto", pas tous les fichiers) + rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/search?q=%25", token, nil, "") + env = expectOK(t, rec, "search-escaped") + files = nil + if err := json.Unmarshal(env.Data, &files); err != nil { + t.Fatalf("search-escaped: unmarshal: %v", err) + } + if len(files) != 0 { + t.Errorf("q=%% doit ne rien matcher, got %+v", files) + } +} + func TestUploadTooLarge(t *testing.T) { r, _, _ := setup(t) device := repository.NewID() diff --git a/backend/repository/resources.go b/backend/repository/resources.go index e3a2644..1b22760 100644 --- a/backend/repository/resources.go +++ b/backend/repository/resources.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "strings" "time" "github.com/lib/pq" @@ -197,6 +198,41 @@ func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) { return resourceID, nil } +// SearchFiles returns the owner device's files whose name matches q +// (case-insensitive substring, wildcards escaped), plus the total count. +func (r *Resources) SearchFiles(ownerID, q string, limit, offset int) ([]FileRow, int, error) { + pattern := `%` + escapeLike(q) + `%` + where := `type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND name ILIKE $2 ESCAPE '\'` + + var total int + if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, pattern).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := r.DB.Query( + fmt.Sprintf(`SELECT %s FROM resources WHERE %s ORDER BY name ASC LIMIT $3 OFFSET $4`, + fileColumns, where), + ownerID, pattern, 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 escapeLike(q string) string { + replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return replacer.Replace(q) +} + // 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( diff --git a/backend/service/resources.go b/backend/service/resources.go index 85864b0..8a05879 100644 --- a/backend/service/resources.go +++ b/backend/service/resources.go @@ -89,6 +89,18 @@ func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) { return folders, nil } +func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDTO, int, error) { + rows, total, err := s.Repo.SearchFiles(ownerID, q, pageSize, (page-1)*pageSize) + 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 +} + // 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). diff --git a/docs/api-v1.md b/docs/api-v1.md index 96e6178..d1f953a 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -127,4 +127,4 @@ type ResourcePermission = { ## 7. Codes d'erreur courants -`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 +`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — état actuel : files CRUD/upload/search, folders, devices, health sont réels ; `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