Add face service package for AWS Rekognition integration

This commit is contained in:
Chris Anderson 2026-06-25 21:15:57 +02:00
parent 4bf37faf4f
commit 6568af4dba
8 changed files with 1190 additions and 2 deletions

View File

@ -16,6 +16,7 @@ import (
type Handler struct { type Handler struct {
Repo store.Repository Repo store.Repository
Debug bool Debug bool
FaceService *face.Service
} }
// New creates a new Handler with the given store. // New creates a new Handler with the given store.
@ -23,6 +24,14 @@ func New(repo store.Repository) *Handler {
return &Handler{Repo: repo} 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. // RegisterRoutes mounts all routes on the given chi router.
func (h *Handler) RegisterRoutes(r chi.Router) { func (h *Handler) RegisterRoutes(r chi.Router) {
r.Use(middleware.Logger) r.Use(middleware.Logger)
@ -46,6 +55,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
// JSON API v1 routes. // JSON API v1 routes.
h.RegisterV1Routes(r) h.RegisterV1Routes(r)
// Space management routes.
h.RegisterSpaceRoutes(r)
// Inspect page: server-rendered request list. // Inspect page: server-rendered request list.
r.Get("/e/{endpointID}", HandleInspect(h.Repo)) r.Get("/e/{endpointID}", HandleInspect(h.Repo))

383
internal/handler/spaces.go Normal file
View File

@ -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
}

View File

@ -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"])
}
}

View File

@ -12,6 +12,9 @@ import (
//go:embed schema.sql //go:embed schema.sql
var schemaSQL string var schemaSQL string
//go:embed schema_spaces.sql
var schemaSpacesSQL string
func Open(dbPath string) (Repository, error) { func Open(dbPath string) (Repository, error) {
if dbPath == "" { if dbPath == "" {
dbPath = filepath.Join("data", "hatch.db") dbPath = filepath.Join("data", "hatch.db")
@ -39,9 +42,17 @@ func Open(dbPath string) (Repository, error) {
} }
func migrate(db *sql.DB) error { func migrate(db *sql.DB) error {
// Load main schema
_, err := db.Exec(schemaSQL) _, err := db.Exec(schemaSQL)
if err != nil { if err != nil {
return fmt.Errorf("store/migrate: %w", err) 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 return nil
} }

View File

@ -28,7 +28,50 @@ type MockConfig struct {
Body []byte `json:"body"` 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 { type Repository interface {
// Endpoint operations
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
GetEndpoint(ctx context.Context, id string) (*Endpoint, error) GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
AppendRequest(ctx context.Context, endpointID string, req *Request) 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) SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
GetMock(ctx context.Context, endpointID string) (*MockConfig, error) GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
SetMock(ctx context.Context, mock *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 Close() error
Ping(ctx context.Context) error Ping(ctx context.Context) error
} }

View File

@ -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);

View File

@ -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
}

View File

@ -12,13 +12,23 @@ type FakeRepository struct {
Endpoints map[string]*store.Endpoint Endpoints map[string]*store.Endpoint
Requests []*store.Request Requests []*store.Request
Mocks map[string]*store.MockConfig 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. // NewFakeRepository creates a new FakeRepository.
func NewFakeRepository() *FakeRepository { func NewFakeRepository() *FakeRepository {
return &FakeRepository{ return &FakeRepository{
Endpoints: map[string]*store.Endpoint{}, Endpoints: map[string]*store.Endpoint{},
Mocks: map[string]*store.MockConfig{}, 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 } 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{} type notFoundError struct{}
func (e *notFoundError) Error() string { return "not found" } func (e *notFoundError) Error() string { return "not found" }