Compare commits

..

No commits in common. "main" and "gitea-deploy-snapshot" have entirely different histories.

37 changed files with 120 additions and 3727 deletions

View File

@ -4,13 +4,3 @@ HATCH_HOSTNAME=hatch.example.com
# Base URL for SSE and self-referencing links within the UI.
HATCH_BASE_URL=https://hatch.example.com
# AWS Rekognition Configuration (optional — set FACE_SERVICE_ENABLED=true to enable)
FACE_SERVICE_ENABLED=false
AWS_REGION=us-east-1
REKOGNITION_COLLECTION=hatch-faces
REKOGNITION_MAX_FACES=10
REKOGNITION_THRESHOLD=80.0
# Redis URL (optional — used for rate-limit backing store)
REDIS_URL=

View File

@ -1,10 +1,10 @@
# Hatch API — Deployment Guide
# Hatch Landing Page — Deployment Guide
## Overview
The Hatch API (`hatch.surf`) is a **Go backend** deployed via **Docker Compose** on the production server.
The Hatch landing page (`hatch.surf`) is a **static HTML** site deployed via **Docker** on the production server.
**Key principle**: All changes MUST go through the git repository and automated deployment. Never edit files directly on the server.
**Key principle**: All changes MUST go through the git repository and automated deployment. Never edit files directly on the server or use `/var/www`.
---
@ -13,16 +13,17 @@ The Hatch API (`hatch.surf`) is a **Go backend** deployed via **Docker Compose**
**Location**: `/home/nara/apps/web/`
**Git Remotes**:
- `github``el-foundation/hatch-surf` (Gitea, source of truth)
- `origin``el-foundation/hatch-surf` (Gitea push-mirror)
- `github``elfoundation/hatch` (source of truth)
- `origin``el-foundation/hatch-surf` (Gitea push-mirror, deploys to hatch.surf)
**Structure**:
```
cmd/hatch/ # Go server entrypoint
internal/ # Go packages (handler, store, face, etc.)
Dockerfile # Multi-stage Docker build (Go → scratch)
docker-compose.yml # Container orchestration (hatch + caddy)
Caddyfile # Caddy reverse proxy config
site/ # Static files served by nginx
index.html # Main landing page
style.css # Styles
main.js # Anime.js animations
Dockerfile # Docker build configuration (static files only)
docker-compose.yml # Container orchestration
deploy.sh # Deployment script
```
@ -30,11 +31,18 @@ deploy.sh # Deployment script
## Deployment Flow
### Source of Truth
**GitHub `main`** is the source of truth. Gitea is a push-mirror.
All development happens via PRs to `elfoundation/hatch`. The deploy script pulls from GitHub and pushes to Gitea.
### Automatic (Recommended)
1. Merge PR to `main`
1. Merge PR to GitHub `main`
2. Gitea webhook triggers `gitea-webhook.py`
3. Script runs `deploy.sh` automatically
4. `deploy.sh` pulls from GitHub, pushes to Gitea (push-mirror), rebuilds Docker, restarts
### Manual
@ -46,66 +54,56 @@ cd /home/nara/apps/web
**What `deploy.sh` does**:
1. `git pull github main` — fetches latest from GitHub (source of truth)
2. `git push origin main` — syncs to Gitea (push-mirror)
3. `docker compose down` — stops current containers
4. `docker compose build hatch` — rebuilds the Go binary in a multi-stage build
5. `docker compose --profile with-caddy up -d` — starts hatch + caddy
6. Verifies health endpoint responds 200
3. `cd site && docker build -t hatch-web:latest .` — rebuilds image
4. Stops and removes old container
5. `docker run -d` — starts new container
6. Verifies deployment with health check
---
## Docker Setup
**Services**:
| Service | Image | Port | Purpose |
|----------|----------------|-------|------------------------|
| `hatch` | web-hatch | 8080 | Go API server |
| `caddy` | caddy:2-alpine | 80/443| Reverse proxy + TLS |
**Profiles**:
- Default: `hatch` only (API on port 8080)
- `with-caddy`: `hatch` + `caddy` (full HTTPS via Let's Encrypt)
**Container**: `hatch-web`
**Port**: `8080` (mapped to host)
**Internal**: nginx serving static files on port 80
**Network**: `hatch-network`
**Check status**:
```bash
docker compose ps
docker compose logs hatch
docker compose logs caddy
docker ps | grep hatch-web
docker logs hatch-web
```
---
## Environment Variables
Configure in `.env` or pass to Docker Compose:
| Variable | Default | Description |
|------------------------|----------------------|--------------------------------------|
| `HATCH_HOSTNAME` | `localhost` | Public hostname for TLS cert |
| `HATCH_BASE_URL` | `http://localhost` | Base URL for SSE and self-references |
| `FACE_SERVICE_ENABLED` | `false` | Enable AWS Rekognition face search |
| `AWS_REGION` | `us-east-1` | AWS region for Rekognition |
| `REKOGNITION_COLLECTION` | `hatch-faces` | Rekognition collection ID |
| `REDIS_URL` | — | Redis URL (optional, for caching) |
**Restart without rebuild**:
```bash
docker compose -f /home/nara/apps/web/docker-compose.yml restart
```
---
## Making Changes
### 1. Edit Go source
### 1. Edit the static files
```bash
cd /home/nara/apps/web
vim cmd/hatch/main.go # Server entrypoint
vim internal/handler/ # HTTP handlers
vim internal/face/ # Face detection service
vim internal/store/ # Database layer
# Edit landing page
vim out/index.html
# Edit styles
vim out/style.css
# Edit animations
vim out/main.js
```
### 2. Build and test locally
### 2. Test locally (optional)
```bash
go build ./cmd/hatch/
go test ./...
# Serve files locally
cd out && python3 -m http.server 3000
# Visit http://localhost:3000
```
### 3. Commit and push
@ -126,21 +124,23 @@ Either wait for webhook or run manually:
### 5. Verify
```bash
curl -s http://localhost:8080/healthz
curl -s http://localhost:8080/readyz
curl -s http://localhost:8080 | head -20
# Or visit https://hatch.surf
```
---
## Common Mistakes to Avoid
**DON'T**: Edit files in `/var/www/html/`
**DON'T**: Edit files directly inside the Docker container
**DON'T**: Commit without testing
**DON'T**: Use `go run` in production
**DON'T**: Work on static HTML/CSS files in a workspace without committing
**DON'T**: Copy files to the server without using git
**DO**: Test with `go test ./...` before committing
**DO**: Use `docker compose build` for production builds
**DO**: Check health endpoints after deployment
**DO**: Edit files in `/home/nara/apps/web/out/`
**DO**: Commit changes to git
**DO**: Use `deploy.sh` for deployment
**DO**: Test locally before pushing
---
@ -151,6 +151,9 @@ If a deployment causes issues:
```bash
cd /home/nara/apps/web
# View recent commits
git log --oneline -10
# Revert to previous version
git revert HEAD
git push origin main
@ -159,21 +162,22 @@ git push origin main
./deploy.sh
```
Previous builds are backed up in `out.backup.*` directories.
---
## API Endpoints
## Cache Headers
| Method | Path | Description |
|--------|-------------------------------|---------------------------------|
| GET | `/healthz` | Liveness probe |
| GET | `/readyz` | Readiness probe (checks DB) |
| GET | `/api/v1/spaces` | List public spaces |
| POST | `/api/v1/spaces` | Create a space |
| GET | `/api/v1/spaces/{slug}` | Get space by slug |
| POST | `/api/v1/spaces/{slug}/photos`| Upload photo to space |
| POST | `/api/v1/face/search` | Search faces by image |
| GET | `/e/{endpointID}` | Inspect captured requests |
| GET | `/e/{endpointID}/events` | SSE stream for live updates |
**HTML pages**: `no-cache, must-revalidate` — browsers always revalidate, ensuring fresh content after deploys.
**Static assets** (CSS/JS/images): `public, immutable` with 1-year expiry.
**Nginx config**: Built into the Dockerfile.
If users report seeing old content after deployment:
1. Verify the container is running: `docker ps | grep hatch-web`
2. Check the HTML has new content: `curl -s http://localhost:8080 | head -5`
3. Ask user to hard refresh: Ctrl+Shift+R (Chrome/Firefox) or Cmd+Shift+R (Mac)
---
@ -181,51 +185,26 @@ git push origin main
**Container not starting**:
```bash
docker compose logs hatch
docker compose logs caddy
docker logs hatch-web
docker compose -f /home/nara/apps/web/docker-compose.yml up -d
```
**Health check failing**:
```bash
curl -v http://localhost:8080/healthz
docker compose exec hatch /bin/hatch # Check binary exists
```
**Database issues**:
```bash
# SQLite database is persisted in a Docker volume
docker volume ls | grep hatch-data
docker run --rm -v hatch-data:/data alpine ls -la /data/
```
**Changes not showing**:
1. Verify commit pushed: `git log --oneline -1`
2. Check container was rebuilt: `docker images | grep hatch-web`
3. Force rebuild: `cd /home/nara/apps/web && docker build -t hatch-web:latest . && docker compose up -d`
**Port conflict**:
```bash
sudo lsof -i :8080
lsof -i :8080
# Kill conflicting process or change port in docker-compose.yml
```
---
## Integration Tests
Integration tests require AWS credentials (build tag: `integration`):
```bash
export AWS_REGION=us-east-1
export REKOGNITION_COLLECTION=hatch-faces-test
go test -tags=integration ./internal/face/... -v
```
Performance benchmarks (build tag: `performance`):
```bash
go test -tags=performance -bench=. ./internal/face/...
```
---
## Contact
- **DevOps**: Jing Yang (Sam Lee)
- **CTO**: Jordan Patel
- **Repository**: `/home/nara/apps/web/`
- **API**: http://localhost:8080 (internal) / https://hatch.surf (public)
- **Live site**: https://hatch.surf

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# ---- build stage ----
FROM golang:1.26-alpine AS build
FROM golang:1.25-alpine AS build
WORKDIR /src

View File

@ -1,10 +1,6 @@
@import "tailwindcss";
/* Explicit source roots for Tailwind v4. Auto-detection misses files when
Turbopack's workspace root is ambiguous (e.g. shell cwd differs from the
web project root), so we pin the scan paths here. */
@source "../components/**/*.{ts,tsx}";
@source "../app/**/*.{ts,tsx}";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--surface-0: #09090b;
@ -21,11 +17,11 @@
--code-border: #27272a;
}
/* Note: no `* { margin: 0; padding: 0; box-sizing: border-box; }` here.
Tailwind v4's Preflight (included by `@import "tailwindcss"`) already
resets margins on block elements and sets border-box globally. An
unlayered `*` selector would beat Tailwind's `@layer utilities` and
silently break every margin utility (mb-*, mt-*, mx-*, etc.). */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
@ -41,19 +37,14 @@ body {
overflow-x: hidden;
}
/* Default link color. Wrapped in @layer base so Tailwind utilities
(text-white, text-zinc-*, etc.) in @layer utilities still win. An
unlayered `a { color: ... }` would silently override utilities. */
@layer base {
a {
a {
color: var(--brand);
text-decoration: none;
transition: color 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
}
a:hover {
a:hover {
color: var(--text-primary);
}
}
code {

View File

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

View File

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

View File

@ -1,32 +1,4 @@
"use client";
import { useEffect, useState } from "react";
// Rotating taglines — Slot 3 above the hero sub (ELF-457, brief ELF-456).
// Order matters: index 0 is the default and the initial server-rendered string.
const TAGLINES = [
"Inspect any request. Mock any response.",
"Your HTTP bin, on your machine.",
"Send. Capture. Mock. No signup.",
] as const;
// Per-tagline dwell. 3.5s gives ~2.8s solid visibility after the 700ms crossfade.
const ROTATE_MS = 3500;
export default function Hero() {
const [index, setIndex] = useState(0);
useEffect(() => {
// Respect prefers-reduced-motion at the JS layer too: don't start a timer
// for users who opt out. The fade is independently suppressed via the
// motion-reduce: variant on the <p> class below.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % TAGLINES.length);
}, ROTATE_MS);
return () => window.clearInterval(id);
}, []);
return (
<section className="pt-32 pb-20 px-6">
<div className="max-w-4xl mx-auto text-center">
@ -39,27 +11,6 @@ export default function Hero() {
inspector&nbsp;+&nbsp;mocker
</h1>
{/* Slot 3: rotating tagline. Fixed-height container prevents CLS; subtle
opacity crossfade so it doesn't compete with the terminal CTA below.
a11y: aria-live polite + aria-hidden on inactive items. */}
<div
className="relative h-7 md:h-8 mb-6 mx-auto max-w-2xl"
aria-live="polite"
aria-atomic="true"
>
{TAGLINES.map((t, i) => (
<p
key={t}
aria-hidden={i !== index}
className={`absolute inset-0 font-mono text-sm md:text-lg font-semibold text-zinc-300 leading-7 md:leading-8 transition-opacity duration-700 ease-out motion-reduce:transition-none ${
i === index ? "opacity-100" : "opacity-0"
}`}
>
{t}
</p>
))}
</div>
<p className="text-lg md:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto leading-relaxed">
One Go binary. SQLite under the hood.
<code className="mx-1 px-2 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-fuchsia-400 text-base">

View File

@ -1,9 +1,9 @@
#!/bin/bash
# Deploy hatch.surf API — triggered by Gitea webhook or manually
# Deploy hatch.surf - triggered by Gitea webhook or manually
set -e
echo "=== Deploying hatch API ==="
echo "=== Deploying hatch.surf ==="
cd /home/nara/apps/web
# Pull latest changes from GitHub (primary source)
@ -12,21 +12,20 @@ git pull github main
# Push to Gitea (push-mirror)
git push origin main
# Stop old containers
docker compose down 2>/dev/null || true
# Rebuild and start
docker compose build hatch
docker compose --profile with-caddy up -d
# Build and deploy from site directory
cd site
docker build -t hatch-web:latest .
docker stop hatch-web 2>/dev/null || true
docker rm hatch-web 2>/dev/null || true
docker run -d --name hatch-web --restart unless-stopped -p 8080:80 hatch-web:latest
cd ..
# Verify
sleep 3
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/healthz | grep -q "200"; then
echo "✅ hatch API deployed successfully!"
echo " Health: http://localhost:8080/healthz"
echo " Ready: http://localhost:8080/readyz"
sleep 5
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 | grep -q "200"; then
echo "✅ hatch.surf deployed successfully!"
else
echo "❌ Deployment failed!"
docker compose logs --tail=20 hatch
docker logs hatch-web
exit 1
fi

View File

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

View File

@ -21,9 +21,6 @@ sudo mv hatch /usr/local/bin/
| `hatch mock set <endpoint>` | Configure mock | `hatch mock set api -status 200 -body '{}'` |
| `hatch doc generate <endpoint>` | Generate OpenAPI | `hatch doc generate api > openapi.json` |
| `hatch version` | Print version | `hatch version` |
| `hatch config show` | Show config | `hatch config show` |
| `hatch config set <k> <v>` | Set config | `hatch config set format table` |
| `hatch completions [shell]` | Shell completions | `hatch completions bash` |
## Common Flags
@ -43,23 +40,8 @@ sudo mv hatch /usr/local/bin/
| Variable | Description | Default |
|----------|-------------|---------|
| `HATCH_URL` | Server URL | `http://localhost:8080` |
| `HATCH_FORMAT` | Output format | `json` |
| `NO_COLOR` | Disable colors | not set |
| `PORT` | Server port | `8080` |
## Configuration File
- **Linux**: `~/.config/hatch/config.json`
- **macOS**: `~/Library/Application Support/hatch/config.json`
```bash
# Initialize config
hatch config init
# View config
hatch config show
```
## Quick Examples
### Capture a Webhook
@ -117,8 +99,8 @@ hatch doc generate api/posts > posts-api.json
| 1 | General error |
| 2 | Invalid arguments |
| 3 | Network error |
| 4 | Server error |
| 5 | Config error |
| 4 | Auth error |
| 5 | Not found |
## Troubleshooting

View File

@ -51,28 +51,14 @@ docker run --rm -it ghcr.io/elfoundation/hatch:latest --help
| Variable | Description | Default |
|----------|-------------|---------|
| `HATCH_URL` | Hatch server URL | `http://localhost:8080` |
| `HATCH_FORMAT` | Output format (`json`, `table`, `compact`) | `json` |
| `NO_COLOR` | Disable colored output | not set |
| `PORT` | Server listening port | `8080` |
### Global Options
All commands support these flags:
- `-h, --help`: Show help for the command
- `-output FORMAT`: Output format (`json`, `table`, `compact`)
- `version`: Print version information
### Configuration File
Hatch stores configuration in a JSON file. The location depends on your OS:
- **Linux/Unix**: `~/.config/hatch/config.json`
- **macOS**: `~/Library/Application Support/hatch/config.json`
- **XDG**: `$XDG_CONFIG_HOME/hatch/config.json`
Priority: Environment variables > Config file > Defaults
## Commands
### `hatch serve`
@ -87,16 +73,6 @@ hatch serve
PORT=9090 hatch serve
```
Start the Hatch server. This is the default command when no subcommand is specified.
```bash
# Start server on default port (8080)
hatch serve
# Start with custom port (use environment variable)
PORT=9090 hatch serve
```
### `hatch capture <url>`
Send a request to an endpoint and store it in Hatch.
@ -422,95 +398,11 @@ chmod +x hatch
sudo mv hatch /usr/local/bin/
```
### `hatch config`
Manage hatch configuration.
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `config show` | Display current configuration |
| `config set <key> <value>` | Set a configuration value |
| `config get <key>` | Get a configuration value |
| `config init` | Create default config file |
**Configurable Keys:**
| Key | Description | Valid Values |
|-----|-------------|--------------|
| `server_url` | Hatch server URL | Any valid URL |
| `format` | Output format | `json`, `table`, `compact` |
| `no_color` | Disable colors | `true`, `false`, `1`, `0` |
| `timeout` | Request timeout (seconds) | Positive integer |
**Examples:**
```bash
# Show current configuration
hatch config show
# Set server URL
hatch config set server_url https://hatch.example.com
# Set output format to table
hatch config set format table
# Disable colored output
hatch config set no_color true
# Set timeout to 60 seconds
hatch config set timeout 60
# Get a specific value
hatch config get server_url
# Initialize config file with defaults
hatch config init
```
### `hatch completions`
Generate shell completions for hatch.
**Syntax:**
```bash
hatch completions [shell]
```
**Supported Shells:**
| Shell | Command |
|-------|----------|
| Bash | `hatch completions bash` |
| Zsh | `hatch completions zsh` |
| Fish | `hatch completions fish` |
| PowerShell | `hatch completions powershell` |
**Installation:**
```bash
# Bash - add to ~/.bashrc
hatch completions bash >> ~/.bashrc
# Zsh - add to ~/.zshrc
hatch completions zsh >> ~/.zshrc
# Fish - save to completions directory
hatch completions fish > ~/.config/fish/completions/hatch.fish
# PowerShell - add to profile
hatch completions powershell >> $PROFILE
```
## Environment Variables Reference
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `HATCH_URL` | Hatch server URL | `http://localhost:8080` | `https://hatch.example.com` |
| `HATCH_FORMAT` | Output format | `json` | `table`, `compact` |
| `NO_COLOR` | Disable colored output | not set | `1` |
| `PORT` | Server listening port | `8080` | `9090` |
## Exit Codes

30
go.mod
View File

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

53
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,383 +0,0 @@
package handler
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5"
)
// RegisterSpaceRoutes mounts the space management routes on the given router.
func (h *Handler) RegisterSpaceRoutes(r chi.Router) {
r.Route("/api/spaces", func(r chi.Router) {
// Public endpoints
r.Post("/", h.createSpace)
r.Get("/", h.listPublicSpaces)
r.Get("/{slug}", h.getSpaceBySlug)
r.Patch("/{slug}", h.updateSpace)
r.Delete("/{slug}", h.deleteSpace)
// Photo management
r.Post("/{slug}/photos", h.createPhoto)
r.Get("/{slug}/photos", h.listPhotos)
// Face search endpoints
r.Post("/{slug}/face-search/guest", h.guestFaceSearch)
r.Post("/{slug}/face-search", h.authenticatedFaceSearch)
})
}
// createSpace handles POST /api/spaces
func (h *Handler) createSpace(w http.ResponseWriter, r *http.Request) {
var space store.Space
if err := json.NewDecoder(r.Body).Decode(&space); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
// Generate slug if not provided
if space.Slug == "" {
space.Slug = generateSlug(space.Name)
}
// Validate required fields
if space.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
ctx := r.Context()
createdSpace, err := h.Repo.CreateSpace(ctx, &space)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create space: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(createdSpace)
}
// listPublicSpaces handles GET /api/spaces
func (h *Handler) listPublicSpaces(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
spaces, err := h.Repo.ListPublicSpaces(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list spaces")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(spaces)
}
// getSpaceBySlug handles GET /api/spaces/{slug}
func (h *Handler) getSpaceBySlug(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
writeError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
if err != nil {
writeError(w, http.StatusNotFound, "space not found")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// updateSpace handles PATCH /api/spaces/{slug}
func (h *Handler) updateSpace(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
writeError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
existingSpace, err := h.Repo.GetSpaceBySlug(ctx, slug)
if err != nil {
writeError(w, http.StatusNotFound, "space not found")
return
}
// Update fields from request body
var updates store.Space
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
// Apply updates
if updates.Name != "" {
existingSpace.Name = updates.Name
}
if updates.Description != "" {
existingSpace.Description = updates.Description
}
// Note: is_public is a boolean, so we need to check if it was explicitly set
// For simplicity, we'll always update it if provided in the request
existingSpace.IsPublic = updates.IsPublic
updatedSpace, err := h.Repo.UpdateSpace(ctx, existingSpace)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update space: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updatedSpace)
}
// deleteSpace handles DELETE /api/spaces/{slug}
func (h *Handler) deleteSpace(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
writeError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
if err != nil {
writeError(w, http.StatusNotFound, "space not found")
return
}
if err := h.Repo.DeleteSpace(ctx, space.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete space: "+err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// createPhoto handles POST /api/spaces/{slug}/photos
func (h *Handler) createPhoto(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
writeError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
if err != nil {
writeError(w, http.StatusNotFound, "space not found")
return
}
var photo store.Photo
if err := json.NewDecoder(r.Body).Decode(&photo); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
// Validate required fields
if photo.URL == "" {
writeError(w, http.StatusBadRequest, "url is required")
return
}
photo.SpaceID = space.ID
createdPhoto, err := h.Repo.CreatePhoto(ctx, &photo)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create photo: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(createdPhoto)
}
// listPhotos handles GET /api/spaces/{slug}/photos
func (h *Handler) listPhotos(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
writeError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
if err != nil {
writeError(w, http.StatusNotFound, "space not found")
return
}
// Parse limit parameter
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
photos, err := h.Repo.ListPhotosBySpace(ctx, space.ID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list photos")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(photos)
}
// guestFaceSearch handles POST /api/spaces/{slug}/face-search/guest
func (h *Handler) guestFaceSearch(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
writeError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
if err != nil {
writeError(w, http.StatusNotFound, "space not found")
return
}
// Check rate limit (20 requests per 15 minutes per IP)
ipAddress := getClientIP(r)
allowed, err := h.Repo.CheckRateLimit(ctx, ipAddress, space.ID, 20, 15)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check rate limit")
return
}
if !allowed {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
// Increment rate limit
if err := h.Repo.IncrementRateLimit(ctx, ipAddress, space.ID, 15); err != nil {
writeError(w, http.StatusInternalServerError, "failed to increment rate limit")
return
}
// Parse request body (should contain image data or URL)
var request struct {
ImageURL string `json:"imageUrl,omitempty"`
ImageData string `json:"imageData,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
// Check if face service is available
if h.FaceService == nil {
writeError(w, http.StatusInternalServerError, "face search service not configured")
return
}
// Convert base64 image data to bytes
var imageBytes []byte
if request.ImageData != "" {
// Decode base64 image data
imageBytes, err = base64.StdEncoding.DecodeString(request.ImageData)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid image data")
return
}
} else if request.ImageURL != "" {
// Download image from URL
resp, err := http.Get(request.ImageURL)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to download image")
return
}
defer resp.Body.Close()
imageBytes, err = io.ReadAll(resp.Body)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read image")
return
}
} else {
writeError(w, http.StatusBadRequest, "image data or URL required")
return
}
// Validate image
if err := h.FaceService.ValidateImage(imageBytes); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Search faces
result, err := h.FaceService.SearchFaces(ctx, space.ID, imageBytes, 10)
if err != nil {
writeError(w, http.StatusInternalServerError, "face search failed: "+err.Error())
return
}
// Format response
response := map[string]interface{}{
"status": "success",
"matches": result.Matches,
"photoCount": result.PhotoCount,
"searchTime": result.SearchTime.Milliseconds(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// authenticatedFaceSearch handles POST /api/spaces/{slug}/face-search
func (h *Handler) authenticatedFaceSearch(w http.ResponseWriter, r *http.Request) {
// For now, delegate to guest face search
h.guestFaceSearch(w, r)
}
// Helper functions
func generateSlug(name string) string {
// Convert to lowercase, replace spaces with hyphens, remove non-alphanumeric characters
slug := strings.ToLower(name)
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
// Remove non-alphanumeric characters except hyphens
var result strings.Builder
for _, c := range slug {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
result.WriteRune(c)
}
}
return result.String()
}
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header first
xForwardedFor := r.Header.Get("X-Forwarded-For")
if xForwardedFor != "" {
// X-Forwarded-For may contain multiple IPs, take the first one
parts := strings.Split(xForwardedFor, ",")
return strings.TrimSpace(parts[0])
}
// Check X-Real-IP header
xRealIP := r.Header.Get("X-Real-IP")
if xRealIP != "" {
return xRealIP
}
// Fall back to RemoteAddr
parts := strings.Split(r.RemoteAddr, ":")
if len(parts) > 1 {
return strings.Join(parts[:len(parts)-1], ":")
}
return r.RemoteAddr
}

View File

@ -1,296 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/elfoundation/hatch/internal/store"
"github.com/elfoundation/hatch/internal/testutil"
"github.com/go-chi/chi/v5"
)
// testSpaceRouter creates a chi router with all routes registered using a fake repo.
func testSpaceRouter(repo store.Repository) chi.Router {
r := chi.NewRouter()
h := New(repo)
h.RegisterRoutes(r)
return r
}
func TestCreateSpace(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
body := `{"name": "Test Space", "description": "A test space", "isPublic": true}`
req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var space store.Space
if err := json.NewDecoder(w.Body).Decode(&space); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if space.Name != "Test Space" {
t.Errorf("expected name 'Test Space', got %q", space.Name)
}
if space.Description != "A test space" {
t.Errorf("expected description 'A test space', got %q", space.Description)
}
if !space.IsPublic {
t.Error("expected isPublic to be true")
}
if space.Slug == "" {
t.Error("expected slug to be generated")
}
}
func TestCreateSpaceMissingName(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
body := `{"description": "Missing name"}`
req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestListPublicSpaces(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
// Create some spaces
space1 := &store.Space{ID: "1", Slug: "space-1", Name: "Space 1", IsPublic: true}
space2 := &store.Space{ID: "2", Slug: "space-2", Name: "Space 2", IsPublic: false}
space3 := &store.Space{ID: "3", Slug: "space-3", Name: "Space 3", IsPublic: true}
repo.CreateSpace(nil, space1)
repo.CreateSpace(nil, space2)
repo.CreateSpace(nil, space3)
req := httptest.NewRequest(http.MethodGet, "/api/spaces", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var spaces []*store.Space
if err := json.NewDecoder(w.Body).Decode(&spaces); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(spaces) != 2 {
t.Fatalf("expected 2 spaces, got %d", len(spaces))
}
// Check that only public spaces are returned
for _, space := range spaces {
if !space.IsPublic {
t.Errorf("expected only public spaces, got %q", space.Name)
}
}
}
func TestGetSpaceBySlug(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
repo.CreateSpace(nil, space)
req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var retrievedSpace store.Space
if err := json.NewDecoder(w.Body).Decode(&retrievedSpace); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if retrievedSpace.ID != space.ID {
t.Errorf("expected ID %q, got %q", space.ID, retrievedSpace.ID)
}
if retrievedSpace.Name != space.Name {
t.Errorf("expected name %q, got %q", space.Name, retrievedSpace.Name)
}
}
func TestGetSpaceBySlugNotFound(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/api/spaces/nonexistent", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestUpdateSpace(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
space := &store.Space{ID: "1", Slug: "test-space", Name: "Original Name", IsPublic: true}
repo.CreateSpace(nil, space)
body := `{"name": "Updated Name", "isPublic": false}`
req := httptest.NewRequest(http.MethodPatch, "/api/spaces/test-space", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var updatedSpace store.Space
if err := json.NewDecoder(w.Body).Decode(&updatedSpace); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if updatedSpace.Name != "Updated Name" {
t.Errorf("expected name 'Updated Name', got %q", updatedSpace.Name)
}
if updatedSpace.IsPublic != false {
t.Error("expected isPublic to be false")
}
}
func TestDeleteSpace(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
repo.CreateSpace(nil, space)
req := httptest.NewRequest(http.MethodDelete, "/api/spaces/test-space", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
}
// Verify space was deleted
_, err := repo.GetSpaceBySlug(nil, "test-space")
if err == nil {
t.Error("expected space to be deleted")
}
}
func TestCreatePhoto(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
repo.CreateSpace(nil, space)
body := `{"url": "https://example.com/photo.jpg", "thumbnailUrl": "https://example.com/thumb.jpg", "faceCount": 2}`
req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/photos", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var photo store.Photo
if err := json.NewDecoder(w.Body).Decode(&photo); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if photo.URL != "https://example.com/photo.jpg" {
t.Errorf("expected URL 'https://example.com/photo.jpg', got %q", photo.URL)
}
if photo.ThumbnailURL != "https://example.com/thumb.jpg" {
t.Errorf("expected thumbnail URL 'https://example.com/thumb.jpg', got %q", photo.ThumbnailURL)
}
if photo.FaceCount != 2 {
t.Errorf("expected face count 2, got %d", photo.FaceCount)
}
}
func TestListPhotos(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
repo.CreateSpace(nil, space)
// Create some photos
photo1 := &store.Photo{ID: "1", SpaceID: "1", URL: "https://example.com/photo1.jpg"}
photo2 := &store.Photo{ID: "2", SpaceID: "1", URL: "https://example.com/photo2.jpg"}
photo3 := &store.Photo{ID: "3", SpaceID: "2", URL: "https://example.com/photo3.jpg"}
repo.CreatePhoto(nil, photo1)
repo.CreatePhoto(nil, photo2)
repo.CreatePhoto(nil, photo3)
req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space/photos", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var photos []*store.Photo
if err := json.NewDecoder(w.Body).Decode(&photos); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(photos) != 2 {
t.Fatalf("expected 2 photos, got %d", len(photos))
}
}
func TestGuestFaceSearch(t *testing.T) {
repo := testutil.NewFakeRepository()
r := testSpaceRouter(repo)
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
repo.CreateSpace(nil, space)
// Face search without face service configured should return 500
body := `{"imageUrl": "https://example.com/test.jpg"}`
req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/face-search/guest", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.1.1:12345"
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 when face service not configured, got %d: %s", w.Code, w.Body.String())
}
var response map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["error"] != "face search service not configured" {
t.Errorf("expected error about face search service not configured, got %q", response["error"])
}
}

View File

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

View File

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

View File

@ -1,51 +0,0 @@
-- Space tables for public demo spaces with face search
CREATE TABLE IF NOT EXISTS spaces (
id TEXT NOT NULL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_spaces_slug ON spaces(slug);
CREATE INDEX IF NOT EXISTS idx_spaces_is_public ON spaces(is_public);
CREATE TABLE IF NOT EXISTS photos (
id TEXT NOT NULL PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
url TEXT NOT NULL,
thumbnail_url TEXT,
face_count INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_photos_space_id ON photos(space_id);
CREATE INDEX IF NOT EXISTS idx_photos_created_at ON photos(created_at);
CREATE TABLE IF NOT EXISTS face_embeddings (
id TEXT NOT NULL PRIMARY KEY,
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
embedding BLOB NOT NULL, -- JSON array of float32
bounding_box TEXT NOT NULL, -- JSON bounding box
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_face_embeddings_space_id ON face_embeddings(space_id);
CREATE INDEX IF NOT EXISTS idx_face_embeddings_photo_id ON face_embeddings(photo_id);
CREATE TABLE IF NOT EXISTS rate_limits (
id TEXT NOT NULL PRIMARY KEY,
ip_address TEXT NOT NULL,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
request_count INTEGER NOT NULL DEFAULT 1,
window_start TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_space ON rate_limits(ip_address, space_id);
CREATE INDEX IF NOT EXISTS idx_rate_limits_window_start ON rate_limits(window_start);

View File

@ -1,248 +0,0 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
// Space operations
func (r *sqliteRepo) CreateSpace(ctx context.Context, space *Space) (*Space, error) {
now := utcNow()
space.ID = uuid.NewString()
space.CreatedAt = now
space.UpdatedAt = now
_, err := r.db.ExecContext(ctx,
`INSERT INTO spaces (id, slug, name, description, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
space.ID, space.Slug, space.Name, space.Description, space.IsPublic, space.CreatedAt, space.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("store: create space: %w", err)
}
return space, nil
}
func (r *sqliteRepo) GetSpaceBySlug(ctx context.Context, slug string) (*Space, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE slug = ?`, slug)
space := &Space{}
if err := row.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
return nil, fmt.Errorf("store: get space by slug: %w", err)
}
return space, nil
}
func (r *sqliteRepo) UpdateSpace(ctx context.Context, space *Space) (*Space, error) {
space.UpdatedAt = utcNow()
_, err := r.db.ExecContext(ctx,
`UPDATE spaces SET name = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?`,
space.Name, space.Description, space.IsPublic, space.UpdatedAt, space.ID)
if err != nil {
return nil, fmt.Errorf("store: update space: %w", err)
}
return space, nil
}
func (r *sqliteRepo) DeleteSpace(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM spaces WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete space: %w", err)
}
return nil
}
func (r *sqliteRepo) ListPublicSpaces(ctx context.Context) ([]*Space, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE is_public = TRUE ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list public spaces: %w", err)
}
defer rows.Close()
var spaces []*Space
for rows.Next() {
space := &Space{}
if err := rows.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
return nil, fmt.Errorf("store: scan space: %w", err)
}
spaces = append(spaces, space)
}
return spaces, rows.Err()
}
// Photo operations
func (r *sqliteRepo) CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error) {
photo.ID = uuid.NewString()
photo.CreatedAt = utcNow()
_, err := r.db.ExecContext(ctx,
`INSERT INTO photos (id, space_id, url, thumbnail_url, face_count, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
photo.ID, photo.SpaceID, photo.URL, photo.ThumbnailURL, photo.FaceCount, photo.Metadata, photo.CreatedAt)
if err != nil {
return nil, fmt.Errorf("store: create photo: %w", err)
}
return photo, nil
}
func (r *sqliteRepo) GetPhoto(ctx context.Context, id string) (*Photo, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE id = ?`, id)
photo := &Photo{}
if err := row.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
return nil, fmt.Errorf("store: get photo: %w", err)
}
return photo, nil
}
func (r *sqliteRepo) ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error) {
if limit <= 0 {
limit = 100
}
rows, err := r.db.QueryContext(ctx,
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE space_id = ? ORDER BY created_at DESC LIMIT ?`,
spaceID, limit)
if err != nil {
return nil, fmt.Errorf("store: list photos by space: %w", err)
}
defer rows.Close()
var photos []*Photo
for rows.Next() {
photo := &Photo{}
if err := rows.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan photo: %w", err)
}
photos = append(photos, photo)
}
return photos, rows.Err()
}
func (r *sqliteRepo) DeletePhoto(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM photos WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete photo: %w", err)
}
return nil
}
// Face embedding operations
func (r *sqliteRepo) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error) {
embedding.ID = uuid.NewString()
embedding.CreatedAt = utcNow()
// Convert embedding to JSON
embeddingJSON, err := json.Marshal(embedding.Embedding)
if err != nil {
return nil, fmt.Errorf("store: marshal face embedding: %w", err)
}
_, err = r.db.ExecContext(ctx,
`INSERT INTO face_embeddings (id, photo_id, space_id, embedding, bounding_box, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
embedding.ID, embedding.PhotoID, embedding.SpaceID, embeddingJSON, embedding.BoundingBox, embedding.CreatedAt)
if err != nil {
return nil, fmt.Errorf("store: create face embedding: %w", err)
}
return embedding, nil
}
func (r *sqliteRepo) ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, photo_id, space_id, embedding, bounding_box, created_at FROM face_embeddings WHERE space_id = ?`,
spaceID)
if err != nil {
return nil, fmt.Errorf("store: list face embeddings by space: %w", err)
}
defer rows.Close()
var embeddings []*FaceEmbedding
for rows.Next() {
embedding := &FaceEmbedding{}
var embeddingJSON []byte
if err := rows.Scan(&embedding.ID, &embedding.PhotoID, &embedding.SpaceID, &embeddingJSON, &embedding.BoundingBox, &embedding.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan face embedding: %w", err)
}
if err := json.Unmarshal(embeddingJSON, &embedding.Embedding); err != nil {
return nil, fmt.Errorf("store: unmarshal face embedding: %w", err)
}
embeddings = append(embeddings, embedding)
}
return embeddings, rows.Err()
}
func (r *sqliteRepo) SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error) {
if limit <= 0 {
limit = 10
}
// For now, return all embeddings in the space (cosine similarity search would be implemented here)
// In a real implementation, you'd compute cosine similarity between the query embedding and all embeddings
embeddings, err := r.ListFaceEmbeddingsBySpace(ctx, spaceID)
if err != nil {
return nil, err
}
// Return up to limit results
if len(embeddings) > limit {
embeddings = embeddings[:limit]
}
return embeddings, nil
}
// Rate limiting operations
func (r *sqliteRepo) CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
// Calculate window start time
windowStart := time.Now().UTC().Add(-time.Duration(windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
var count int
err := r.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(request_count), 0) FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start >= ?`,
ipAddress, spaceID, windowStart).Scan(&count)
if err != nil {
return false, fmt.Errorf("store: check rate limit: %w", err)
}
return count < limit, nil
}
func (r *sqliteRepo) IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error {
// Calculate window start time (rounded to the nearest window)
now := time.Now().UTC()
windowStart := now.Add(-time.Duration(now.Minute()%windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
// Check if there's an existing record for this IP, space, and window
var id string
err := r.db.QueryRowContext(ctx,
`SELECT id FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start = ?`,
ipAddress, spaceID, windowStart).Scan(&id)
if err == sql.ErrNoRows {
// Create new record
id = uuid.NewString()
_, err = r.db.ExecContext(ctx,
`INSERT INTO rate_limits (id, ip_address, space_id, request_count, window_start, created_at) VALUES (?, ?, ?, 1, ?, ?)`,
id, ipAddress, spaceID, windowStart, utcNow())
if err != nil {
return fmt.Errorf("store: create rate limit: %w", err)
}
} else if err == nil {
// Update existing record
_, err = r.db.ExecContext(ctx,
`UPDATE rate_limits SET request_count = request_count + 1 WHERE id = ?`,
id)
if err != nil {
return fmt.Errorf("store: increment rate limit: %w", err)
}
} else {
return fmt.Errorf("store: get rate limit: %w", err)
}
return nil
}

View File

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

View File

@ -1,17 +1,10 @@
import type { NextConfig } from "next";
import path from "node:path";
const nextConfig: NextConfig = {
output: "export",
images: {
unoptimized: true,
},
// Pin the Turbopack workspace root to this directory. Without this, Turbopack
// infers it from the shell's cwd, which during Paperclip agent runs is the
// paperclip project root — that misroutes CSS/source scanning and silently
// strips Tailwind utility classes from the production bundle.
turbopack: {
root: path.resolve(import.meta.dirname),
},
};
export default nextConfig;

10
package-lock.json generated
View File

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

View File

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

View File

@ -67,13 +67,6 @@
<h1 data-animate="fade-up" data-delay="80">
Self-hostable HTTP request<br>inspector&nbsp;+&nbsp;mocker
</h1>
<!-- Slot 3 rotating tagline (ELF-457, brief ELF-456). Default #1 is in the
initial HTML for no-JS / SEO. JS in main.js cycles opacity every 3.5s. -->
<div class="hero-tagline" aria-live="polite" aria-atomic="true">
<p class="hero-tagline-item is-active">Inspect any request. Mock any response.</p>
<p class="hero-tagline-item" aria-hidden="true">Your HTTP bin, on your machine.</p>
<p class="hero-tagline-item" aria-hidden="true">Send. Capture. Mock. No signup.</p>
</div>
<p class="hero-sub" data-animate="fade-up" data-delay="160">
One Go binary. SQLite under the hood.<br>
<code>docker compose up</code>, and you have an inspection endpoint and a live feed in under 30 seconds.

View File

@ -420,32 +420,6 @@
initEntranceAnimations(animate, stagger);
initTerminalGlow(animate);
initRotatingTagline();
}
// ============================================================
// Slot 3 rotating tagline (ELF-457)
// Cycles 3 tagline <p>s every 3.5s with a 700ms opacity crossfade
// (handled in CSS via .hero-tagline-item transition). Honors
// prefers-reduced-motion: skips the timer entirely so the default
// #1 stays visible without any animation.
// ============================================================
function initRotatingTagline() {
var container = document.querySelector('.hero-tagline');
if (!container) return;
var items = container.querySelectorAll('.hero-tagline-item');
if (items.length < 2) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var idx = 0;
window.setInterval(function () {
items[idx].classList.remove('is-active');
items[idx].setAttribute('aria-hidden', 'true');
idx = (idx + 1) % items.length;
items[idx].classList.add('is-active');
items[idx].removeAttribute('aria-hidden');
}, 3500);
}
// Defer boot by one frame so anime.js engine is ready

View File

@ -363,43 +363,6 @@ nav {
color: var(--text-primary);
}
/* Slot 3: rotating tagline (ELF-457). Sits between H1 and hero-sub.
Fixed height prevents CLS during rotation; absolute children crossfade. */
.hero-tagline {
position: relative;
max-width: 560px;
height: 1.7em;
margin: 0 auto var(--sp-6);
overflow: hidden;
font-family: var(--font-mono);
font-size: clamp(0.85rem, 2.2vw, 1rem);
font-weight: 500;
line-height: 1.7;
color: var(--text-secondary);
text-align: center;
}
.hero-tagline-item {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
transition: opacity 700ms var(--ease-out);
display: flex;
align-items: center;
justify-content: center;
}
.hero-tagline-item.is-active {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.hero-tagline-item {
transition: none;
}
}
.hero-sub {
font-size: clamp(1rem, 2vw, 1.2rem);
color: var(--text-secondary);