feat(api): GET /files/search (ILIKE substring insensible casse, wildcards échappés, scope owner, q obligatoire)

This commit is contained in:
m
2026-09-10 07:45:53 +02:00
parent 14c058fddb
commit 37ad2d4275
5 changed files with 118 additions and 2 deletions
+24 -1
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api" "github.com/vaultdrop/backend/pkg/api"
@@ -123,4 +124,26 @@ func FoldersList(c *gin.Context) {
api.OK(c, folders) 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)
}
+45
View File
@@ -242,6 +242,51 @@ func TestFilesFlow(t *testing.T) {
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-after-delete") 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) { func TestUploadTooLarge(t *testing.T) {
r, _, _ := setup(t) r, _, _ := setup(t)
device := repository.NewID() device := repository.NewID()
+36
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/lib/pq" "github.com/lib/pq"
@@ -197,6 +198,41 @@ func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
return resourceID, nil 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). // ListRootFolders returns the owner device's top-level folders (parent_id NULL).
func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) { func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
rows, err := r.DB.Query( rows, err := r.DB.Query(
+12
View File
@@ -89,6 +89,18 @@ func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) {
return folders, nil 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/<device> and // Upload persists the multipart-sourced file under UploadDir/<device> and
// records its metadata, returning the FileDTO. The physical file is removed // records its metadata, returning the FileDTO. The physical file is removed
// if metadata persistence fails (e.g. name conflict). // if metadata persistence fails (e.g. name conflict).
+1 -1
View File
@@ -127,4 +127,4 @@ type ResourcePermission = {
## 7. Codes d'erreur courants ## 7. Codes d'erreur courants
`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — é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é. `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_<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é.