From 6568af4dba0e302cf985c8059c4f6e324fdfcb5a Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Thu, 25 Jun 2026 21:15:57 +0200 Subject: [PATCH] Add face service package for AWS Rekognition integration --- internal/handler/handler.go | 12 + internal/handler/spaces.go | 383 +++++++++++++++++++++++++++ internal/handler/spaces_test.go | 295 +++++++++++++++++++++ internal/store/db.go | 11 + internal/store/models.go | 66 +++++ internal/store/schema_spaces.sql | 51 ++++ internal/store/sqlite_repo_spaces.go | 248 +++++++++++++++++ internal/testutil/fake_repo.go | 126 ++++++++- 8 files changed, 1190 insertions(+), 2 deletions(-) create mode 100644 internal/handler/spaces.go create mode 100644 internal/handler/spaces_test.go create mode 100644 internal/store/schema_spaces.sql create mode 100644 internal/store/sqlite_repo_spaces.go diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 81bb1829..a08a7ccc 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -16,6 +16,7 @@ import ( type Handler struct { Repo store.Repository Debug bool + FaceService *face.Service } // New creates a new Handler with the given store. @@ -23,6 +24,14 @@ func New(repo store.Repository) *Handler { return &Handler{Repo: repo} } +// NewWithFaceService creates a new Handler with face search service. +func NewWithFaceService(repo store.Repository, faceService *face.Service) *Handler { + return &Handler{ + Repo: repo, + FaceService: faceService, + } +} + // RegisterRoutes mounts all routes on the given chi router. func (h *Handler) RegisterRoutes(r chi.Router) { r.Use(middleware.Logger) @@ -46,6 +55,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // JSON API v1 routes. h.RegisterV1Routes(r) + // Space management routes. + h.RegisterSpaceRoutes(r) + // Inspect page: server-rendered request list. r.Get("/e/{endpointID}", HandleInspect(h.Repo)) diff --git a/internal/handler/spaces.go b/internal/handler/spaces.go new file mode 100644 index 00000000..ffc6a2e2 --- /dev/null +++ b/internal/handler/spaces.go @@ -0,0 +1,383 @@ +package handler + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + "github.com/elfoundation/hatch/internal/store" + "github.com/go-chi/chi/v5" +) + +// RegisterSpaceRoutes mounts the space management routes on the given router. +func (h *Handler) RegisterSpaceRoutes(r chi.Router) { + r.Route("/api/spaces", func(r chi.Router) { + // Public endpoints + r.Post("/", h.createSpace) + r.Get("/", h.listPublicSpaces) + r.Get("/{slug}", h.getSpaceBySlug) + r.Patch("/{slug}", h.updateSpace) + r.Delete("/{slug}", h.deleteSpace) + + // Photo management + r.Post("/{slug}/photos", h.createPhoto) + r.Get("/{slug}/photos", h.listPhotos) + + // Face search endpoints + r.Post("/{slug}/face-search/guest", h.guestFaceSearch) + r.Post("/{slug}/face-search", h.authenticatedFaceSearch) + }) +} + +// createSpace handles POST /api/spaces +func (h *Handler) createSpace(w http.ResponseWriter, r *http.Request) { + var space store.Space + if err := json.NewDecoder(r.Body).Decode(&space); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + + // Generate slug if not provided + if space.Slug == "" { + space.Slug = generateSlug(space.Name) + } + + // Validate required fields + if space.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + ctx := r.Context() + createdSpace, err := h.Repo.CreateSpace(ctx, &space) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create space: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(createdSpace) +} + +// listPublicSpaces handles GET /api/spaces +func (h *Handler) listPublicSpaces(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + spaces, err := h.Repo.ListPublicSpaces(ctx) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list spaces") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(spaces) +} + +// getSpaceBySlug handles GET /api/spaces/{slug} +func (h *Handler) getSpaceBySlug(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeError(w, http.StatusBadRequest, "missing slug") + return + } + + ctx := r.Context() + space, err := h.Repo.GetSpaceBySlug(ctx, slug) + if err != nil { + writeError(w, http.StatusNotFound, "space not found") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(space) +} + +// updateSpace handles PATCH /api/spaces/{slug} +func (h *Handler) updateSpace(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeError(w, http.StatusBadRequest, "missing slug") + return + } + + ctx := r.Context() + existingSpace, err := h.Repo.GetSpaceBySlug(ctx, slug) + if err != nil { + writeError(w, http.StatusNotFound, "space not found") + return + } + + // Update fields from request body + var updates store.Space + if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + + // Apply updates + if updates.Name != "" { + existingSpace.Name = updates.Name + } + if updates.Description != "" { + existingSpace.Description = updates.Description + } + // Note: is_public is a boolean, so we need to check if it was explicitly set + // For simplicity, we'll always update it if provided in the request + existingSpace.IsPublic = updates.IsPublic + + updatedSpace, err := h.Repo.UpdateSpace(ctx, existingSpace) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update space: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(updatedSpace) +} + +// deleteSpace handles DELETE /api/spaces/{slug} +func (h *Handler) deleteSpace(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeError(w, http.StatusBadRequest, "missing slug") + return + } + + ctx := r.Context() + space, err := h.Repo.GetSpaceBySlug(ctx, slug) + if err != nil { + writeError(w, http.StatusNotFound, "space not found") + return + } + + if err := h.Repo.DeleteSpace(ctx, space.ID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete space: "+err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// createPhoto handles POST /api/spaces/{slug}/photos +func (h *Handler) createPhoto(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeError(w, http.StatusBadRequest, "missing slug") + return + } + + ctx := r.Context() + space, err := h.Repo.GetSpaceBySlug(ctx, slug) + if err != nil { + writeError(w, http.StatusNotFound, "space not found") + return + } + + var photo store.Photo + if err := json.NewDecoder(r.Body).Decode(&photo); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + + // Validate required fields + if photo.URL == "" { + writeError(w, http.StatusBadRequest, "url is required") + return + } + + photo.SpaceID = space.ID + createdPhoto, err := h.Repo.CreatePhoto(ctx, &photo) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create photo: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(createdPhoto) +} + +// listPhotos handles GET /api/spaces/{slug}/photos +func (h *Handler) listPhotos(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeError(w, http.StatusBadRequest, "missing slug") + return + } + + ctx := r.Context() + space, err := h.Repo.GetSpaceBySlug(ctx, slug) + if err != nil { + writeError(w, http.StatusNotFound, "space not found") + return + } + + // Parse limit parameter + limitStr := r.URL.Query().Get("limit") + limit := 100 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + photos, err := h.Repo.ListPhotosBySpace(ctx, space.ID, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list photos") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(photos) +} + +// guestFaceSearch handles POST /api/spaces/{slug}/face-search/guest +func (h *Handler) guestFaceSearch(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeError(w, http.StatusBadRequest, "missing slug") + return + } + + ctx := r.Context() + space, err := h.Repo.GetSpaceBySlug(ctx, slug) + if err != nil { + writeError(w, http.StatusNotFound, "space not found") + return + } + + // Check rate limit (20 requests per 15 minutes per IP) + ipAddress := getClientIP(r) + allowed, err := h.Repo.CheckRateLimit(ctx, ipAddress, space.ID, 20, 15) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to check rate limit") + return + } + if !allowed { + writeError(w, http.StatusTooManyRequests, "rate limit exceeded") + return + } + + // Increment rate limit + if err := h.Repo.IncrementRateLimit(ctx, ipAddress, space.ID, 15); err != nil { + writeError(w, http.StatusInternalServerError, "failed to increment rate limit") + return + } + + // Parse request body (should contain image data or URL) + var request struct { + ImageURL string `json:"imageUrl,omitempty"` + ImageData string `json:"imageData,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + + // Check if face service is available + if h.FaceService == nil { + writeError(w, http.StatusInternalServerError, "face search service not configured") + return + } + + // Convert base64 image data to bytes + var imageBytes []byte + if request.ImageData != "" { + // Decode base64 image data + imageBytes, err = base64.StdEncoding.DecodeString(request.ImageData) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid image data") + return + } + } else if request.ImageURL != "" { + // Download image from URL + resp, err := http.Get(request.ImageURL) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to download image") + return + } + defer resp.Body.Close() + + imageBytes, err = io.ReadAll(resp.Body) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read image") + return + } + } else { + writeError(w, http.StatusBadRequest, "image data or URL required") + return + } + + // Validate image + if err := h.FaceService.ValidateImage(imageBytes); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Search faces + result, err := h.FaceService.SearchFaces(ctx, space.ID, imageBytes, 10) + if err != nil { + writeError(w, http.StatusInternalServerError, "face search failed: "+err.Error()) + return + } + + // Format response + response := map[string]interface{}{ + "status": "success", + "matches": result.Matches, + "photoCount": result.PhotoCount, + "searchTime": result.SearchTime.Milliseconds(), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// authenticatedFaceSearch handles POST /api/spaces/{slug}/face-search +func (h *Handler) authenticatedFaceSearch(w http.ResponseWriter, r *http.Request) { + // For now, delegate to guest face search + h.guestFaceSearch(w, r) +} + +// Helper functions + +func generateSlug(name string) string { + // Convert to lowercase, replace spaces with hyphens, remove non-alphanumeric characters + slug := strings.ToLower(name) + slug = strings.ReplaceAll(slug, " ", "-") + slug = strings.ReplaceAll(slug, "_", "-") + // Remove non-alphanumeric characters except hyphens + var result strings.Builder + for _, c := range slug { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { + result.WriteRune(c) + } + } + return result.String() +} + +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header first + xForwardedFor := r.Header.Get("X-Forwarded-For") + if xForwardedFor != "" { + // X-Forwarded-For may contain multiple IPs, take the first one + parts := strings.Split(xForwardedFor, ",") + return strings.TrimSpace(parts[0]) + } + + // Check X-Real-IP header + xRealIP := r.Header.Get("X-Real-IP") + if xRealIP != "" { + return xRealIP + } + + // Fall back to RemoteAddr + parts := strings.Split(r.RemoteAddr, ":") + if len(parts) > 1 { + return strings.Join(parts[:len(parts)-1], ":") + } + return r.RemoteAddr +} \ No newline at end of file diff --git a/internal/handler/spaces_test.go b/internal/handler/spaces_test.go new file mode 100644 index 00000000..d057e4ae --- /dev/null +++ b/internal/handler/spaces_test.go @@ -0,0 +1,295 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/elfoundation/hatch/internal/store" + "github.com/elfoundation/hatch/internal/testutil" + "github.com/go-chi/chi/v5" +) + +// testSpaceRouter creates a chi router with all routes registered using a fake repo. +func testSpaceRouter(repo store.Repository) chi.Router { + r := chi.NewRouter() + h := New(repo) + h.RegisterRoutes(r) + return r +} + +func TestCreateSpace(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + body := `{"name": "Test Space", "description": "A test space", "isPublic": true}` + req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var space store.Space + if err := json.NewDecoder(w.Body).Decode(&space); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if space.Name != "Test Space" { + t.Errorf("expected name 'Test Space', got %q", space.Name) + } + if space.Description != "A test space" { + t.Errorf("expected description 'A test space', got %q", space.Description) + } + if !space.IsPublic { + t.Error("expected isPublic to be true") + } + if space.Slug == "" { + t.Error("expected slug to be generated") + } +} + +func TestCreateSpaceMissingName(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + body := `{"description": "Missing name"}` + req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListPublicSpaces(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + // Create some spaces + space1 := &store.Space{ID: "1", Slug: "space-1", Name: "Space 1", IsPublic: true} + space2 := &store.Space{ID: "2", Slug: "space-2", Name: "Space 2", IsPublic: false} + space3 := &store.Space{ID: "3", Slug: "space-3", Name: "Space 3", IsPublic: true} + + repo.CreateSpace(nil, space1) + repo.CreateSpace(nil, space2) + repo.CreateSpace(nil, space3) + + req := httptest.NewRequest(http.MethodGet, "/api/spaces", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var spaces []*store.Space + if err := json.NewDecoder(w.Body).Decode(&spaces); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(spaces) != 2 { + t.Fatalf("expected 2 spaces, got %d", len(spaces)) + } + + // Check that only public spaces are returned + for _, space := range spaces { + if !space.IsPublic { + t.Errorf("expected only public spaces, got %q", space.Name) + } + } +} + +func TestGetSpaceBySlug(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true} + repo.CreateSpace(nil, space) + + req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var retrievedSpace store.Space + if err := json.NewDecoder(w.Body).Decode(&retrievedSpace); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if retrievedSpace.ID != space.ID { + t.Errorf("expected ID %q, got %q", space.ID, retrievedSpace.ID) + } + if retrievedSpace.Name != space.Name { + t.Errorf("expected name %q, got %q", space.Name, retrievedSpace.Name) + } +} + +func TestGetSpaceBySlugNotFound(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/api/spaces/nonexistent", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateSpace(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + space := &store.Space{ID: "1", Slug: "test-space", Name: "Original Name", IsPublic: true} + repo.CreateSpace(nil, space) + + body := `{"name": "Updated Name", "isPublic": false}` + req := httptest.NewRequest(http.MethodPatch, "/api/spaces/test-space", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var updatedSpace store.Space + if err := json.NewDecoder(w.Body).Decode(&updatedSpace); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if updatedSpace.Name != "Updated Name" { + t.Errorf("expected name 'Updated Name', got %q", updatedSpace.Name) + } + if updatedSpace.IsPublic != false { + t.Error("expected isPublic to be false") + } +} + +func TestDeleteSpace(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true} + repo.CreateSpace(nil, space) + + req := httptest.NewRequest(http.MethodDelete, "/api/spaces/test-space", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String()) + } + + // Verify space was deleted + _, err := repo.GetSpaceBySlug(nil, "test-space") + if err == nil { + t.Error("expected space to be deleted") + } +} + +func TestCreatePhoto(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true} + repo.CreateSpace(nil, space) + + body := `{"url": "https://example.com/photo.jpg", "thumbnailUrl": "https://example.com/thumb.jpg", "faceCount": 2}` + req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/photos", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var photo store.Photo + if err := json.NewDecoder(w.Body).Decode(&photo); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if photo.URL != "https://example.com/photo.jpg" { + t.Errorf("expected URL 'https://example.com/photo.jpg', got %q", photo.URL) + } + if photo.ThumbnailURL != "https://example.com/thumb.jpg" { + t.Errorf("expected thumbnail URL 'https://example.com/thumb.jpg', got %q", photo.ThumbnailURL) + } + if photo.FaceCount != 2 { + t.Errorf("expected face count 2, got %d", photo.FaceCount) + } +} + +func TestListPhotos(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true} + repo.CreateSpace(nil, space) + + // Create some photos + photo1 := &store.Photo{ID: "1", SpaceID: "1", URL: "https://example.com/photo1.jpg"} + photo2 := &store.Photo{ID: "2", SpaceID: "1", URL: "https://example.com/photo2.jpg"} + photo3 := &store.Photo{ID: "3", SpaceID: "2", URL: "https://example.com/photo3.jpg"} + + repo.CreatePhoto(nil, photo1) + repo.CreatePhoto(nil, photo2) + repo.CreatePhoto(nil, photo3) + + req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space/photos", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var photos []*store.Photo + if err := json.NewDecoder(w.Body).Decode(&photos); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(photos) != 2 { + t.Fatalf("expected 2 photos, got %d", len(photos)) + } +} + +func TestGuestFaceSearch(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testSpaceRouter(repo) + + space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true} + repo.CreateSpace(nil, space) + + body := `{"imageUrl": "https://example.com/test.jpg"}` + req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/face-search/guest", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.1.1:12345" + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response["status"] != "success" { + t.Errorf("expected status 'success', got %q", response["status"]) + } +} \ No newline at end of file diff --git a/internal/store/db.go b/internal/store/db.go index e1b500f0..73d4fc76 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -12,6 +12,9 @@ import ( //go:embed schema.sql var schemaSQL string +//go:embed schema_spaces.sql +var schemaSpacesSQL string + func Open(dbPath string) (Repository, error) { if dbPath == "" { dbPath = filepath.Join("data", "hatch.db") @@ -39,9 +42,17 @@ func Open(dbPath string) (Repository, error) { } func migrate(db *sql.DB) error { + // Load main schema _, err := db.Exec(schemaSQL) if err != nil { return fmt.Errorf("store/migrate: %w", err) } + + // Load spaces schema + _, err = db.Exec(schemaSpacesSQL) + if err != nil { + return fmt.Errorf("store/migrate spaces: %w", err) + } + return nil } diff --git a/internal/store/models.go b/internal/store/models.go index bca5dcaf..fb30774c 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -28,7 +28,50 @@ type MockConfig struct { Body []byte `json:"body"` } +// Space represents a public or private demo space +type Space struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"isPublic"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// Photo represents a photo in a space gallery +type Photo struct { + ID string `json:"id"` + SpaceID string `json:"spaceId"` + URL string `json:"url"` + ThumbnailURL string `json:"thumbnailUrl"` + FaceCount int `json:"faceCount"` + Metadata string `json:"metadata"` + CreatedAt string `json:"createdAt"` +} + +// FaceEmbedding stores face embeddings for search +type FaceEmbedding struct { + ID string `json:"id"` + PhotoID string `json:"photoId"` + SpaceID string `json:"spaceId"` + Embedding []float32 `json:"embedding"` + BoundingBox string `json:"boundingBox"` + CreatedAt string `json:"createdAt"` +} + +// RateLimit tracks rate limiting for guest face search +type RateLimit struct { + ID string `json:"id"` + IPAddress string `json:"ipAddress"` + SpaceID string `json:"spaceId"` + RequestCount int `json:"requestCount"` + WindowStart string `json:"windowStart"` + CreatedAt string `json:"createdAt"` +} + type Repository interface { + // Endpoint operations CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) GetEndpoint(ctx context.Context, id string) (*Endpoint, error) AppendRequest(ctx context.Context, endpointID string, req *Request) error @@ -37,6 +80,29 @@ type Repository interface { SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error) GetMock(ctx context.Context, endpointID string) (*MockConfig, error) SetMock(ctx context.Context, mock *MockConfig) error + + // Space operations + CreateSpace(ctx context.Context, space *Space) (*Space, error) + GetSpaceBySlug(ctx context.Context, slug string) (*Space, error) + UpdateSpace(ctx context.Context, space *Space) (*Space, error) + DeleteSpace(ctx context.Context, id string) error + ListPublicSpaces(ctx context.Context) ([]*Space, error) + + // Photo operations + CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error) + GetPhoto(ctx context.Context, id string) (*Photo, error) + ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error) + DeletePhoto(ctx context.Context, id string) error + + // Face embedding operations + CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error) + ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error) + SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error) + + // Rate limiting operations + CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) + IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error + Close() error Ping(ctx context.Context) error } diff --git a/internal/store/schema_spaces.sql b/internal/store/schema_spaces.sql new file mode 100644 index 00000000..4e595281 --- /dev/null +++ b/internal/store/schema_spaces.sql @@ -0,0 +1,51 @@ +-- Space tables for public demo spaces with face search + +CREATE TABLE IF NOT EXISTS spaces ( + id TEXT NOT NULL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_spaces_slug ON spaces(slug); +CREATE INDEX IF NOT EXISTS idx_spaces_is_public ON spaces(is_public); + +CREATE TABLE IF NOT EXISTS photos ( + id TEXT NOT NULL PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + url TEXT NOT NULL, + thumbnail_url TEXT, + face_count INTEGER NOT NULL DEFAULT 0, + metadata TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_photos_space_id ON photos(space_id); +CREATE INDEX IF NOT EXISTS idx_photos_created_at ON photos(created_at); + +CREATE TABLE IF NOT EXISTS face_embeddings ( + id TEXT NOT NULL PRIMARY KEY, + photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + embedding BLOB NOT NULL, -- JSON array of float32 + bounding_box TEXT NOT NULL, -- JSON bounding box + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_face_embeddings_space_id ON face_embeddings(space_id); +CREATE INDEX IF NOT EXISTS idx_face_embeddings_photo_id ON face_embeddings(photo_id); + +CREATE TABLE IF NOT EXISTS rate_limits ( + id TEXT NOT NULL PRIMARY KEY, + ip_address TEXT NOT NULL, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + request_count INTEGER NOT NULL DEFAULT 1, + window_start TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_space ON rate_limits(ip_address, space_id); +CREATE INDEX IF NOT EXISTS idx_rate_limits_window_start ON rate_limits(window_start); \ No newline at end of file diff --git a/internal/store/sqlite_repo_spaces.go b/internal/store/sqlite_repo_spaces.go new file mode 100644 index 00000000..306bbe12 --- /dev/null +++ b/internal/store/sqlite_repo_spaces.go @@ -0,0 +1,248 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Space operations + +func (r *sqliteRepo) CreateSpace(ctx context.Context, space *Space) (*Space, error) { + now := utcNow() + space.ID = uuid.NewString() + space.CreatedAt = now + space.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, + `INSERT INTO spaces (id, slug, name, description, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + space.ID, space.Slug, space.Name, space.Description, space.IsPublic, space.CreatedAt, space.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("store: create space: %w", err) + } + return space, nil +} + +func (r *sqliteRepo) GetSpaceBySlug(ctx context.Context, slug string) (*Space, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE slug = ?`, slug) + space := &Space{} + if err := row.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil { + return nil, fmt.Errorf("store: get space by slug: %w", err) + } + return space, nil +} + +func (r *sqliteRepo) UpdateSpace(ctx context.Context, space *Space) (*Space, error) { + space.UpdatedAt = utcNow() + _, err := r.db.ExecContext(ctx, + `UPDATE spaces SET name = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?`, + space.Name, space.Description, space.IsPublic, space.UpdatedAt, space.ID) + if err != nil { + return nil, fmt.Errorf("store: update space: %w", err) + } + return space, nil +} + +func (r *sqliteRepo) DeleteSpace(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM spaces WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: delete space: %w", err) + } + return nil +} + +func (r *sqliteRepo) ListPublicSpaces(ctx context.Context) ([]*Space, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE is_public = TRUE ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("store: list public spaces: %w", err) + } + defer rows.Close() + + var spaces []*Space + for rows.Next() { + space := &Space{} + if err := rows.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil { + return nil, fmt.Errorf("store: scan space: %w", err) + } + spaces = append(spaces, space) + } + return spaces, rows.Err() +} + +// Photo operations + +func (r *sqliteRepo) CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error) { + photo.ID = uuid.NewString() + photo.CreatedAt = utcNow() + + _, err := r.db.ExecContext(ctx, + `INSERT INTO photos (id, space_id, url, thumbnail_url, face_count, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + photo.ID, photo.SpaceID, photo.URL, photo.ThumbnailURL, photo.FaceCount, photo.Metadata, photo.CreatedAt) + if err != nil { + return nil, fmt.Errorf("store: create photo: %w", err) + } + return photo, nil +} + +func (r *sqliteRepo) GetPhoto(ctx context.Context, id string) (*Photo, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE id = ?`, id) + photo := &Photo{} + if err := row.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil { + return nil, fmt.Errorf("store: get photo: %w", err) + } + return photo, nil +} + +func (r *sqliteRepo) ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error) { + if limit <= 0 { + limit = 100 + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE space_id = ? ORDER BY created_at DESC LIMIT ?`, + spaceID, limit) + if err != nil { + return nil, fmt.Errorf("store: list photos by space: %w", err) + } + defer rows.Close() + + var photos []*Photo + for rows.Next() { + photo := &Photo{} + if err := rows.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan photo: %w", err) + } + photos = append(photos, photo) + } + return photos, rows.Err() +} + +func (r *sqliteRepo) DeletePhoto(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM photos WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: delete photo: %w", err) + } + return nil +} + +// Face embedding operations + +func (r *sqliteRepo) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error) { + embedding.ID = uuid.NewString() + embedding.CreatedAt = utcNow() + + // Convert embedding to JSON + embeddingJSON, err := json.Marshal(embedding.Embedding) + if err != nil { + return nil, fmt.Errorf("store: marshal face embedding: %w", err) + } + + _, err = r.db.ExecContext(ctx, + `INSERT INTO face_embeddings (id, photo_id, space_id, embedding, bounding_box, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + embedding.ID, embedding.PhotoID, embedding.SpaceID, embeddingJSON, embedding.BoundingBox, embedding.CreatedAt) + if err != nil { + return nil, fmt.Errorf("store: create face embedding: %w", err) + } + return embedding, nil +} + +func (r *sqliteRepo) ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, photo_id, space_id, embedding, bounding_box, created_at FROM face_embeddings WHERE space_id = ?`, + spaceID) + if err != nil { + return nil, fmt.Errorf("store: list face embeddings by space: %w", err) + } + defer rows.Close() + + var embeddings []*FaceEmbedding + for rows.Next() { + embedding := &FaceEmbedding{} + var embeddingJSON []byte + if err := rows.Scan(&embedding.ID, &embedding.PhotoID, &embedding.SpaceID, &embeddingJSON, &embedding.BoundingBox, &embedding.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan face embedding: %w", err) + } + if err := json.Unmarshal(embeddingJSON, &embedding.Embedding); err != nil { + return nil, fmt.Errorf("store: unmarshal face embedding: %w", err) + } + embeddings = append(embeddings, embedding) + } + return embeddings, rows.Err() +} + +func (r *sqliteRepo) SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error) { + if limit <= 0 { + limit = 10 + } + + // For now, return all embeddings in the space (cosine similarity search would be implemented here) + // In a real implementation, you'd compute cosine similarity between the query embedding and all embeddings + embeddings, err := r.ListFaceEmbeddingsBySpace(ctx, spaceID) + if err != nil { + return nil, err + } + + // Return up to limit results + if len(embeddings) > limit { + embeddings = embeddings[:limit] + } + return embeddings, nil +} + +// Rate limiting operations + +func (r *sqliteRepo) CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) { + // Calculate window start time + windowStart := time.Now().UTC().Add(-time.Duration(windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00") + + var count int + err := r.db.QueryRowContext(ctx, + `SELECT COALESCE(SUM(request_count), 0) FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start >= ?`, + ipAddress, spaceID, windowStart).Scan(&count) + if err != nil { + return false, fmt.Errorf("store: check rate limit: %w", err) + } + + return count < limit, nil +} + +func (r *sqliteRepo) IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error { + // Calculate window start time (rounded to the nearest window) + now := time.Now().UTC() + windowStart := now.Add(-time.Duration(now.Minute()%windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00") + + // Check if there's an existing record for this IP, space, and window + var id string + err := r.db.QueryRowContext(ctx, + `SELECT id FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start = ?`, + ipAddress, spaceID, windowStart).Scan(&id) + + if err == sql.ErrNoRows { + // Create new record + id = uuid.NewString() + _, err = r.db.ExecContext(ctx, + `INSERT INTO rate_limits (id, ip_address, space_id, request_count, window_start, created_at) VALUES (?, ?, ?, 1, ?, ?)`, + id, ipAddress, spaceID, windowStart, utcNow()) + if err != nil { + return fmt.Errorf("store: create rate limit: %w", err) + } + } else if err == nil { + // Update existing record + _, err = r.db.ExecContext(ctx, + `UPDATE rate_limits SET request_count = request_count + 1 WHERE id = ?`, + id) + if err != nil { + return fmt.Errorf("store: increment rate limit: %w", err) + } + } else { + return fmt.Errorf("store: get rate limit: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/internal/testutil/fake_repo.go b/internal/testutil/fake_repo.go index 3da59396..208f939f 100644 --- a/internal/testutil/fake_repo.go +++ b/internal/testutil/fake_repo.go @@ -12,13 +12,23 @@ type FakeRepository struct { Endpoints map[string]*store.Endpoint Requests []*store.Request Mocks map[string]*store.MockConfig + + // Space-related fields + Spaces map[string]*store.Space + Photos map[string]*store.Photo + FaceEmbeddings map[string]*store.FaceEmbedding + RateLimits map[string]*store.RateLimit } // NewFakeRepository creates a new FakeRepository. func NewFakeRepository() *FakeRepository { return &FakeRepository{ - Endpoints: map[string]*store.Endpoint{}, - Mocks: map[string]*store.MockConfig{}, + Endpoints: map[string]*store.Endpoint{}, + Mocks: map[string]*store.MockConfig{}, + Spaces: map[string]*store.Space{}, + Photos: map[string]*store.Photo{}, + FaceEmbeddings: map[string]*store.FaceEmbedding{}, + RateLimits: map[string]*store.RateLimit{}, } } @@ -93,6 +103,118 @@ func (f *FakeRepository) Close() error { return nil } func (f *FakeRepository) Ping(_ context.Context) error { return nil } +// Space operations + +func (f *FakeRepository) CreateSpace(_ context.Context, space *store.Space) (*store.Space, error) { + f.Spaces[space.Slug] = space + return space, nil +} + +func (f *FakeRepository) GetSpaceBySlug(_ context.Context, slug string) (*store.Space, error) { + space, ok := f.Spaces[slug] + if !ok { + return nil, errNotFound + } + return space, nil +} + +func (f *FakeRepository) UpdateSpace(_ context.Context, space *store.Space) (*store.Space, error) { + f.Spaces[space.Slug] = space + return space, nil +} + +func (f *FakeRepository) DeleteSpace(_ context.Context, id string) error { + for slug, space := range f.Spaces { + if space.ID == id { + delete(f.Spaces, slug) + return nil + } + } + return errNotFound +} + +func (f *FakeRepository) ListPublicSpaces(_ context.Context) ([]*store.Space, error) { + var spaces []*store.Space + for _, space := range f.Spaces { + if space.IsPublic { + spaces = append(spaces, space) + } + } + return spaces, nil +} + +// Photo operations + +func (f *FakeRepository) CreatePhoto(_ context.Context, photo *store.Photo) (*store.Photo, error) { + f.Photos[photo.ID] = photo + return photo, nil +} + +func (f *FakeRepository) GetPhoto(_ context.Context, id string) (*store.Photo, error) { + photo, ok := f.Photos[id] + if !ok { + return nil, errNotFound + } + return photo, nil +} + +func (f *FakeRepository) ListPhotosBySpace(_ context.Context, spaceID string, limit int) ([]*store.Photo, error) { + var photos []*store.Photo + for _, photo := range f.Photos { + if photo.SpaceID == spaceID { + photos = append(photos, photo) + if limit > 0 && len(photos) >= limit { + break + } + } + } + return photos, nil +} + +func (f *FakeRepository) DeletePhoto(_ context.Context, id string) error { + for photoID := range f.Photos { + if photoID == id { + delete(f.Photos, photoID) + return nil + } + } + return errNotFound +} + +// Face embedding operations + +func (f *FakeRepository) CreateFaceEmbedding(_ context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) { + f.FaceEmbeddings[embedding.ID] = embedding + return embedding, nil +} + +func (f *FakeRepository) ListFaceEmbeddingsBySpace(_ context.Context, spaceID string) ([]*store.FaceEmbedding, error) { + var embeddings []*store.FaceEmbedding + for _, embedding := range f.FaceEmbeddings { + if embedding.SpaceID == spaceID { + embeddings = append(embeddings, embedding) + } + } + return embeddings, nil +} + +func (f *FakeRepository) SearchFaceEmbeddings(_ context.Context, spaceID string, embedding []float32, limit int) ([]*store.FaceEmbedding, error) { + // For fake repo, just return all embeddings in the space + return f.ListFaceEmbeddingsBySpace(nil, spaceID) +} + +// Rate limiting operations + +func (f *FakeRepository) CheckRateLimit(_ context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) { + // For fake repo, always allow + return true, nil +} + +func (f *FakeRepository) IncrementRateLimit(_ context.Context, ipAddress, spaceID string, windowMinutes int) error { + // For fake repo, do nothing + return nil +} + type notFoundError struct{} func (e *notFoundError) Error() string { return "not found" }