Compare commits

..

No commits in common. "5bce1bd39bc2338373217362aea156e36301619a" and "6e472f3eea19b75ab146435136169c5d8be9e0a6" have entirely different histories.

26 changed files with 6 additions and 3319 deletions

View File

@ -1,65 +0,0 @@
'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,8 +10,6 @@ import (
"syscall"
"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/store"
"github.com/go-chi/chi/v5"
@ -35,39 +33,9 @@ func main() {
}
defer repo.Close()
// 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 := handler.New(repo)
h.Debug = os.Getenv("HATCH_DEBUG") != ""
r := chi.NewRouter()
// Security and performance middleware
r.Use(handler.SecurityHeaders)
r.Use(handler.NewRateLimiter(100, time.Minute).Limit)
h.RegisterRoutes(r)
addr := fmt.Sprintf(":%s", port)
@ -98,4 +66,4 @@ func main() {
}
log.Println("server exited")
}
}

View File

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

30
go.mod
View File

@ -1,48 +1,20 @@
module github.com/elfoundation/hatch
go 1.26.4
go 1.25.0
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/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
)
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/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/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
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
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/elfoundation/hatch => .

53
go.sum
View File

@ -1,37 +1,3 @@
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
@ -44,26 +10,10 @@ 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/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/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/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/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/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@ -73,9 +23,6 @@ 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/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
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/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=

View File

@ -1,153 +0,0 @@
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
}

View File

@ -1,191 +0,0 @@
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
}

View File

@ -1,102 +0,0 @@
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
}

View File

@ -1,94 +0,0 @@
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

@ -1,281 +0,0 @@
//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")
}

View File

@ -1,35 +0,0 @@
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

@ -1,225 +0,0 @@
//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

@ -1,218 +0,0 @@
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
}

View File

@ -1,203 +0,0 @@
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

@ -1,317 +0,0 @@
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,12 +5,8 @@ import (
"io"
"net/http"
"net/http/pprof"
"strconv"
"strings"
"sync"
"time"
"github.com/elfoundation/hatch/internal/face"
"github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@ -20,7 +16,6 @@ import (
type Handler struct {
Repo store.Repository
Debug bool
FaceService *face.Service
}
// New creates a new Handler with the given store.
@ -28,14 +23,6 @@ func New(repo store.Repository) *Handler {
return &Handler{Repo: repo}
}
// NewWithFaceService creates a new Handler with face search service.
func NewWithFaceService(repo store.Repository, faceService *face.Service) *Handler {
return &Handler{
Repo: repo,
FaceService: faceService,
}
}
// RegisterRoutes mounts all routes on the given chi router.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Use(middleware.Logger)
@ -59,9 +46,6 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
// JSON API v1 routes.
h.RegisterV1Routes(r)
// Space management routes.
h.RegisterSpaceRoutes(r)
// Inspect page: server-rendered request list.
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
@ -170,115 +154,3 @@ func writeError(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status)
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
}

View File

@ -1,383 +0,0 @@
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

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

View File

@ -28,50 +28,7 @@ type MockConfig struct {
Body []byte `json:"body"`
}
// Space represents a public or private demo space
type Space struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// Photo represents a photo in a space gallery
type Photo struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
URL string `json:"url"`
ThumbnailURL string `json:"thumbnailUrl"`
FaceCount int `json:"faceCount"`
Metadata string `json:"metadata"`
CreatedAt string `json:"createdAt"`
}
// FaceEmbedding stores face embeddings for search
type FaceEmbedding struct {
ID string `json:"id"`
PhotoID string `json:"photoId"`
SpaceID string `json:"spaceId"`
Embedding []float32 `json:"embedding"`
BoundingBox string `json:"boundingBox"`
CreatedAt string `json:"createdAt"`
}
// RateLimit tracks rate limiting for guest face search
type RateLimit struct {
ID string `json:"id"`
IPAddress string `json:"ipAddress"`
SpaceID string `json:"spaceId"`
RequestCount int `json:"requestCount"`
WindowStart string `json:"windowStart"`
CreatedAt string `json:"createdAt"`
}
type Repository interface {
// Endpoint operations
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
AppendRequest(ctx context.Context, endpointID string, req *Request) error
@ -80,29 +37,6 @@ type Repository interface {
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
SetMock(ctx context.Context, mock *MockConfig) error
// Space operations
CreateSpace(ctx context.Context, space *Space) (*Space, error)
GetSpaceBySlug(ctx context.Context, slug string) (*Space, error)
UpdateSpace(ctx context.Context, space *Space) (*Space, error)
DeleteSpace(ctx context.Context, id string) error
ListPublicSpaces(ctx context.Context) ([]*Space, error)
// Photo operations
CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error)
GetPhoto(ctx context.Context, id string) (*Photo, error)
ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error)
DeletePhoto(ctx context.Context, id string) error
// Face embedding operations
CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error)
ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error)
SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error)
// Rate limiting operations
CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error)
IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error
Close() error
Ping(ctx context.Context) error
}

View File

@ -1,51 +0,0 @@
-- 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

@ -1,248 +0,0 @@
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,23 +12,13 @@ type FakeRepository struct {
Endpoints map[string]*store.Endpoint
Requests []*store.Request
Mocks map[string]*store.MockConfig
// Space-related fields
Spaces map[string]*store.Space
Photos map[string]*store.Photo
FaceEmbeddings map[string]*store.FaceEmbedding
RateLimits map[string]*store.RateLimit
}
// NewFakeRepository creates a new FakeRepository.
func NewFakeRepository() *FakeRepository {
return &FakeRepository{
Endpoints: map[string]*store.Endpoint{},
Mocks: map[string]*store.MockConfig{},
Spaces: map[string]*store.Space{},
Photos: map[string]*store.Photo{},
FaceEmbeddings: map[string]*store.FaceEmbedding{},
RateLimits: map[string]*store.RateLimit{},
Endpoints: map[string]*store.Endpoint{},
Mocks: map[string]*store.MockConfig{},
}
}
@ -103,118 +93,6 @@ func (f *FakeRepository) Close() error { return nil }
func (f *FakeRepository) Ping(_ context.Context) error { return nil }
// Space operations
func (f *FakeRepository) CreateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
f.Spaces[space.Slug] = space
return space, nil
}
func (f *FakeRepository) GetSpaceBySlug(_ context.Context, slug string) (*store.Space, error) {
space, ok := f.Spaces[slug]
if !ok {
return nil, errNotFound
}
return space, nil
}
func (f *FakeRepository) UpdateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
f.Spaces[space.Slug] = space
return space, nil
}
func (f *FakeRepository) DeleteSpace(_ context.Context, id string) error {
for slug, space := range f.Spaces {
if space.ID == id {
delete(f.Spaces, slug)
return nil
}
}
return errNotFound
}
func (f *FakeRepository) ListPublicSpaces(_ context.Context) ([]*store.Space, error) {
var spaces []*store.Space
for _, space := range f.Spaces {
if space.IsPublic {
spaces = append(spaces, space)
}
}
return spaces, nil
}
// Photo operations
func (f *FakeRepository) CreatePhoto(_ context.Context, photo *store.Photo) (*store.Photo, error) {
f.Photos[photo.ID] = photo
return photo, nil
}
func (f *FakeRepository) GetPhoto(_ context.Context, id string) (*store.Photo, error) {
photo, ok := f.Photos[id]
if !ok {
return nil, errNotFound
}
return photo, nil
}
func (f *FakeRepository) ListPhotosBySpace(_ context.Context, spaceID string, limit int) ([]*store.Photo, error) {
var photos []*store.Photo
for _, photo := range f.Photos {
if photo.SpaceID == spaceID {
photos = append(photos, photo)
if limit > 0 && len(photos) >= limit {
break
}
}
}
return photos, nil
}
func (f *FakeRepository) DeletePhoto(_ context.Context, id string) error {
for photoID := range f.Photos {
if photoID == id {
delete(f.Photos, photoID)
return nil
}
}
return errNotFound
}
// Face embedding operations
func (f *FakeRepository) CreateFaceEmbedding(_ context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) {
f.FaceEmbeddings[embedding.ID] = embedding
return embedding, nil
}
func (f *FakeRepository) ListFaceEmbeddingsBySpace(_ context.Context, spaceID string) ([]*store.FaceEmbedding, error) {
var embeddings []*store.FaceEmbedding
for _, embedding := range f.FaceEmbeddings {
if embedding.SpaceID == spaceID {
embeddings = append(embeddings, embedding)
}
}
return embeddings, nil
}
func (f *FakeRepository) SearchFaceEmbeddings(_ context.Context, spaceID string, embedding []float32, limit int) ([]*store.FaceEmbedding, error) {
// For fake repo, just return all embeddings in the space
return f.ListFaceEmbeddingsBySpace(nil, spaceID)
}
// Rate limiting operations
func (f *FakeRepository) CheckRateLimit(_ context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
// For fake repo, always allow
return true, nil
}
func (f *FakeRepository) IncrementRateLimit(_ context.Context, ipAddress, spaceID string, windowMinutes int) error {
// For fake repo, do nothing
return nil
}
type notFoundError struct{}
func (e *notFoundError) Error() string { return "not found" }

View File

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

10
package-lock.json generated
View File

@ -9,7 +9,6 @@
"version": "0.1.0",
"dependencies": {
"next": "16.2.9",
"qrcode.react": "^4.2.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
@ -5516,15 +5515,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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

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