Compare commits

..

6 Commits

Author SHA1 Message Date
Chris Anderson
5bce1bd39b Phase 2.4: Testing & Deployment
Some checks are pending
CI / go (push) Waiting to run
CI / docker (push) Waiting to run
CI / lint-go (push) Waiting to run
CI / lint-docs (push) Waiting to run
CI / check-paperclip (push) Waiting to run
- Wire face service into main.go with FACE_SERVICE_ENABLED toggle
- Add integration tests for AWS Rekognition (build tag: integration)
- Add performance benchmarks for face operations (build tag: performance)
- Fix PhotoRepository interface to use store types directly
- Remove duplicate PhotoInfo/FaceEmbeddingInfo types from face package
- Add go.mod replace directive for local module resolution

Integration tests: go test -tags=integration ./internal/face/...
Performance tests: go test -tags=performance -bench=. ./internal/face/...
Standard tests: go test ./...
2026-06-26 05:50:07 +02:00
Chris Anderson
a63d2dbf62 Add security headers, rate limiting, and input validation middleware
- SecurityHeaders: X-Frame-Options, CSP, HSTS, X-XSS-Protection, etc.
- RateLimiter: in-memory sliding window, 100 req/min per IP
- ValidateSlug: alphanumeric + hyphens, 3-100 chars
- SanitizeSearchQuery: strips injection-prone characters
- REDIS_URL env var passthrough in docker-compose for future caching

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-25 22:18:21 +02:00
Chris Anderson
4a24f33f17 Fix TestGuestFaceSearch to handle missing face service 2026-06-25 21:35:29 +02:00
Chris Anderson
51aaf3c9d2 Add face package import to handler
- Import internal/face package in handler.go
- Add FaceService field to Handler struct
- Add NewWithFaceService constructor
- Update go.mod with AWS SDK dependencies

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-25 21:16:50 +02:00
Chris Anderson
6568af4dba Add face service package for AWS Rekognition integration 2026-06-25 21:15:57 +02:00
Chris Anderson
4bf37faf4f Add face service package for AWS Rekognition integration
- Client interface and MockClient for testing
- Service layer with IndexPhoto and SearchFaces methods
- CacheService interface and MemoryCache implementation
- Configuration with environment variable support
- Error types and user-facing messages
- Unit tests for service layer

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-25 21:11:45 +02:00
26 changed files with 3319 additions and 6 deletions

65
app/join/[code]/page.tsx Normal file
View File

@ -0,0 +1,65 @@
'use client';
import { QRCodeSVG } from 'qrcode.react';
import { useParams } from 'next/navigation';
export default function JoinPage() {
const params = useParams();
const code = params.code as string;
const joinUrl = `https://hatch.surf/join/${code}`;
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center px-6 py-12">
{/* Main content */}
<div className="max-w-md w-full text-center">
{/* Bilingual welcome phrase */}
<div className="mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
Арга хэмжээнд тавтай морилно уу
</h1>
<p className="text-lg text-zinc-400 font-medium">
Welcome to the event
</p>
</div>
{/* QR Code section */}
<div className="mb-12">
<div className="inline-block p-6 bg-white rounded-2xl shadow-2xl">
<QRCodeSVG
value={joinUrl}
size={200}
bgColor="#ffffff"
fgColor="#09090b"
level="H"
includeMargin={false}
/>
</div>
</div>
{/* Event info */}
<div className="mb-8">
<div className="inline-block px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700/50 text-zinc-300 text-sm font-mono">
Code: {code}
</div>
</div>
{/* Instructions */}
<div className="space-y-4 text-zinc-400 text-sm">
<p className="leading-relaxed">
Scan the QR code above to join this event space.
</p>
<p className="leading-relaxed">
You'll be able to see live HTTP requests and interact with the event.
</p>
</div>
{/* Footer */}
<div className="mt-16 pt-8 border-t border-zinc-800/50">
<p className="text-xs text-zinc-600">
Powered by Hatch · Self-hostable HTTP request inspector
</p>
</div>
</div>
</div>
);
}

View File

@ -10,6 +10,8 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/elfoundation/hatch/internal/face"
"github.com/elfoundation/hatch/internal/handler" "github.com/elfoundation/hatch/internal/handler"
"github.com/elfoundation/hatch/internal/store" "github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -33,9 +35,39 @@ func main() {
} }
defer repo.Close() defer repo.Close()
h := handler.New(repo) // Initialize face service if enabled
var faceService *face.Service
if os.Getenv("FACE_SERVICE_ENABLED") == "true" {
cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
log.Printf("warning: failed to load AWS config: %v", err)
} else {
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces"
}
rekognitionClient := face.NewRekognitionClient(cfg, collectionID)
faceConfig := face.DefaultConfig()
faceCache := face.NewMemoryCache()
faceService = face.NewService(rekognitionClient, repo, faceCache, faceConfig)
log.Println("face search service initialized")
}
}
// Create handler with or without face service
var h *handler.Handler
if faceService != nil {
h = handler.NewWithFaceService(repo, faceService)
} else {
h = handler.New(repo)
}
h.Debug = os.Getenv("HATCH_DEBUG") != "" h.Debug = os.Getenv("HATCH_DEBUG") != ""
r := chi.NewRouter() r := chi.NewRouter()
// Security and performance middleware
r.Use(handler.SecurityHeaders)
r.Use(handler.NewRateLimiter(100, time.Minute).Limit)
h.RegisterRoutes(r) h.RegisterRoutes(r)
addr := fmt.Sprintf(":%s", port) addr := fmt.Sprintf(":%s", port)

View File

@ -10,6 +10,7 @@ services:
- HATCH_PORT=8080 - HATCH_PORT=8080
- HATCH_DB_PATH=/data/hatch.db - HATCH_DB_PATH=/data/hatch.db
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost} - HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
- REDIS_URL=${REDIS_URL:-}
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine

30
go.mod
View File

@ -1,20 +1,48 @@
module github.com/elfoundation/hatch module github.com/elfoundation/hatch
go 1.25.0 go 1.26.4
require ( require (
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.25
github.com/aws/aws-sdk-go-v2/service/rekognition v1.52.3
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.21.0
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
) )
require ( require (
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/smithy-go v1.27.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
replace github.com/elfoundation/hatch => .

53
go.sum
View File

@ -1,3 +1,37 @@
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/rekognition v1.52.3 h1:SV1cXOW9qnXotyFJW8kw4qjZV2KaybIkVz99kKjOWck=
github.com/aws/aws-sdk-go-v2/service/rekognition v1.52.3/go.mod h1:9i14Fo0M0qlOsf1wPvG7DDHRwQH1kamlNkmKoBEJ13w=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
@ -10,10 +44,26 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@ -23,6 +73,9 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=

153
internal/face/cache.go Normal file
View File

@ -0,0 +1,153 @@
package face
import (
"context"
"encoding/json"
"sync"
"time"
)
// CacheService defines the interface for caching face search results
type CacheService interface {
// Get retrieves a cached value by key
Get(ctx context.Context, key string) (string, error)
// Set stores a value with expiration
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
// Delete removes cached values
Delete(ctx context.Context, keys ...string) error
// GetJSON retrieves and unmarshals a cached JSON value
GetJSON(ctx context.Context, key string, dest interface{}) error
// SetJSON marshals and stores a JSON value
SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error
}
// MemoryCache is an in-memory cache implementation for testing and development
type MemoryCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry
}
type cacheEntry struct {
value string
expiresAt time.Time
}
// NewMemoryCache creates a new in-memory cache
func NewMemoryCache() *MemoryCache {
return &MemoryCache{
entries: make(map[string]*cacheEntry),
}
}
// Get retrieves a value from the cache
func (c *MemoryCache) Get(ctx context.Context, key string) (string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok {
return "", nil
}
if time.Now().After(entry.expiresAt) {
return "", nil
}
return entry.value, nil
}
// Set stores a value in the cache
func (c *MemoryCache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
var strValue string
switch v := value.(type) {
case string:
strValue = v
default:
bytes, err := json.Marshal(v)
if err != nil {
return err
}
strValue = string(bytes)
}
c.entries[key] = &cacheEntry{
value: strValue,
expiresAt: time.Now().Add(expiration),
}
return nil
}
// Delete removes values from the cache
func (c *MemoryCache) Delete(ctx context.Context, keys ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
for _, key := range keys {
delete(c.entries, key)
}
return nil
}
// GetJSON retrieves and unmarshals a cached JSON value
func (c *MemoryCache) GetJSON(ctx context.Context, key string, dest interface{}) error {
value, err := c.Get(ctx, key)
if err != nil {
return err
}
if value == "" {
return nil
}
return json.Unmarshal([]byte(value), dest)
}
// SetJSON marshals and stores a JSON value
func (c *MemoryCache) SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return c.Set(ctx, key, value, expiration)
}
// Cleanup removes expired entries (call periodically)
func (c *MemoryCache) Cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, entry := range c.entries {
if now.After(entry.expiresAt) {
delete(c.entries, key)
}
}
}
// CacheKeys returns common cache key patterns
type CacheKeys struct{}
// Search returns the cache key for search results
func (CacheKeys) Search(spaceID string, imageHash string) string {
return "search:" + spaceID + ":" + imageHash
}
// Photo returns the cache key for photo metadata
func (CacheKeys) Photo(photoID string) string {
return "photo:" + photoID
}
// Face returns the cache key for face embeddings
func (CacheKeys) Face(faceID string) string {
return "face:" + faceID
}
// Indexed returns the cache key for indexed photos
func (CacheKeys) Indexed(photoID string) string {
return "indexed:" + photoID
}

191
internal/face/client.go Normal file
View File

@ -0,0 +1,191 @@
package face
import (
"context"
"fmt"
"sync"
"time"
)
// Client defines the interface for face detection, indexing, and search operations
type Client interface {
// CreateCollection creates a new face collection for a space
CreateCollection(ctx context.Context, collectionName string) error
// DeleteCollection deletes a face collection
DeleteCollection(ctx context.Context, collectionName string) error
// DetectFaces detects faces in an image
DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
// IndexFace indexes a face in a collection
IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
// SearchFacesByImage searches for similar faces in the collection
SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
}
// Compile-time check that RekognitionClient implements Client
var _ Client = (*RekognitionClient)(nil)
// MockClient is a mock implementation of the Client interface for testing
type MockClient struct {
mu sync.RWMutex
collections map[string]*CollectionInfo
faces map[string][]FaceIndex
detections []FaceDetection
searchResults []FaceMatch
// Mock configuration
DetectFacesFunc func(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
IndexFaceFunc func(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
SearchFacesFunc func(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
}
// FaceIndex represents an indexed face (internal to mock)
type FaceIndex struct {
FaceID string `json:"faceId"`
ExternalImageID string `json:"externalImageId"`
Confidence float64 `json:"confidence"`
BoundingBox BoundingBox `json:"boundingBox"`
IndexedAt time.Time `json:"indexedAt"`
}
// NewMockClient creates a new mock client for testing
func NewMockClient() *MockClient {
return &MockClient{
collections: make(map[string]*CollectionInfo),
faces: make(map[string][]FaceIndex),
}
}
// CreateCollection creates a mock collection
func (m *MockClient) CreateCollection(ctx context.Context, collectionName string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.collections[collectionName] = &CollectionInfo{
CollectionID: collectionName,
FaceCount: 0,
CreationDate: time.Now(),
LastUpdated: time.Now(),
}
return nil
}
// DeleteCollection deletes a mock collection
func (m *MockClient) DeleteCollection(ctx context.Context, collectionName string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.collections, collectionName)
delete(m.faces, collectionName)
return nil
}
// DetectFaces returns mock face detections
func (m *MockClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
if m.DetectFacesFunc != nil {
return m.DetectFacesFunc(ctx, imageBytes)
}
m.mu.RLock()
defer m.mu.RUnlock()
// Return mock detections
if len(m.detections) > 0 {
return m.detections, nil
}
// Default: return one face
return []FaceDetection{
{
Confidence: 99.5,
BoundingBox: BoundingBox{
Left: 0.1,
Top: 0.1,
Width: 0.3,
Height: 0.3,
},
},
}, nil
}
// IndexFace indexes a mock face
func (m *MockClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
if m.IndexFaceFunc != nil {
return m.IndexFaceFunc(ctx, imageBytes, photoID)
}
m.mu.Lock()
defer m.mu.Unlock()
detection := &FaceDetection{
Confidence: 99.5,
BoundingBox: BoundingBox{
Left: 0.1,
Top: 0.1,
Width: 0.3,
Height: 0.3,
},
}
// Store in mock database
faceIndex := FaceIndex{
FaceID: fmt.Sprintf("face-%s-%d", photoID, time.Now().UnixNano()),
ExternalImageID: photoID,
Confidence: 99.5,
BoundingBox: detection.BoundingBox,
IndexedAt: time.Now(),
}
m.faces["hatch-default"] = append(m.faces["hatch-default"], faceIndex)
return detection, nil
}
// SearchFacesByImage returns mock search results
func (m *MockClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
if m.SearchFacesFunc != nil {
return m.SearchFacesFunc(ctx, imageBytes, maxFaces, threshold)
}
m.mu.RLock()
defer m.mu.RUnlock()
// Return mock matches
if len(m.searchResults) > 0 {
if maxFaces > 0 && len(m.searchResults) > maxFaces {
return m.searchResults[:maxFaces], nil
}
return m.searchResults, nil
}
// Default: return one match
return []FaceMatch{
{
FaceID: "face-mock-1",
ExternalImageID: "photo-1",
Similarity: 95.0,
BoundingBox: BoundingBox{
Left: 0.1,
Top: 0.1,
Width: 0.3,
Height: 0.3,
},
},
}, nil
}
// SetDetections configures mock face detections
func (m *MockClient) SetDetections(detections []FaceDetection) {
m.mu.Lock()
defer m.mu.Unlock()
m.detections = detections
}
// SetSearchResults configures mock search results
func (m *MockClient) SetSearchResults(results []FaceMatch) {
m.mu.Lock()
defer m.mu.Unlock()
m.searchResults = results
}

102
internal/face/config.go Normal file
View File

@ -0,0 +1,102 @@
package face
import (
"os"
"strconv"
"time"
)
// Config holds configuration for the face service
type Config struct {
// AWS Configuration
Region string
CollectionID string
// Face Detection Configuration
MaxFaces int
Threshold float64
QualityFilter string
// Cache Configuration
CacheTTL time.Duration
SearchCacheTTL time.Duration
PhotoCacheTTL time.Duration
// Rate Limiting
GuestRateLimit int
GuestRateWindowMin int
AuthRateLimit int
AuthRateWindowMin int
// Image Optimization
MaxImageSize int
ResizeWidth int
ResizeHeight int
ImageQuality int
}
// DefaultConfig returns default configuration
func DefaultConfig() *Config {
return &Config{
Region: getEnv("AWS_REGION", "us-east-1"),
CollectionID: getEnv("REKOGNITION_COLLECTION", "hatch-faces"),
MaxFaces: getEnvInt("REKOGNITION_MAX_FACES", 10),
Threshold: getEnvFloat("REKOGNITION_THRESHOLD", 80.0),
QualityFilter: getEnv("REKOGNITION_QUALITY_FILTER", "AUTO"),
CacheTTL: 5 * time.Minute,
SearchCacheTTL: 5 * time.Minute,
PhotoCacheTTL: 24 * time.Hour,
GuestRateLimit: getEnvInt("RATE_LIMIT_GUEST_SEARCHES", 20),
GuestRateWindowMin: getEnvInt("RATE_LIMIT_GUEST_WINDOW_MINUTES", 15),
AuthRateLimit: getEnvInt("RATE_LIMIT_AUTH_SEARCHES", 100),
AuthRateWindowMin: getEnvInt("RATE_LIMIT_AUTH_WINDOW_MINUTES", 1),
MaxImageSize: 10 * 1024 * 1024, // 10MB
ResizeWidth: 1024,
ResizeHeight: 1024,
ImageQuality: 85,
}
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.Region == "" {
return ErrMissingRegion
}
if c.CollectionID == "" {
return ErrMissingCollectionID
}
if c.MaxFaces <= 0 {
return ErrInvalidMaxFaces
}
if c.Threshold < 0 || c.Threshold > 100 {
return ErrInvalidThreshold
}
return nil
}
// Helper functions for environment variables
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
func getEnvFloat(key string, defaultValue float64) float64 {
if value := os.Getenv(key); value != "" {
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
return floatValue
}
}
return defaultValue
}

94
internal/face/errors.go Normal file
View File

@ -0,0 +1,94 @@
package face
import "errors"
// Sentinel errors for face operations
var (
// Client errors
ErrNoFacesDetected = errors.New("no faces detected in image")
ErrImageTooSmall = errors.New("image too small for face detection")
ErrImageTooLarge = errors.New("image exceeds maximum size")
ErrInvalidImageFormat = errors.New("invalid image format")
ErrImageProcessingFailed = errors.New("failed to process image")
// Collection errors
ErrCollectionNotFound = errors.New("face collection not found")
ErrCollectionExists = errors.New("face collection already exists")
ErrCollectionCreationFailed = errors.New("failed to create face collection")
// Indexing errors
ErrIndexingFailed = errors.New("failed to index face")
ErrFaceAlreadyIndexed = errors.New("face already indexed")
// Search errors
ErrSearchFailed = errors.New("face search failed")
ErrNoMatchesFound = errors.New("no matching faces found")
// Configuration errors
ErrMissingRegion = errors.New("AWS region is required")
ErrMissingCollectionID = errors.New("collection ID is required")
ErrInvalidMaxFaces = errors.New("max faces must be positive")
ErrInvalidThreshold = errors.New("threshold must be between 0 and 100")
// Rate limiting errors
ErrRateLimitExceeded = errors.New("rate limit exceeded")
// AWS service errors
ErrAWSServiceError = errors.New("AWS Rekognition service error")
ErrAWSAccessDenied = errors.New("AWS access denied")
ErrAWSThrottling = errors.New("AWS request throttled")
ErrAWSInvalidImage = errors.New("invalid image for AWS Rekognition")
)
// FaceSearchError represents a structured error for face search operations
type FaceSearchError struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// Error implements the error interface
func (e *FaceSearchError) Error() string {
return e.Message
}
// NewFaceSearchError creates a new FaceSearchError
func NewFaceSearchError(code, message, details string) *FaceSearchError {
return &FaceSearchError{
Code: code,
Message: message,
Details: details,
}
}
// Common error codes
const (
ErrorCodeNoFaces = "NO_FACES_DETECTED"
ErrorCodeInvalidImage = "INVALID_IMAGE"
ErrorCodeRateLimited = "RATE_LIMITED"
ErrorCodeCollectionError = "COLLECTION_ERROR"
ErrorCodeSearchFailed = "SEARCH_FAILED"
ErrorCodeIndexingFailed = "INDEXING_FAILED"
ErrorCodeInternalError = "INTERNAL_ERROR"
)
// User-facing error messages
var UserErrorMessages = map[error]string{
ErrNoFacesDetected: "No faces detected in the image. Please upload a photo with clear faces.",
ErrImageTooSmall: "Image is too small. Please upload a larger image (minimum 100x100 pixels).",
ErrImageTooLarge: "Image is too large. Please upload an image smaller than 10MB.",
ErrInvalidImageFormat: "Invalid image format. Please upload a JPEG, PNG, or WebP image.",
ErrCollectionNotFound: "Face collection not found. Please contact support.",
ErrRateLimitExceeded: "Search limit exceeded. Please try again later.",
ErrAWSServiceError: "An error occurred while processing your request. Please try again.",
ErrAWSAccessDenied: "Access denied. Please contact support.",
ErrAWSThrottling: "Service is busy. Please try again in a few moments.",
}
// GetUserMessage returns a user-friendly error message
func GetUserMessage(err error) string {
if msg, ok := UserErrorMessages[err]; ok {
return msg
}
return "An unexpected error occurred. Please try again."
}

View File

@ -0,0 +1,281 @@
//go:build integration
package face
import (
"context"
"os"
"testing"
"time"
)
// TestIntegration_DetectFaces tests face detection with real AWS Rekognition
func TestIntegration_DetectFaces(t *testing.T) {
if os.Getenv("AWS_REGION") == "" {
t.Skip("AWS_REGION not set, skipping integration test")
}
cfg, err := loadAWSConfig()
if err != nil {
t.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-test"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Load test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
t.Fatalf("failed to load test image: %v", err)
}
// Detect faces
detections, err := client.DetectFaces(ctx, imageBytes)
if err != nil {
t.Fatalf("failed to detect faces: %v", err)
}
if len(detections) == 0 {
t.Error("expected at least one face detection")
}
// Verify confidence > 99%
for _, detection := range detections {
if detection.Confidence < 99.0 {
t.Errorf("expected confidence > 99%%, got %f", detection.Confidence)
}
}
}
// TestIntegration_IndexFace tests face indexing with real AWS Rekognition
func TestIntegration_IndexFace(t *testing.T) {
if os.Getenv("AWS_REGION") == "" {
t.Skip("AWS_REGION not set, skipping integration test")
}
cfg, err := loadAWSConfig()
if err != nil {
t.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-test"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Create collection
if err := client.CreateCollection(ctx, collectionID); err != nil {
t.Fatalf("failed to create collection: %v", err)
}
defer client.DeleteCollection(ctx, collectionID)
// Load test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
t.Fatalf("failed to load test image: %v", err)
}
// Index face
photoID := "test-photo-" + time.Now().Format("20060102150405")
detection, err := client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
t.Fatalf("failed to index face: %v", err)
}
if detection == nil {
t.Error("expected face detection, got nil")
}
if detection.Confidence < 99.0 {
t.Errorf("expected confidence > 99%%, got %f", detection.Confidence)
}
}
// TestIntegration_SearchFaces tests face search with real AWS Rekognition
func TestIntegration_SearchFaces(t *testing.T) {
if os.Getenv("AWS_REGION") == "" {
t.Skip("AWS_REGION not set, skipping integration test")
}
cfg, err := loadAWSConfig()
if err != nil {
t.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-test"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Create collection
if err := client.CreateCollection(ctx, collectionID); err != nil {
t.Fatalf("failed to create collection: %v", err)
}
defer client.DeleteCollection(ctx, collectionID)
// Load and index test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
t.Fatalf("failed to load test image: %v", err)
}
photoID := "test-photo-" + time.Now().Format("20060102150405")
_, err = client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
t.Fatalf("failed to index face: %v", err)
}
// Search for the indexed face
matches, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
if err != nil {
t.Fatalf("failed to search faces: %v", err)
}
if len(matches) == 0 {
t.Error("expected at least one match")
}
// Verify similarity > 80%
for _, match := range matches {
if match.Similarity < 80.0 {
t.Errorf("expected similarity > 80%%, got %f", match.Similarity)
}
}
}
// TestIntegration_EndToEnd tests complete face detection → index → search flow
func TestIntegration_EndToEnd(t *testing.T) {
if os.Getenv("AWS_REGION") == "" {
t.Skip("AWS_REGION not set, skipping integration test")
}
startTime := time.Now()
cfg, err := loadAWSConfig()
if err != nil {
t.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-test"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Create collection
if err := client.CreateCollection(ctx, collectionID); err != nil {
t.Fatalf("failed to create collection: %v", err)
}
defer client.DeleteCollection(ctx, collectionID)
// Load test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
t.Fatalf("failed to load test image: %v", err)
}
// Step 1: Detect faces
detections, err := client.DetectFaces(ctx, imageBytes)
if err != nil {
t.Fatalf("failed to detect faces: %v", err)
}
if len(detections) == 0 {
t.Fatal("no faces detected")
}
// Step 2: Index face
photoID := "e2e-test-" + time.Now().Format("20060102150405")
_, err = client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
t.Fatalf("failed to index face: %v", err)
}
// Step 3: Search for the indexed face
matches, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
if err != nil {
t.Fatalf("failed to search faces: %v", err)
}
if len(matches) == 0 {
t.Error("expected at least one match")
}
// Verify performance < 5 seconds
elapsed := time.Since(startTime)
if elapsed > 5*time.Second {
t.Errorf("end-to-end latency exceeded 5 seconds: %v", elapsed)
}
t.Logf("End-to-end latency: %v", elapsed)
}
// TestIntegration_ErrorHandling tests error handling with invalid inputs
func TestIntegration_ErrorHandling(t *testing.T) {
if os.Getenv("AWS_REGION") == "" {
t.Skip("AWS_REGION not set, skipping integration test")
}
cfg, err := loadAWSConfig()
if err != nil {
t.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-test"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Test with empty image data
_, err = client.DetectFaces(ctx, []byte{})
if err == nil {
t.Error("expected error for empty image data")
}
// Test with invalid image data
_, err = client.DetectFaces(ctx, []byte("invalid image data"))
if err == nil {
t.Error("expected error for invalid image data")
}
}
// Helper functions
func loadAWSConfig() (aws.Config, error) {
return config.LoadDefaultConfig(context.Background())
}
func loadTestImage(path string) ([]byte, error) {
// Try multiple locations
locations := []string{
path,
"test/fixtures/images/face.jpg",
"testdata/face.jpg",
"../test/fixtures/images/face.jpg",
}
for _, loc := range locations {
data, err := os.ReadFile(loc)
if err == nil {
return data, nil
}
}
return nil, fmt.Errorf("test image not found in any of the expected locations")
}

35
internal/face/models.go Normal file
View File

@ -0,0 +1,35 @@
package face
import "time"
// FaceSearchResult represents the result of a face search
type FaceSearchResult struct {
Matches []FaceMatch `json:"matches"`
PhotoCount int `json:"photoCount"`
SearchTime time.Duration `json:"searchTime"`
CacheHit bool `json:"cacheHit"`
}
// CollectionInfo represents information about a face collection
type CollectionInfo struct {
CollectionID string `json:"collectionId"`
FaceCount int `json:"faceCount"`
CreationDate time.Time `json:"creationDate"`
LastUpdated time.Time `json:"lastUpdated"`
}
// SearchRequest represents a face search request
type SearchRequest struct {
ImageBytes []byte `json:"-"`
ImageURL string `json:"imageUrl,omitempty"`
MaxFaces int `json:"maxFaces,omitempty"`
Threshold float64 `json:"threshold,omitempty"`
}
// IndexRequest represents a face indexing request
type IndexRequest struct {
PhotoID string `json:"photoId"`
SpaceID string `json:"spaceId"`
ImageBytes []byte `json:"-"`
ImageURL string `json:"imageUrl,omitempty"`
}

View File

@ -0,0 +1,225 @@
//go:build performance
package face
import (
"context"
"fmt"
"os"
"testing"
"time"
)
// BenchmarkFaceDetection benchmarks face detection performance
func BenchmarkFaceDetection(b *testing.B) {
if os.Getenv("AWS_REGION") == "" {
b.Skip("AWS_REGION not set, skipping performance test")
}
cfg, err := loadAWSConfig()
if err != nil {
b.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-perf"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Load test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
b.Fatalf("failed to load test image: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := client.DetectFaces(ctx, imageBytes)
if err != nil {
b.Fatalf("failed to detect faces: %v", err)
}
}
}
// BenchmarkFaceIndexing benchmarks face indexing performance
func BenchmarkFaceIndexing(b *testing.B) {
if os.Getenv("AWS_REGION") == "" {
b.Skip("AWS_REGION not set, skipping performance test")
}
cfg, err := loadAWSConfig()
if err != nil {
b.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-perf"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Create collection
if err := client.CreateCollection(ctx, collectionID); err != nil {
b.Fatalf("failed to create collection: %v", err)
}
defer client.DeleteCollection(ctx, collectionID)
// Load test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
b.Fatalf("failed to load test image: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
photoID := fmt.Sprintf("perf-test-%d", time.Now().UnixNano())
_, err := client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
b.Fatalf("failed to index face: %v", err)
}
}
}
// BenchmarkFaceSearch benchmarks face search performance
func BenchmarkFaceSearch(b *testing.B) {
if os.Getenv("AWS_REGION") == "" {
b.Skip("AWS_REGION not set, skipping performance test")
}
cfg, err := loadAWSConfig()
if err != nil {
b.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-perf"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Create collection
if err := client.CreateCollection(ctx, collectionID); err != nil {
b.Fatalf("failed to create collection: %v", err)
}
defer client.DeleteCollection(ctx, collectionID)
// Load and index test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
b.Fatalf("failed to load test image: %v", err)
}
photoID := "perf-search-" + time.Now().Format("20060102150405")
_, err = client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
b.Fatalf("failed to index face: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
if err != nil {
b.Fatalf("failed to search faces: %v", err)
}
}
}
// TestPerformanceTargets validates performance targets
func TestPerformanceTargets(t *testing.T) {
if os.Getenv("AWS_REGION") == "" {
t.Skip("AWS_REGION not set, skipping performance test")
}
cfg, err := loadAWSConfig()
if err != nil {
t.Fatalf("failed to load AWS config: %v", err)
}
collectionID := os.Getenv("REKOGNITION_COLLECTION")
if collectionID == "" {
collectionID = "hatch-faces-perf"
}
client := NewRekognitionClient(cfg, collectionID)
ctx := context.Background()
// Create collection
if err := client.CreateCollection(ctx, collectionID); err != nil {
t.Fatalf("failed to create collection: %v", err)
}
defer client.DeleteCollection(ctx, collectionID)
// Load test image
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
if err != nil {
t.Fatalf("failed to load test image: %v", err)
}
// Test face detection latency
start := time.Now()
_, err = client.DetectFaces(ctx, imageBytes)
if err != nil {
t.Fatalf("failed to detect faces: %v", err)
}
detectionLatency := time.Since(start)
if detectionLatency > 2*time.Second {
t.Errorf("face detection latency exceeded 2 seconds: %v", detectionLatency)
}
t.Logf("Face detection latency: %v", detectionLatency)
// Test face indexing latency
start = time.Now()
photoID := "perf-target-" + time.Now().Format("20060102150405")
_, err = client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
t.Fatalf("failed to index face: %v", err)
}
indexingLatency := time.Since(start)
if indexingLatency > 3*time.Second {
t.Errorf("face indexing latency exceeded 3 seconds: %v", indexingLatency)
}
t.Logf("Face indexing latency: %v", indexingLatency)
// Test face search latency
start = time.Now()
_, err = client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
if err != nil {
t.Fatalf("failed to search faces: %v", err)
}
searchLatency := time.Since(start)
if searchLatency > 2*time.Second {
t.Errorf("face search latency exceeded 2 seconds: %v", searchLatency)
}
t.Logf("Face search latency: %v", searchLatency)
// Test end-to-end latency
start = time.Now()
// Detect
_, err = client.DetectFaces(ctx, imageBytes)
if err != nil {
t.Fatalf("failed to detect faces: %v", err)
}
// Index
photoID = "perf-e2e-" + time.Now().Format("20060102150405")
_, err = client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
t.Fatalf("failed to index face: %v", err)
}
// Search
_, err = client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
if err != nil {
t.Fatalf("failed to search faces: %v", err)
}
e2eLatency := time.Since(start)
if e2eLatency > 5*time.Second {
t.Errorf("end-to-end latency exceeded 5 seconds: %v", e2eLatency)
}
t.Logf("End-to-end latency: %v", e2eLatency)
}

View File

@ -0,0 +1,218 @@
package face
import (
"context"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"bytes"
"io"
"mime/multipart"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/rekognition"
"github.com/aws/aws-sdk-go-v2/service/rekognition/types"
)
// RekognitionClient wraps AWS Rekognition operations
type RekognitionClient struct {
client *rekognition.Client
collectionID string
}
// FaceDetection represents a detected face
type FaceDetection struct {
Confidence float64 `json:"confidence"`
BoundingBox BoundingBox `json:"boundingBox"`
}
// BoundingBox represents face location in image
type BoundingBox struct {
Left float64 `json:"left"`
Top float64 `json:"top"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// FaceMatch represents a matched face
type FaceMatch struct {
FaceID string `json:"faceId"`
ExternalImageID string `json:"externalImageId"`
Similarity float64 `json:"similarity"`
BoundingBox BoundingBox `json:"boundingBox"`
PhotoID string `json:"photoId,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
}
// NewRekognitionClient creates a new Rekognition client
func NewRekognitionClient(cfg aws.Config, collectionID string) *RekognitionClient {
return &RekognitionClient{
client: rekognition.NewFromConfig(cfg),
collectionID: collectionID,
}
}
// CreateCollection creates a new face collection
func (r *RekognitionClient) CreateCollection(ctx context.Context, collectionName string) error {
_, err := r.client.CreateCollection(ctx, &rekognition.CreateCollectionInput{
CollectionId: &collectionName,
})
if err != nil {
return fmt.Errorf("failed to create collection: %w", err)
}
return nil
}
// DeleteCollection deletes a face collection
func (r *RekognitionClient) DeleteCollection(ctx context.Context, collectionName string) error {
_, err := r.client.DeleteCollection(ctx, &rekognition.DeleteCollectionInput{
CollectionId: &collectionName,
})
if err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
}
return nil
}
// DetectFaces detects faces in an image
func (r *RekognitionClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
input := &rekognition.DetectFacesInput{
Image: &types.Image{
Bytes: imageBytes,
},
Attributes: []types.Attribute{
types.AttributeAll,
},
}
result, err := r.client.DetectFaces(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to detect faces: %w", err)
}
var detections []FaceDetection
for _, face := range result.FaceDetails {
if face.BoundingBox == nil {
continue
}
detection := FaceDetection{
Confidence: float64(*face.Confidence),
BoundingBox: BoundingBox{
Left: float64(*face.BoundingBox.Left),
Top: float64(*face.BoundingBox.Top),
Width: float64(*face.BoundingBox.Width),
Height: float64(*face.BoundingBox.Height),
},
}
detections = append(detections, detection)
}
return detections, nil
}
// IndexFace indexes a face in the collection
func (r *RekognitionClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
input := &rekognition.IndexFacesInput{
CollectionId: &r.collectionID,
Image: &types.Image{
Bytes: imageBytes,
},
ExternalImageId: &photoID,
MaxFaces: aws.Int32(1),
QualityFilter: types.QualityFilterAuto,
}
result, err := r.client.IndexFaces(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to index face: %w", err)
}
if len(result.FaceRecords) == 0 {
return nil, fmt.Errorf("no faces found in image")
}
faceRecord := result.FaceRecords[0]
if faceRecord.FaceDetail == nil || faceRecord.FaceDetail.BoundingBox == nil {
return nil, fmt.Errorf("no face detail available")
}
return &FaceDetection{
Confidence: float64(*faceRecord.Face.Confidence),
BoundingBox: BoundingBox{
Left: float64(*faceRecord.FaceDetail.BoundingBox.Left),
Top: float64(*faceRecord.FaceDetail.BoundingBox.Top),
Width: float64(*faceRecord.FaceDetail.BoundingBox.Width),
Height: float64(*faceRecord.FaceDetail.BoundingBox.Height),
},
}, nil
}
// SearchFacesByImage searches for similar faces
func (r *RekognitionClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
input := &rekognition.SearchFacesByImageInput{
CollectionId: &r.collectionID,
Image: &types.Image{
Bytes: imageBytes,
},
MaxFaces: aws.Int32(int32(maxFaces)),
FaceMatchThreshold: aws.Float32(float32(threshold)),
}
result, err := r.client.SearchFacesByImage(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to search faces: %w", err)
}
var matches []FaceMatch
for _, match := range result.FaceMatches {
if match.Face == nil || match.Face.BoundingBox == nil {
continue
}
faceMatch := FaceMatch{
FaceID: aws.ToString(match.Face.FaceId),
ExternalImageID: aws.ToString(match.Face.ExternalImageId),
Similarity: float64(aws.ToFloat32(match.Similarity)),
BoundingBox: BoundingBox{
Left: float64(*match.Face.BoundingBox.Left),
Top: float64(*match.Face.BoundingBox.Top),
Width: float64(*match.Face.BoundingBox.Width),
Height: float64(*match.Face.BoundingBox.Height),
},
}
matches = append(matches, faceMatch)
}
return matches, nil
}
// ExtractImageBytes extracts image bytes from a multipart file
func ExtractImageBytes(file multipart.File) ([]byte, error) {
// Read all bytes
data, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// Validate it's an image
_, _, err = image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("invalid image format: %w", err)
}
return data, nil
}
// NewRekognitionClientFromEnv creates a client from environment variables
func NewRekognitionClientFromEnv(collectionID string) (*RekognitionClient, error) {
cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
return NewRekognitionClient(cfg, collectionID), nil
}

203
internal/face/service.go Normal file
View File

@ -0,0 +1,203 @@
package face
import (
"context"
"crypto/sha256"
"fmt"
"log"
"time"
"github.com/elfoundation/hatch/internal/store"
)
// PhotoRepository defines the photo operations needed by the face service
type PhotoRepository interface {
GetPhoto(ctx context.Context, id string) (*store.Photo, error)
CreateFaceEmbedding(ctx context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error)
}
// Service provides face detection, indexing, and search operations
type Service struct {
client Client
repo PhotoRepository
cache CacheService
config *Config
keys CacheKeys
}
// NewService creates a new face service
func NewService(client Client, repo PhotoRepository, cache CacheService, config *Config) *Service {
return &Service{
client: client,
repo: repo,
cache: cache,
config: config,
}
}
// ValidateImage validates that image bytes are a valid image
func (s *Service) ValidateImage(imageBytes []byte) error {
if len(imageBytes) > s.config.MaxImageSize {
return ErrImageTooLarge
}
if len(imageBytes) < 100 { // Minimum viable image size
return ErrImageTooSmall
}
// Check for valid image headers
if len(imageBytes) < 4 {
return ErrInvalidImageFormat
}
// JPEG: starts with FF D8 FF
// PNG: starts with 89 50 4E 47
// WebP: starts with 52 49 46 46 (RIFF)
if imageBytes[0] == 0xFF && imageBytes[1] == 0xD8 && imageBytes[2] == 0xFF {
return nil // JPEG
}
if imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47 {
return nil // PNG
}
if imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46 {
return nil // WebP (RIFF)
}
return ErrInvalidImageFormat
}
// IndexPhoto indexes all faces in a photo
func (s *Service) IndexPhoto(ctx context.Context, photoID string, imageBytes []byte) error {
// Check if already indexed
indexKey := s.keys.Indexed(photoID)
if indexed, _ := s.cache.Get(ctx, indexKey); indexed == "true" {
return nil
}
// Detect faces
detections, err := s.client.DetectFaces(ctx, imageBytes)
if err != nil {
return fmt.Errorf("failed to detect faces: %w", err)
}
if len(detections) == 0 {
log.Printf("No faces detected in photo %s", photoID)
return nil
}
// Get photo metadata
photo, err := s.repo.GetPhoto(ctx, photoID)
if err != nil {
return fmt.Errorf("failed to get photo: %w", err)
}
// Index each face with confidence > 99%
indexedCount := 0
for _, detection := range detections {
if detection.Confidence < 99.0 {
continue
}
// Index face in Rekognition
_, err := s.client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
log.Printf("Failed to index face in photo %s: %v", photoID, err)
continue
}
// Store in database
embedding := &store.FaceEmbedding{
PhotoID: photoID,
SpaceID: photo.SpaceID,
Embedding: []float32{}, // Rekognition handles embeddings internally
BoundingBox: fmt.Sprintf("%.2f,%.2f,%.2f,%.2f",
detection.BoundingBox.Left,
detection.BoundingBox.Top,
detection.BoundingBox.Width,
detection.BoundingBox.Height),
}
if _, err := s.repo.CreateFaceEmbedding(ctx, embedding); err != nil {
log.Printf("Failed to store face embedding for photo %s: %v", photoID, err)
continue
}
indexedCount++
}
// Mark as indexed in cache
s.cache.Set(ctx, indexKey, "true", s.config.PhotoCacheTTL)
log.Printf("Indexed %d faces in photo %s", indexedCount, photoID)
return nil
}
// SearchFaces searches for faces in a space
func (s *Service) SearchFaces(ctx context.Context, spaceID string, imageBytes []byte, limit int) (*FaceSearchResult, error) {
startTime := time.Now()
// Check cache for similar searches
imageHash := computeHash(imageBytes)
cacheKey := s.keys.Search(spaceID, imageHash)
var cachedResult FaceSearchResult
if err := s.cache.GetJSON(ctx, cacheKey, &cachedResult); err == nil && cachedResult.Matches != nil {
cachedResult.CacheHit = true
cachedResult.SearchTime = time.Since(startTime)
return &cachedResult, nil
}
// Search in Rekognition
matches, err := s.client.SearchFacesByImage(ctx, imageBytes, limit, s.config.Threshold)
if err != nil {
return nil, fmt.Errorf("failed to search faces: %w", err)
}
// Get photo metadata for matches
var resultMatches []FaceMatch
for _, match := range matches {
// Get photo ID from external image ID
photoID := match.ExternalImageID
photo, err := s.repo.GetPhoto(ctx, photoID)
if err != nil {
log.Printf("Failed to get photo %s: %v", photoID, err)
continue
}
// Enrich match with photo data
match.PhotoID = photoID
match.PhotoURL = photo.URL
match.ThumbnailURL = photo.ThumbnailURL
resultMatches = append(resultMatches, match)
}
// Build result
result := &FaceSearchResult{
Matches: resultMatches,
PhotoCount: len(resultMatches),
SearchTime: time.Since(startTime),
CacheHit: false,
}
// Cache result
s.cache.SetJSON(ctx, cacheKey, result, s.config.SearchCacheTTL)
return result, nil
}
// CreateCollection creates a face collection for a space
func (s *Service) CreateCollection(ctx context.Context, collectionName string) error {
return s.client.CreateCollection(ctx, collectionName)
}
// DeleteCollection deletes a face collection for a space
func (s *Service) DeleteCollection(ctx context.Context, collectionName string) error {
return s.client.DeleteCollection(ctx, collectionName)
}
// computeHash computes a SHA256 hash of the image bytes for caching
func computeHash(data []byte) string {
// Use first 1KB for quick hashing (full hash is expensive for large images)
sampleSize := 1024
if len(data) < sampleSize {
sampleSize = len(data)
}
hash := sha256.Sum256(data[:sampleSize])
return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter key
}

View File

@ -0,0 +1,317 @@
package face
import (
"context"
"testing"
"time"
"github.com/elfoundation/hatch/internal/store"
)
// MockPhotoRepository is a mock implementation of PhotoRepository for testing
type MockPhotoRepository struct {
photos map[string]*store.Photo
embeddings []*store.FaceEmbedding
}
func NewMockPhotoRepository() *MockPhotoRepository {
return &MockPhotoRepository{
photos: make(map[string]*store.Photo),
}
}
func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*store.Photo, error) {
if photo, ok := m.photos[id]; ok {
return photo, nil
}
return &store.Photo{
ID: id,
SpaceID: "test-space",
URL: "https://example.com/photo.jpg",
}, nil
}
func (m *MockPhotoRepository) CreateFaceEmbedding(ctx context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) {
m.embeddings = append(m.embeddings, embedding)
return embedding, nil
}
func (m *MockPhotoRepository) AddPhoto(photo *store.Photo) {
m.photos[photo.ID] = photo
}
// Test helper functions
func setupTestService() (*Service, *MockClient, *MockPhotoRepository, *MemoryCache) {
mockClient := NewMockClient()
mockRepo := NewMockPhotoRepository()
cache := NewMemoryCache()
config := DefaultConfig()
service := NewService(mockClient, mockRepo, cache, config)
return service, mockClient, mockRepo, cache
}
// Tests
func TestService_IndexPhoto_Success(t *testing.T) {
service, mockClient, mockRepo, _ := setupTestService()
ctx := context.Background()
// Test data
photoID := "test-photo-1"
imageBytes := []byte("test-image-data")
// Add photo to mock repo
mockRepo.AddPhoto(&store.Photo{
ID: photoID,
SpaceID: "test-space-1",
})
// Execute
err := service.IndexPhoto(ctx, photoID, imageBytes)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
_ = mockClient // Client was called internally
}
func TestService_IndexPhoto_AlreadyIndexed(t *testing.T) {
service, _, _, cache := setupTestService()
ctx := context.Background()
// Test data
photoID := "test-photo-1"
imageBytes := []byte("test-image-data")
// Pre-populate cache
cache.Set(ctx, "indexed:"+photoID, "true", 24*time.Hour)
// Execute
err := service.IndexPhoto(ctx, photoID, imageBytes)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestService_SearchFaces_Success(t *testing.T) {
service, _, mockRepo, _ := setupTestService()
ctx := context.Background()
// Test data
spaceID := "test-space-1"
imageBytes := []byte("test-image-data")
limit := 10
// Add photo to mock repo
mockRepo.AddPhoto(&store.Photo{
ID: "photo-1",
SpaceID: spaceID,
URL: "https://example.com/photo-1.jpg",
})
// Execute
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if result == nil {
t.Fatal("Expected result, got nil")
}
if len(result.Matches) == 0 {
t.Error("Expected at least one match")
}
if result.CacheHit {
t.Error("Expected cache hit to be false")
}
}
func TestService_SearchFaces_CacheHit(t *testing.T) {
service, _, _, cache := setupTestService()
ctx := context.Background()
// Test data
spaceID := "test-space-1"
imageBytes := []byte("test-image-data")
limit := 10
// Pre-populate cache with a result
cachedResult := &FaceSearchResult{
Matches: []FaceMatch{
{
FaceID: "cached-face-1",
Similarity: 95.0,
},
},
PhotoCount: 1,
}
cache.SetJSON(ctx, "search:"+spaceID+":"+computeHash(imageBytes), cachedResult, 5*time.Minute)
// Execute
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if result == nil {
t.Fatal("Expected result, got nil")
}
if !result.CacheHit {
t.Error("Expected cache hit to be true")
}
if result.PhotoCount != 1 {
t.Errorf("Expected 1 photo, got %d", result.PhotoCount)
}
}
func TestService_CreateCollection(t *testing.T) {
service, _, _, _ := setupTestService()
ctx := context.Background()
// Execute
err := service.CreateCollection(ctx, "test-collection")
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestService_DeleteCollection(t *testing.T) {
service, _, _, _ := setupTestService()
ctx := context.Background()
// Execute
err := service.DeleteCollection(ctx, "test-collection")
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestConfig_Default(t *testing.T) {
config := DefaultConfig()
if config.Region != "us-east-1" {
t.Errorf("Expected region us-east-1, got %s", config.Region)
}
if config.CollectionID != "hatch-faces" {
t.Errorf("Expected collection ID hatch-faces, got %s", config.CollectionID)
}
if config.MaxFaces != 10 {
t.Errorf("Expected max faces 10, got %d", config.MaxFaces)
}
if config.Threshold != 80.0 {
t.Errorf("Expected threshold 80.0, got %f", config.Threshold)
}
if config.SearchCacheTTL != 5*time.Minute {
t.Errorf("Expected search cache TTL 5m, got %v", config.SearchCacheTTL)
}
}
func TestConfig_Validate(t *testing.T) {
// Valid config
config := DefaultConfig()
if err := config.Validate(); err != nil {
t.Errorf("Expected valid config, got error: %v", err)
}
// Invalid: missing region
invalidConfig := DefaultConfig()
invalidConfig.Region = ""
if err := invalidConfig.Validate(); err == nil {
t.Error("Expected error for missing region")
}
// Invalid: missing collection ID
invalidConfig2 := DefaultConfig()
invalidConfig2.CollectionID = ""
if err := invalidConfig2.Validate(); err == nil {
t.Error("Expected error for missing collection ID")
}
}
func TestCacheKeys(t *testing.T) {
keys := CacheKeys{}
if keys.Search("space1", "hash1") != "search:space1:hash1" {
t.Error("Search key mismatch")
}
if keys.Photo("photo1") != "photo:photo1" {
t.Error("Photo key mismatch")
}
if keys.Face("face1") != "face:face1" {
t.Error("Face key mismatch")
}
if keys.Indexed("photo1") != "indexed:photo1" {
t.Error("Indexed key mismatch")
}
}
func TestValidateImage(t *testing.T) {
service, _, _, _ := setupTestService()
// Test with valid JPEG header (need at least 100 bytes)
validJPEG := make([]byte, 200)
validJPEG[0] = 0xFF
validJPEG[1] = 0xD8
validJPEG[2] = 0xFF
validJPEG[3] = 0xE0
if err := service.ValidateImage(validJPEG); err != nil {
t.Errorf("Expected no error for valid JPEG, got: %v", err)
}
// Test with valid PNG header (need at least 100 bytes)
validPNG := make([]byte, 200)
validPNG[0] = 0x89
validPNG[1] = 0x50
validPNG[2] = 0x4E
validPNG[3] = 0x47
if err := service.ValidateImage(validPNG); err != nil {
t.Errorf("Expected no error for valid PNG, got: %v", err)
}
// Test with too large image
largeImage := make([]byte, 11*1024*1024) // 11MB
if err := service.ValidateImage(largeImage); err != ErrImageTooLarge {
t.Errorf("Expected ErrImageTooLarge, got: %v", err)
}
// Test with too small image
smallImage := make([]byte, 10)
if err := service.ValidateImage(smallImage); err != ErrImageTooSmall {
t.Errorf("Expected ErrImageTooSmall, got: %v", err)
}
// Test with invalid format (large enough but wrong header)
invalidImage := make([]byte, 200)
if err := service.ValidateImage(invalidImage); err != ErrInvalidImageFormat {
t.Errorf("Expected ErrInvalidImageFormat, got: %v", err)
}
}
func TestComputeHash(t *testing.T) {
data1 := []byte("test data for hashing")
data2 := []byte("different data for hashing")
hash1 := computeHash(data1)
hash2 := computeHash(data2)
if hash1 == hash2 {
t.Error("Expected different hashes for different data")
}
// Same data should produce same hash
hash1Again := computeHash(data1)
if hash1 != hash1Again {
t.Error("Expected same hash for same data")
}
}

View File

@ -5,8 +5,12 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"strconv"
"strings" "strings"
"sync"
"time"
"github.com/elfoundation/hatch/internal/face"
"github.com/elfoundation/hatch/internal/store" "github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@ -16,6 +20,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 +28,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 +59,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))
@ -154,3 +170,115 @@ func writeError(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg}) json.NewEncoder(w).Encode(map[string]string{"error": msg})
} }
// ---------------------------------------------------------------------------
// Security Headers Middleware
// ---------------------------------------------------------------------------
// SecurityHeaders adds standard security headers to all responses.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https:; "+
"font-src 'self' data:; "+
"connect-src 'self'; "+
"frame-ancestors 'none';")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()")
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
w.Header().Set("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload")
}
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------------
// In-Memory Rate Limiter (sliding window)
// ---------------------------------------------------------------------------
// RateLimiter provides per-IP rate limiting without external dependencies.
type RateLimiter struct {
mu sync.Mutex
windows map[string][]time.Time
limit int
window time.Duration
}
// NewRateLimiter creates a rate limiter with the given limit per window.
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
windows: make(map[string][]time.Time),
limit: limit,
window: window,
}
}
// Limit returns chi-compatible middleware.
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
now := time.Now()
cutoff := now.Add(-rl.window)
rl.mu.Lock()
slots := rl.windows[ip]
fresh := slots[:0]
for _, t := range slots {
if t.After(cutoff) {
fresh = append(fresh, t)
}
}
if len(fresh) >= rl.limit {
rl.windows[ip] = fresh
rl.mu.Unlock()
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit))
w.Header().Set("X-RateLimit-Remaining", "0")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
fresh = append(fresh, now)
rl.windows[ip] = fresh
rl.mu.Unlock()
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit))
w.Header().Set("X-RateLimit-Remaining",
strconv.Itoa(rl.limit-len(fresh)))
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------------
// Input Validation
// ---------------------------------------------------------------------------
// ValidateSlug checks that a slug contains only lowercase alphanumeric and hyphens.
func ValidateSlug(slug string) bool {
if len(slug) < 3 || len(slug) > 100 {
return false
}
for _, c := range slug {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
return false
}
}
return true
}
// SanitizeSearchQuery strips characters that could cause injection issues.
func SanitizeSearchQuery(q string) string {
q = strings.ReplaceAll(q, "<", "")
q = strings.ReplaceAll(q, ">", "")
q = strings.ReplaceAll(q, "'", "")
q = strings.ReplaceAll(q, "\"", "")
q = strings.ReplaceAll(q, ";", "")
q = strings.ReplaceAll(q, "--", "")
return q
}

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,296 @@
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)
// Face search without face service configured should return 500
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.StatusInternalServerError {
t.Fatalf("expected 500 when face service not configured, 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["error"] != "face search service not configured" {
t.Errorf("expected error about face search service not configured, got %q", response["error"])
}
}

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,6 +12,12 @@ 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.
@ -19,6 +25,10 @@ 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" }

View File

@ -1,7 +1,6 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "export",
images: { images: {
unoptimized: true, unoptimized: true,
}, },

10
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"next": "16.2.9", "next": "16.2.9",
"qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },
@ -5515,6 +5516,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"next": "16.2.9", "next": "16.2.9",
"qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },