Compare commits
No commits in common. "5bce1bd39bc2338373217362aea156e36301619a" and "6e472f3eea19b75ab146435136169c5d8be9e0a6" have entirely different histories.
5bce1bd39b
...
6e472f3eea
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -10,8 +10,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
|
||||||
"github.com/elfoundation/hatch/internal/face"
|
|
||||||
"github.com/elfoundation/hatch/internal/handler"
|
"github.com/elfoundation/hatch/internal/handler"
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@ -35,39 +33,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer repo.Close()
|
defer repo.Close()
|
||||||
|
|
||||||
// Initialize face service if enabled
|
h := handler.New(repo)
|
||||||
var faceService *face.Service
|
|
||||||
if os.Getenv("FACE_SERVICE_ENABLED") == "true" {
|
|
||||||
cfg, err := config.LoadDefaultConfig(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("warning: failed to load AWS config: %v", err)
|
|
||||||
} else {
|
|
||||||
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
|
||||||
if collectionID == "" {
|
|
||||||
collectionID = "hatch-faces"
|
|
||||||
}
|
|
||||||
rekognitionClient := face.NewRekognitionClient(cfg, collectionID)
|
|
||||||
faceConfig := face.DefaultConfig()
|
|
||||||
faceCache := face.NewMemoryCache()
|
|
||||||
faceService = face.NewService(rekognitionClient, repo, faceCache, faceConfig)
|
|
||||||
log.Println("face search service initialized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create handler with or without face service
|
|
||||||
var h *handler.Handler
|
|
||||||
if faceService != nil {
|
|
||||||
h = handler.NewWithFaceService(repo, faceService)
|
|
||||||
} else {
|
|
||||||
h = handler.New(repo)
|
|
||||||
}
|
|
||||||
h.Debug = os.Getenv("HATCH_DEBUG") != ""
|
h.Debug = os.Getenv("HATCH_DEBUG") != ""
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Security and performance middleware
|
|
||||||
r.Use(handler.SecurityHeaders)
|
|
||||||
r.Use(handler.NewRateLimiter(100, time.Minute).Limit)
|
|
||||||
|
|
||||||
h.RegisterRoutes(r)
|
h.RegisterRoutes(r)
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%s", port)
|
addr := fmt.Sprintf(":%s", port)
|
||||||
@ -98,4 +66,4 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Println("server exited")
|
log.Println("server exited")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ services:
|
|||||||
- HATCH_PORT=8080
|
- HATCH_PORT=8080
|
||||||
- HATCH_DB_PATH=/data/hatch.db
|
- HATCH_DB_PATH=/data/hatch.db
|
||||||
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
|
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
|
||||||
- REDIS_URL=${REDIS_URL:-}
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
|
|||||||
30
go.mod
30
go.mod
@ -1,48 +1,20 @@
|
|||||||
module github.com/elfoundation/hatch
|
module github.com/elfoundation/hatch
|
||||||
|
|
||||||
go 1.26.4
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.42.0
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/rekognition v1.52.3
|
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
|
||||||
github.com/redis/go-redis/v9 v9.21.0
|
|
||||||
modernc.org/sqlite v1.53.0
|
modernc.org/sqlite v1.53.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
|
||||||
github.com/aws/smithy-go v1.27.1 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
|
||||||
modernc.org/libc v1.73.4 // indirect
|
modernc.org/libc v1.73.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/elfoundation/hatch => .
|
|
||||||
|
|||||||
53
go.sum
53
go.sum
@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
@ -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/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
|
||||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
|
||||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
|
||||||
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
|
||||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
|
||||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
|
||||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
@ -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/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
|
||||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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."
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
@ -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"`
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,12 +5,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/elfoundation/hatch/internal/face"
|
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@ -20,7 +16,6 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
Repo store.Repository
|
Repo store.Repository
|
||||||
Debug bool
|
Debug bool
|
||||||
FaceService *face.Service
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handler with the given store.
|
// New creates a new Handler with the given store.
|
||||||
@ -28,14 +23,6 @@ func New(repo store.Repository) *Handler {
|
|||||||
return &Handler{Repo: repo}
|
return &Handler{Repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithFaceService creates a new Handler with face search service.
|
|
||||||
func NewWithFaceService(repo store.Repository, faceService *face.Service) *Handler {
|
|
||||||
return &Handler{
|
|
||||||
Repo: repo,
|
|
||||||
FaceService: faceService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes mounts all routes on the given chi router.
|
// RegisterRoutes mounts all routes on the given chi router.
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@ -59,9 +46,6 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
// JSON API v1 routes.
|
// JSON API v1 routes.
|
||||||
h.RegisterV1Routes(r)
|
h.RegisterV1Routes(r)
|
||||||
|
|
||||||
// Space management routes.
|
|
||||||
h.RegisterSpaceRoutes(r)
|
|
||||||
|
|
||||||
// Inspect page: server-rendered request list.
|
// Inspect page: server-rendered request list.
|
||||||
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
||||||
|
|
||||||
@ -170,115 +154,3 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Security Headers Middleware
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// SecurityHeaders adds standard security headers to all responses.
|
|
||||||
func SecurityHeaders(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
||||||
w.Header().Set("Content-Security-Policy",
|
|
||||||
"default-src 'self'; "+
|
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
|
||||||
"style-src 'self' 'unsafe-inline'; "+
|
|
||||||
"img-src 'self' data: https:; "+
|
|
||||||
"font-src 'self' data:; "+
|
|
||||||
"connect-src 'self'; "+
|
|
||||||
"frame-ancestors 'none';")
|
|
||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
||||||
w.Header().Set("Permissions-Policy",
|
|
||||||
"camera=(), microphone=(), geolocation=(), payment=()")
|
|
||||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
|
||||||
w.Header().Set("Strict-Transport-Security",
|
|
||||||
"max-age=31536000; includeSubDomains; preload")
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// In-Memory Rate Limiter (sliding window)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// RateLimiter provides per-IP rate limiting without external dependencies.
|
|
||||||
type RateLimiter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
windows map[string][]time.Time
|
|
||||||
limit int
|
|
||||||
window time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRateLimiter creates a rate limiter with the given limit per window.
|
|
||||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
|
||||||
return &RateLimiter{
|
|
||||||
windows: make(map[string][]time.Time),
|
|
||||||
limit: limit,
|
|
||||||
window: window,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit returns chi-compatible middleware.
|
|
||||||
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ip := getClientIP(r)
|
|
||||||
now := time.Now()
|
|
||||||
cutoff := now.Add(-rl.window)
|
|
||||||
|
|
||||||
rl.mu.Lock()
|
|
||||||
slots := rl.windows[ip]
|
|
||||||
fresh := slots[:0]
|
|
||||||
for _, t := range slots {
|
|
||||||
if t.After(cutoff) {
|
|
||||||
fresh = append(fresh, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(fresh) >= rl.limit {
|
|
||||||
rl.windows[ip] = fresh
|
|
||||||
rl.mu.Unlock()
|
|
||||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit))
|
|
||||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
|
||||||
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fresh = append(fresh, now)
|
|
||||||
rl.windows[ip] = fresh
|
|
||||||
rl.mu.Unlock()
|
|
||||||
|
|
||||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit))
|
|
||||||
w.Header().Set("X-RateLimit-Remaining",
|
|
||||||
strconv.Itoa(rl.limit-len(fresh)))
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Input Validation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ValidateSlug checks that a slug contains only lowercase alphanumeric and hyphens.
|
|
||||||
func ValidateSlug(slug string) bool {
|
|
||||||
if len(slug) < 3 || len(slug) > 100 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, c := range slug {
|
|
||||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// SanitizeSearchQuery strips characters that could cause injection issues.
|
|
||||||
func SanitizeSearchQuery(q string) string {
|
|
||||||
q = strings.ReplaceAll(q, "<", "")
|
|
||||||
q = strings.ReplaceAll(q, ">", "")
|
|
||||||
q = strings.ReplaceAll(q, "'", "")
|
|
||||||
q = strings.ReplaceAll(q, "\"", "")
|
|
||||||
q = strings.ReplaceAll(q, ";", "")
|
|
||||||
q = strings.ReplaceAll(q, "--", "")
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,9 +12,6 @@ import (
|
|||||||
//go:embed schema.sql
|
//go:embed schema.sql
|
||||||
var schemaSQL string
|
var schemaSQL string
|
||||||
|
|
||||||
//go:embed schema_spaces.sql
|
|
||||||
var schemaSpacesSQL string
|
|
||||||
|
|
||||||
func Open(dbPath string) (Repository, error) {
|
func Open(dbPath string) (Repository, error) {
|
||||||
if dbPath == "" {
|
if dbPath == "" {
|
||||||
dbPath = filepath.Join("data", "hatch.db")
|
dbPath = filepath.Join("data", "hatch.db")
|
||||||
@ -42,17 +39,9 @@ func Open(dbPath string) (Repository, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrate(db *sql.DB) error {
|
func migrate(db *sql.DB) error {
|
||||||
// Load main schema
|
|
||||||
_, err := db.Exec(schemaSQL)
|
_, err := db.Exec(schemaSQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store/migrate: %w", err)
|
return fmt.Errorf("store/migrate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load spaces schema
|
|
||||||
_, err = db.Exec(schemaSpacesSQL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("store/migrate spaces: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,50 +28,7 @@ type MockConfig struct {
|
|||||||
Body []byte `json:"body"`
|
Body []byte `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Space represents a public or private demo space
|
|
||||||
type Space struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
IsPublic bool `json:"isPublic"`
|
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
UpdatedAt string `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Photo represents a photo in a space gallery
|
|
||||||
type Photo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
SpaceID string `json:"spaceId"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
ThumbnailURL string `json:"thumbnailUrl"`
|
|
||||||
FaceCount int `json:"faceCount"`
|
|
||||||
Metadata string `json:"metadata"`
|
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FaceEmbedding stores face embeddings for search
|
|
||||||
type FaceEmbedding struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
PhotoID string `json:"photoId"`
|
|
||||||
SpaceID string `json:"spaceId"`
|
|
||||||
Embedding []float32 `json:"embedding"`
|
|
||||||
BoundingBox string `json:"boundingBox"`
|
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RateLimit tracks rate limiting for guest face search
|
|
||||||
type RateLimit struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
IPAddress string `json:"ipAddress"`
|
|
||||||
SpaceID string `json:"spaceId"`
|
|
||||||
RequestCount int `json:"requestCount"`
|
|
||||||
WindowStart string `json:"windowStart"`
|
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
// Endpoint operations
|
|
||||||
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
|
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
|
||||||
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
|
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
|
||||||
AppendRequest(ctx context.Context, endpointID string, req *Request) error
|
AppendRequest(ctx context.Context, endpointID string, req *Request) error
|
||||||
@ -80,29 +37,6 @@ type Repository interface {
|
|||||||
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
|
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
|
||||||
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
||||||
SetMock(ctx context.Context, mock *MockConfig) error
|
SetMock(ctx context.Context, mock *MockConfig) error
|
||||||
|
|
||||||
// Space operations
|
|
||||||
CreateSpace(ctx context.Context, space *Space) (*Space, error)
|
|
||||||
GetSpaceBySlug(ctx context.Context, slug string) (*Space, error)
|
|
||||||
UpdateSpace(ctx context.Context, space *Space) (*Space, error)
|
|
||||||
DeleteSpace(ctx context.Context, id string) error
|
|
||||||
ListPublicSpaces(ctx context.Context) ([]*Space, error)
|
|
||||||
|
|
||||||
// Photo operations
|
|
||||||
CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error)
|
|
||||||
GetPhoto(ctx context.Context, id string) (*Photo, error)
|
|
||||||
ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error)
|
|
||||||
DeletePhoto(ctx context.Context, id string) error
|
|
||||||
|
|
||||||
// Face embedding operations
|
|
||||||
CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error)
|
|
||||||
ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error)
|
|
||||||
SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error)
|
|
||||||
|
|
||||||
// Rate limiting operations
|
|
||||||
CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error)
|
|
||||||
IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error
|
|
||||||
|
|
||||||
Close() error
|
Close() error
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -12,23 +12,13 @@ type FakeRepository struct {
|
|||||||
Endpoints map[string]*store.Endpoint
|
Endpoints map[string]*store.Endpoint
|
||||||
Requests []*store.Request
|
Requests []*store.Request
|
||||||
Mocks map[string]*store.MockConfig
|
Mocks map[string]*store.MockConfig
|
||||||
|
|
||||||
// Space-related fields
|
|
||||||
Spaces map[string]*store.Space
|
|
||||||
Photos map[string]*store.Photo
|
|
||||||
FaceEmbeddings map[string]*store.FaceEmbedding
|
|
||||||
RateLimits map[string]*store.RateLimit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFakeRepository creates a new FakeRepository.
|
// NewFakeRepository creates a new FakeRepository.
|
||||||
func NewFakeRepository() *FakeRepository {
|
func NewFakeRepository() *FakeRepository {
|
||||||
return &FakeRepository{
|
return &FakeRepository{
|
||||||
Endpoints: map[string]*store.Endpoint{},
|
Endpoints: map[string]*store.Endpoint{},
|
||||||
Mocks: map[string]*store.MockConfig{},
|
Mocks: map[string]*store.MockConfig{},
|
||||||
Spaces: map[string]*store.Space{},
|
|
||||||
Photos: map[string]*store.Photo{},
|
|
||||||
FaceEmbeddings: map[string]*store.FaceEmbedding{},
|
|
||||||
RateLimits: map[string]*store.RateLimit{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,118 +93,6 @@ func (f *FakeRepository) Close() error { return nil }
|
|||||||
|
|
||||||
func (f *FakeRepository) Ping(_ context.Context) error { return nil }
|
func (f *FakeRepository) Ping(_ context.Context) error { return nil }
|
||||||
|
|
||||||
// Space operations
|
|
||||||
|
|
||||||
func (f *FakeRepository) CreateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
|
|
||||||
f.Spaces[space.Slug] = space
|
|
||||||
return space, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) GetSpaceBySlug(_ context.Context, slug string) (*store.Space, error) {
|
|
||||||
space, ok := f.Spaces[slug]
|
|
||||||
if !ok {
|
|
||||||
return nil, errNotFound
|
|
||||||
}
|
|
||||||
return space, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) UpdateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
|
|
||||||
f.Spaces[space.Slug] = space
|
|
||||||
return space, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) DeleteSpace(_ context.Context, id string) error {
|
|
||||||
for slug, space := range f.Spaces {
|
|
||||||
if space.ID == id {
|
|
||||||
delete(f.Spaces, slug)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) ListPublicSpaces(_ context.Context) ([]*store.Space, error) {
|
|
||||||
var spaces []*store.Space
|
|
||||||
for _, space := range f.Spaces {
|
|
||||||
if space.IsPublic {
|
|
||||||
spaces = append(spaces, space)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return spaces, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Photo operations
|
|
||||||
|
|
||||||
func (f *FakeRepository) CreatePhoto(_ context.Context, photo *store.Photo) (*store.Photo, error) {
|
|
||||||
f.Photos[photo.ID] = photo
|
|
||||||
return photo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) GetPhoto(_ context.Context, id string) (*store.Photo, error) {
|
|
||||||
photo, ok := f.Photos[id]
|
|
||||||
if !ok {
|
|
||||||
return nil, errNotFound
|
|
||||||
}
|
|
||||||
return photo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) ListPhotosBySpace(_ context.Context, spaceID string, limit int) ([]*store.Photo, error) {
|
|
||||||
var photos []*store.Photo
|
|
||||||
for _, photo := range f.Photos {
|
|
||||||
if photo.SpaceID == spaceID {
|
|
||||||
photos = append(photos, photo)
|
|
||||||
if limit > 0 && len(photos) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return photos, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) DeletePhoto(_ context.Context, id string) error {
|
|
||||||
for photoID := range f.Photos {
|
|
||||||
if photoID == id {
|
|
||||||
delete(f.Photos, photoID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Face embedding operations
|
|
||||||
|
|
||||||
func (f *FakeRepository) CreateFaceEmbedding(_ context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) {
|
|
||||||
f.FaceEmbeddings[embedding.ID] = embedding
|
|
||||||
return embedding, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) ListFaceEmbeddingsBySpace(_ context.Context, spaceID string) ([]*store.FaceEmbedding, error) {
|
|
||||||
var embeddings []*store.FaceEmbedding
|
|
||||||
for _, embedding := range f.FaceEmbeddings {
|
|
||||||
if embedding.SpaceID == spaceID {
|
|
||||||
embeddings = append(embeddings, embedding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return embeddings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) SearchFaceEmbeddings(_ context.Context, spaceID string, embedding []float32, limit int) ([]*store.FaceEmbedding, error) {
|
|
||||||
// For fake repo, just return all embeddings in the space
|
|
||||||
return f.ListFaceEmbeddingsBySpace(nil, spaceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting operations
|
|
||||||
|
|
||||||
func (f *FakeRepository) CheckRateLimit(_ context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
|
|
||||||
// For fake repo, always allow
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeRepository) IncrementRateLimit(_ context.Context, ipAddress, spaceID string, windowMinutes int) error {
|
|
||||||
// For fake repo, do nothing
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type notFoundError struct{}
|
type notFoundError struct{}
|
||||||
|
|
||||||
func (e *notFoundError) Error() string { return "not found" }
|
func (e *notFoundError) Error() string { return "not found" }
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -9,7 +9,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"qrcode.react": "^4.2.0",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
@ -5516,15 +5515,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qrcode.react": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@ -10,7 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"qrcode.react": "^4.2.0",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user