feat(api): GET /files/search (ILIKE substring insensible casse, wildcards échappés, scope owner, q obligatoire)
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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é.
|
||||||
Reference in New Issue
Block a user