Compare commits
11 Commits
gitea-depl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
478e2818b9 | ||
|
|
bcb4f1688e | ||
|
|
475fd95a59 | ||
|
|
ad0e78c9c9 | ||
|
|
5bce1bd39b | ||
|
|
a63d2dbf62 | ||
|
|
4a24f33f17 | ||
|
|
51aaf3c9d2 | ||
|
|
6568af4dba | ||
|
|
4bf37faf4f | ||
|
|
6e472f3eea |
10
.env.example
10
.env.example
@ -4,3 +4,13 @@ HATCH_HOSTNAME=hatch.example.com
|
|||||||
|
|
||||||
# Base URL for SSE and self-referencing links within the UI.
|
# Base URL for SSE and self-referencing links within the UI.
|
||||||
HATCH_BASE_URL=https://hatch.example.com
|
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=
|
||||||
|
|||||||
183
DEPLOYMENT.md
183
DEPLOYMENT.md
@ -1,10 +1,10 @@
|
|||||||
# Hatch Landing Page — Deployment Guide
|
# Hatch API — Deployment Guide
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Hatch landing page (`hatch.surf`) is a **static HTML** site deployed via **Docker** on the production server.
|
The Hatch API (`hatch.surf`) is a **Go backend** deployed via **Docker Compose** on the production 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`.
|
**Key principle**: All changes MUST go through the git repository and automated deployment. Never edit files directly on the server.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -13,17 +13,16 @@ The Hatch landing page (`hatch.surf`) is a **static HTML** site deployed via **D
|
|||||||
**Location**: `/home/nara/apps/web/`
|
**Location**: `/home/nara/apps/web/`
|
||||||
|
|
||||||
**Git Remotes**:
|
**Git Remotes**:
|
||||||
- `github` → `elfoundation/hatch` (source of truth)
|
- `github` → `el-foundation/hatch-surf` (Gitea, source of truth)
|
||||||
- `origin` → `el-foundation/hatch-surf` (Gitea push-mirror, deploys to hatch.surf)
|
- `origin` → `el-foundation/hatch-surf` (Gitea push-mirror)
|
||||||
|
|
||||||
**Structure**:
|
**Structure**:
|
||||||
```
|
```
|
||||||
site/ # Static files served by nginx
|
cmd/hatch/ # Go server entrypoint
|
||||||
index.html # Main landing page
|
internal/ # Go packages (handler, store, face, etc.)
|
||||||
style.css # Styles
|
Dockerfile # Multi-stage Docker build (Go → scratch)
|
||||||
main.js # Anime.js animations
|
docker-compose.yml # Container orchestration (hatch + caddy)
|
||||||
Dockerfile # Docker build configuration (static files only)
|
Caddyfile # Caddy reverse proxy config
|
||||||
docker-compose.yml # Container orchestration
|
|
||||||
deploy.sh # Deployment script
|
deploy.sh # Deployment script
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -31,18 +30,11 @@ deploy.sh # Deployment script
|
|||||||
|
|
||||||
## Deployment Flow
|
## 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)
|
### Automatic (Recommended)
|
||||||
|
|
||||||
1. Merge PR to GitHub `main`
|
1. Merge PR to `main`
|
||||||
2. Gitea webhook triggers `gitea-webhook.py`
|
2. Gitea webhook triggers `gitea-webhook.py`
|
||||||
3. Script runs `deploy.sh` automatically
|
3. Script runs `deploy.sh` automatically
|
||||||
4. `deploy.sh` pulls from GitHub, pushes to Gitea (push-mirror), rebuilds Docker, restarts
|
|
||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
@ -54,56 +46,66 @@ cd /home/nara/apps/web
|
|||||||
**What `deploy.sh` does**:
|
**What `deploy.sh` does**:
|
||||||
1. `git pull github main` — fetches latest from GitHub (source of truth)
|
1. `git pull github main` — fetches latest from GitHub (source of truth)
|
||||||
2. `git push origin main` — syncs to Gitea (push-mirror)
|
2. `git push origin main` — syncs to Gitea (push-mirror)
|
||||||
3. `cd site && docker build -t hatch-web:latest .` — rebuilds image
|
3. `docker compose down` — stops current containers
|
||||||
4. Stops and removes old container
|
4. `docker compose build hatch` — rebuilds the Go binary in a multi-stage build
|
||||||
5. `docker run -d` — starts new container
|
5. `docker compose --profile with-caddy up -d` — starts hatch + caddy
|
||||||
6. Verifies deployment with health check
|
6. Verifies health endpoint responds 200
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker Setup
|
## Docker Setup
|
||||||
|
|
||||||
**Container**: `hatch-web`
|
**Services**:
|
||||||
**Port**: `8080` (mapped to host)
|
| Service | Image | Port | Purpose |
|
||||||
**Internal**: nginx serving static files on port 80
|
|----------|----------------|-------|------------------------|
|
||||||
**Network**: `hatch-network`
|
| `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)
|
||||||
|
|
||||||
**Check status**:
|
**Check status**:
|
||||||
```bash
|
```bash
|
||||||
docker ps | grep hatch-web
|
docker compose ps
|
||||||
docker logs hatch-web
|
docker compose logs hatch
|
||||||
|
docker compose logs caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
**Restart without rebuild**:
|
---
|
||||||
```bash
|
|
||||||
docker compose -f /home/nara/apps/web/docker-compose.yml restart
|
## 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Making Changes
|
## Making Changes
|
||||||
|
|
||||||
### 1. Edit the static files
|
### 1. Edit Go source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/nara/apps/web
|
cd /home/nara/apps/web
|
||||||
|
vim cmd/hatch/main.go # Server entrypoint
|
||||||
# Edit landing page
|
vim internal/handler/ # HTTP handlers
|
||||||
vim out/index.html
|
vim internal/face/ # Face detection service
|
||||||
|
vim internal/store/ # Database layer
|
||||||
# Edit styles
|
|
||||||
vim out/style.css
|
|
||||||
|
|
||||||
# Edit animations
|
|
||||||
vim out/main.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Test locally (optional)
|
### 2. Build and test locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Serve files locally
|
go build ./cmd/hatch/
|
||||||
cd out && python3 -m http.server 3000
|
go test ./...
|
||||||
# Visit http://localhost:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Commit and push
|
### 3. Commit and push
|
||||||
@ -124,23 +126,21 @@ Either wait for webhook or run manually:
|
|||||||
### 5. Verify
|
### 5. Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8080 | head -20
|
curl -s http://localhost:8080/healthz
|
||||||
# Or visit https://hatch.surf
|
curl -s http://localhost:8080/readyz
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Common Mistakes to Avoid
|
## Common Mistakes to Avoid
|
||||||
|
|
||||||
❌ **DON'T**: Edit files in `/var/www/html/`
|
|
||||||
❌ **DON'T**: Edit files directly inside the Docker container
|
❌ **DON'T**: Edit files directly inside the Docker container
|
||||||
❌ **DON'T**: Work on static HTML/CSS files in a workspace without committing
|
❌ **DON'T**: Commit without testing
|
||||||
❌ **DON'T**: Copy files to the server without using git
|
❌ **DON'T**: Use `go run` in production
|
||||||
|
|
||||||
✅ **DO**: Edit files in `/home/nara/apps/web/out/`
|
✅ **DO**: Test with `go test ./...` before committing
|
||||||
✅ **DO**: Commit changes to git
|
✅ **DO**: Use `docker compose build` for production builds
|
||||||
✅ **DO**: Use `deploy.sh` for deployment
|
✅ **DO**: Check health endpoints after deployment
|
||||||
✅ **DO**: Test locally before pushing
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -151,9 +151,6 @@ If a deployment causes issues:
|
|||||||
```bash
|
```bash
|
||||||
cd /home/nara/apps/web
|
cd /home/nara/apps/web
|
||||||
|
|
||||||
# View recent commits
|
|
||||||
git log --oneline -10
|
|
||||||
|
|
||||||
# Revert to previous version
|
# Revert to previous version
|
||||||
git revert HEAD
|
git revert HEAD
|
||||||
git push origin main
|
git push origin main
|
||||||
@ -162,22 +159,21 @@ git push origin main
|
|||||||
./deploy.sh
|
./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Previous builds are backed up in `out.backup.*` directories.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cache Headers
|
## API Endpoints
|
||||||
|
|
||||||
**HTML pages**: `no-cache, must-revalidate` — browsers always revalidate, ensuring fresh content after deploys.
|
| Method | Path | Description |
|
||||||
|
|--------|-------------------------------|---------------------------------|
|
||||||
**Static assets** (CSS/JS/images): `public, immutable` with 1-year expiry.
|
| GET | `/healthz` | Liveness probe |
|
||||||
|
| GET | `/readyz` | Readiness probe (checks DB) |
|
||||||
**Nginx config**: Built into the Dockerfile.
|
| GET | `/api/v1/spaces` | List public spaces |
|
||||||
|
| POST | `/api/v1/spaces` | Create a space |
|
||||||
If users report seeing old content after deployment:
|
| GET | `/api/v1/spaces/{slug}` | Get space by slug |
|
||||||
1. Verify the container is running: `docker ps | grep hatch-web`
|
| POST | `/api/v1/spaces/{slug}/photos`| Upload photo to space |
|
||||||
2. Check the HTML has new content: `curl -s http://localhost:8080 | head -5`
|
| POST | `/api/v1/face/search` | Search faces by image |
|
||||||
3. Ask user to hard refresh: Ctrl+Shift+R (Chrome/Firefox) or Cmd+Shift+R (Mac)
|
| GET | `/e/{endpointID}` | Inspect captured requests |
|
||||||
|
| GET | `/e/{endpointID}/events` | SSE stream for live updates |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -185,26 +181,51 @@ If users report seeing old content after deployment:
|
|||||||
|
|
||||||
**Container not starting**:
|
**Container not starting**:
|
||||||
```bash
|
```bash
|
||||||
docker logs hatch-web
|
docker compose logs hatch
|
||||||
docker compose -f /home/nara/apps/web/docker-compose.yml up -d
|
docker compose logs caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
**Changes not showing**:
|
**Health check failing**:
|
||||||
1. Verify commit pushed: `git log --oneline -1`
|
```bash
|
||||||
2. Check container was rebuilt: `docker images | grep hatch-web`
|
curl -v http://localhost:8080/healthz
|
||||||
3. Force rebuild: `cd /home/nara/apps/web && docker build -t hatch-web:latest . && docker compose up -d`
|
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/
|
||||||
|
```
|
||||||
|
|
||||||
**Port conflict**:
|
**Port conflict**:
|
||||||
```bash
|
```bash
|
||||||
lsof -i :8080
|
sudo lsof -i :8080
|
||||||
# Kill conflicting process or change port in docker-compose.yml
|
# 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
|
## Contact
|
||||||
|
|
||||||
- **DevOps**: Jing Yang (Sam Lee)
|
|
||||||
- **CTO**: Jordan Patel
|
- **CTO**: Jordan Patel
|
||||||
- **Repository**: `/home/nara/apps/web/`
|
- **Repository**: `/home/nara/apps/web/`
|
||||||
- **Live site**: https://hatch.surf
|
- **API**: http://localhost:8080 (internal) / https://hatch.surf (public)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ---- build stage ----
|
# ---- build stage ----
|
||||||
FROM golang:1.25-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* 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}";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--surface-0: #09090b;
|
--surface-0: #09090b;
|
||||||
@ -17,11 +21,11 @@
|
|||||||
--code-border: #27272a;
|
--code-border: #27272a;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
/* Note: no `* { margin: 0; padding: 0; box-sizing: border-box; }` here.
|
||||||
margin: 0;
|
Tailwind v4's Preflight (included by `@import "tailwindcss"`) already
|
||||||
padding: 0;
|
resets margins on block elements and sets border-box globally. An
|
||||||
box-sizing: border-box;
|
unlayered `*` selector would beat Tailwind's `@layer utilities` and
|
||||||
}
|
silently break every margin utility (mb-*, mt-*, mx-*, etc.). */
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@ -37,6 +41,10 @@ body {
|
|||||||
overflow-x: hidden;
|
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);
|
color: var(--brand);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@ -46,6 +54,7 @@ a {
|
|||||||
a:hover {
|
a:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: var(--font-mono), 'SFMono-Regular', Consolas, monospace;
|
font-family: var(--font-mono), 'SFMono-Regular', Consolas, monospace;
|
||||||
|
|||||||
65
app/join/[code]/page.tsx
Normal file
65
app/join/[code]/page.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function JoinPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const code = params.code as string;
|
||||||
|
const joinUrl = `https://hatch.surf/join/${code}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center px-6 py-12">
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
{/* Bilingual welcome phrase */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
|
||||||
|
Арга хэмжээнд тавтай морилно уу
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-zinc-400 font-medium">
|
||||||
|
Welcome to the event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code section */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<div className="inline-block p-6 bg-white rounded-2xl shadow-2xl">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={joinUrl}
|
||||||
|
size={200}
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#09090b"
|
||||||
|
level="H"
|
||||||
|
includeMargin={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event info */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-block px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700/50 text-zinc-300 text-sm font-mono">
|
||||||
|
Code: {code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="space-y-4 text-zinc-400 text-sm">
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Scan the QR code above to join this event space.
|
||||||
|
</p>
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
You'll be able to see live HTTP requests and interact with the event.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-16 pt-8 border-t border-zinc-800/50">
|
||||||
|
<p className="text-xs text-zinc-600">
|
||||||
|
Powered by Hatch · Self-hostable HTTP request inspector
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/elfoundation/hatch/internal/face"
|
||||||
"github.com/elfoundation/hatch/internal/handler"
|
"github.com/elfoundation/hatch/internal/handler"
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@ -33,9 +35,39 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer repo.Close()
|
defer repo.Close()
|
||||||
|
|
||||||
h := handler.New(repo)
|
// Initialize face service if enabled
|
||||||
|
var faceService *face.Service
|
||||||
|
if os.Getenv("FACE_SERVICE_ENABLED") == "true" {
|
||||||
|
cfg, err := config.LoadDefaultConfig(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: failed to load AWS config: %v", err)
|
||||||
|
} else {
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces"
|
||||||
|
}
|
||||||
|
rekognitionClient := face.NewRekognitionClient(cfg, collectionID)
|
||||||
|
faceConfig := face.DefaultConfig()
|
||||||
|
faceCache := face.NewMemoryCache()
|
||||||
|
faceService = face.NewService(rekognitionClient, repo, faceCache, faceConfig)
|
||||||
|
log.Println("face search service initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handler with or without face service
|
||||||
|
var h *handler.Handler
|
||||||
|
if faceService != nil {
|
||||||
|
h = handler.NewWithFaceService(repo, faceService)
|
||||||
|
} else {
|
||||||
|
h = handler.New(repo)
|
||||||
|
}
|
||||||
h.Debug = os.Getenv("HATCH_DEBUG") != ""
|
h.Debug = os.Getenv("HATCH_DEBUG") != ""
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Security and performance middleware
|
||||||
|
r.Use(handler.SecurityHeaders)
|
||||||
|
r.Use(handler.NewRateLimiter(100, time.Minute).Limit)
|
||||||
|
|
||||||
h.RegisterRoutes(r)
|
h.RegisterRoutes(r)
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%s", port)
|
addr := fmt.Sprintf(":%s", port)
|
||||||
|
|||||||
@ -1,4 +1,32 @@
|
|||||||
|
"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() {
|
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 (
|
return (
|
||||||
<section className="pt-32 pb-20 px-6">
|
<section className="pt-32 pb-20 px-6">
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
@ -11,6 +39,27 @@ export default function Hero() {
|
|||||||
inspector + mocker
|
inspector + mocker
|
||||||
</h1>
|
</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">
|
<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.
|
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">
|
<code className="mx-1 px-2 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-fuchsia-400 text-base">
|
||||||
|
|||||||
27
deploy.sh
27
deploy.sh
@ -1,9 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Deploy hatch.surf - triggered by Gitea webhook or manually
|
# Deploy hatch.surf API — triggered by Gitea webhook or manually
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "=== Deploying hatch.surf ==="
|
echo "=== Deploying hatch API ==="
|
||||||
cd /home/nara/apps/web
|
cd /home/nara/apps/web
|
||||||
|
|
||||||
# Pull latest changes from GitHub (primary source)
|
# Pull latest changes from GitHub (primary source)
|
||||||
@ -12,20 +12,21 @@ git pull github main
|
|||||||
# Push to Gitea (push-mirror)
|
# Push to Gitea (push-mirror)
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
# Build and deploy from site directory
|
# Stop old containers
|
||||||
cd site
|
docker compose down 2>/dev/null || true
|
||||||
docker build -t hatch-web:latest .
|
|
||||||
docker stop hatch-web 2>/dev/null || true
|
# Rebuild and start
|
||||||
docker rm hatch-web 2>/dev/null || true
|
docker compose build hatch
|
||||||
docker run -d --name hatch-web --restart unless-stopped -p 8080:80 hatch-web:latest
|
docker compose --profile with-caddy up -d
|
||||||
cd ..
|
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
sleep 5
|
sleep 3
|
||||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 | grep -q "200"; then
|
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/healthz | grep -q "200"; then
|
||||||
echo "✅ hatch.surf deployed successfully!"
|
echo "✅ hatch API deployed successfully!"
|
||||||
|
echo " Health: http://localhost:8080/healthz"
|
||||||
|
echo " Ready: http://localhost:8080/readyz"
|
||||||
else
|
else
|
||||||
echo "❌ Deployment failed!"
|
echo "❌ Deployment failed!"
|
||||||
docker logs hatch-web
|
docker compose logs --tail=20 hatch
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -10,6 +10,7 @@ services:
|
|||||||
- HATCH_PORT=8080
|
- HATCH_PORT=8080
|
||||||
- HATCH_DB_PATH=/data/hatch.db
|
- HATCH_DB_PATH=/data/hatch.db
|
||||||
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
|
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
|
||||||
|
- REDIS_URL=${REDIS_URL:-}
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
|
|||||||
@ -21,6 +21,9 @@ sudo mv hatch /usr/local/bin/
|
|||||||
| `hatch mock set <endpoint>` | Configure mock | `hatch mock set api -status 200 -body '{}'` |
|
| `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 doc generate <endpoint>` | Generate OpenAPI | `hatch doc generate api > openapi.json` |
|
||||||
| `hatch version` | Print version | `hatch version` |
|
| `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
|
## Common Flags
|
||||||
|
|
||||||
@ -40,8 +43,23 @@ sudo mv hatch /usr/local/bin/
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `HATCH_URL` | Server URL | `http://localhost:8080` |
|
| `HATCH_URL` | Server URL | `http://localhost:8080` |
|
||||||
|
| `HATCH_FORMAT` | Output format | `json` |
|
||||||
|
| `NO_COLOR` | Disable colors | not set |
|
||||||
| `PORT` | Server port | `8080` |
|
| `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
|
## Quick Examples
|
||||||
|
|
||||||
### Capture a Webhook
|
### Capture a Webhook
|
||||||
@ -99,8 +117,8 @@ hatch doc generate api/posts > posts-api.json
|
|||||||
| 1 | General error |
|
| 1 | General error |
|
||||||
| 2 | Invalid arguments |
|
| 2 | Invalid arguments |
|
||||||
| 3 | Network error |
|
| 3 | Network error |
|
||||||
| 4 | Auth error |
|
| 4 | Server error |
|
||||||
| 5 | Not found |
|
| 5 | Config error |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@ -51,14 +51,28 @@ docker run --rm -it ghcr.io/elfoundation/hatch:latest --help
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `HATCH_URL` | Hatch server URL | `http://localhost:8080` |
|
| `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
|
### Global Options
|
||||||
|
|
||||||
All commands support these flags:
|
All commands support these flags:
|
||||||
|
|
||||||
- `-h, --help`: Show help for the command
|
- `-h, --help`: Show help for the command
|
||||||
|
- `-output FORMAT`: Output format (`json`, `table`, `compact`)
|
||||||
- `version`: Print version information
|
- `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
|
## Commands
|
||||||
|
|
||||||
### `hatch serve`
|
### `hatch serve`
|
||||||
@ -73,6 +87,16 @@ hatch serve
|
|||||||
PORT=9090 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>`
|
### `hatch capture <url>`
|
||||||
|
|
||||||
Send a request to an endpoint and store it in Hatch.
|
Send a request to an endpoint and store it in Hatch.
|
||||||
@ -398,11 +422,95 @@ chmod +x hatch
|
|||||||
sudo mv hatch /usr/local/bin/
|
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
|
## Environment Variables Reference
|
||||||
|
|
||||||
| Variable | Description | Default | Example |
|
| Variable | Description | Default | Example |
|
||||||
|----------|-------------|---------|---------|
|
|----------|-------------|---------|---------|
|
||||||
| `HATCH_URL` | Hatch server URL | `http://localhost:8080` | `https://hatch.example.com` |
|
| `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` |
|
| `PORT` | Server listening port | `8080` | `9090` |
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|||||||
30
go.mod
30
go.mod
@ -1,20 +1,48 @@
|
|||||||
module github.com/elfoundation/hatch
|
module github.com/elfoundation/hatch
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/rekognition v1.52.3
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/redis/go-redis/v9 v9.21.0
|
||||||
modernc.org/sqlite v1.53.0
|
modernc.org/sqlite v1.53.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.27.1 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
modernc.org/libc v1.73.4 // indirect
|
modernc.org/libc v1.73.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/elfoundation/hatch => .
|
||||||
|
|||||||
53
go.sum
53
go.sum
@ -1,3 +1,37 @@
|
|||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/rekognition v1.52.3 h1:SV1cXOW9qnXotyFJW8kw4qjZV2KaybIkVz99kKjOWck=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/rekognition v1.52.3/go.mod h1:9i14Fo0M0qlOsf1wPvG7DDHRwQH1kamlNkmKoBEJ13w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||||
|
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
|
||||||
|
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
@ -10,10 +44,26 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
|
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||||
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
|
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||||
|
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
@ -23,6 +73,9 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
|
|||||||
153
internal/face/cache.go
Normal file
153
internal/face/cache.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheService defines the interface for caching face search results
|
||||||
|
type CacheService interface {
|
||||||
|
// Get retrieves a cached value by key
|
||||||
|
Get(ctx context.Context, key string) (string, error)
|
||||||
|
|
||||||
|
// Set stores a value with expiration
|
||||||
|
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||||
|
|
||||||
|
// Delete removes cached values
|
||||||
|
Delete(ctx context.Context, keys ...string) error
|
||||||
|
|
||||||
|
// GetJSON retrieves and unmarshals a cached JSON value
|
||||||
|
GetJSON(ctx context.Context, key string, dest interface{}) error
|
||||||
|
|
||||||
|
// SetJSON marshals and stores a JSON value
|
||||||
|
SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryCache is an in-memory cache implementation for testing and development
|
||||||
|
type MemoryCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]*cacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
value string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryCache creates a new in-memory cache
|
||||||
|
func NewMemoryCache() *MemoryCache {
|
||||||
|
return &MemoryCache{
|
||||||
|
entries: make(map[string]*cacheEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value from the cache
|
||||||
|
func (c *MemoryCache) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, ok := c.entries[key]
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a value in the cache
|
||||||
|
func (c *MemoryCache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
var strValue string
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
strValue = v
|
||||||
|
default:
|
||||||
|
bytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
strValue = string(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.entries[key] = &cacheEntry{
|
||||||
|
value: strValue,
|
||||||
|
expiresAt: time.Now().Add(expiration),
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes values from the cache
|
||||||
|
func (c *MemoryCache) Delete(ctx context.Context, keys ...string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
delete(c.entries, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJSON retrieves and unmarshals a cached JSON value
|
||||||
|
func (c *MemoryCache) GetJSON(ctx context.Context, key string, dest interface{}) error {
|
||||||
|
value, err := c.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal([]byte(value), dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJSON marshals and stores a JSON value
|
||||||
|
func (c *MemoryCache) SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
return c.Set(ctx, key, value, expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes expired entries (call periodically)
|
||||||
|
func (c *MemoryCache) Cleanup() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for key, entry := range c.entries {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(c.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheKeys returns common cache key patterns
|
||||||
|
type CacheKeys struct{}
|
||||||
|
|
||||||
|
// Search returns the cache key for search results
|
||||||
|
func (CacheKeys) Search(spaceID string, imageHash string) string {
|
||||||
|
return "search:" + spaceID + ":" + imageHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo returns the cache key for photo metadata
|
||||||
|
func (CacheKeys) Photo(photoID string) string {
|
||||||
|
return "photo:" + photoID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face returns the cache key for face embeddings
|
||||||
|
func (CacheKeys) Face(faceID string) string {
|
||||||
|
return "face:" + faceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexed returns the cache key for indexed photos
|
||||||
|
func (CacheKeys) Indexed(photoID string) string {
|
||||||
|
return "indexed:" + photoID
|
||||||
|
}
|
||||||
191
internal/face/client.go
Normal file
191
internal/face/client.go
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client defines the interface for face detection, indexing, and search operations
|
||||||
|
type Client interface {
|
||||||
|
// CreateCollection creates a new face collection for a space
|
||||||
|
CreateCollection(ctx context.Context, collectionName string) error
|
||||||
|
|
||||||
|
// DeleteCollection deletes a face collection
|
||||||
|
DeleteCollection(ctx context.Context, collectionName string) error
|
||||||
|
|
||||||
|
// DetectFaces detects faces in an image
|
||||||
|
DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
|
||||||
|
|
||||||
|
// IndexFace indexes a face in a collection
|
||||||
|
IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
|
||||||
|
|
||||||
|
// SearchFacesByImage searches for similar faces in the collection
|
||||||
|
SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time check that RekognitionClient implements Client
|
||||||
|
var _ Client = (*RekognitionClient)(nil)
|
||||||
|
|
||||||
|
// MockClient is a mock implementation of the Client interface for testing
|
||||||
|
type MockClient struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
collections map[string]*CollectionInfo
|
||||||
|
faces map[string][]FaceIndex
|
||||||
|
detections []FaceDetection
|
||||||
|
searchResults []FaceMatch
|
||||||
|
|
||||||
|
// Mock configuration
|
||||||
|
DetectFacesFunc func(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
|
||||||
|
IndexFaceFunc func(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
|
||||||
|
SearchFacesFunc func(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FaceIndex represents an indexed face (internal to mock)
|
||||||
|
type FaceIndex struct {
|
||||||
|
FaceID string `json:"faceId"`
|
||||||
|
ExternalImageID string `json:"externalImageId"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
BoundingBox BoundingBox `json:"boundingBox"`
|
||||||
|
IndexedAt time.Time `json:"indexedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockClient creates a new mock client for testing
|
||||||
|
func NewMockClient() *MockClient {
|
||||||
|
return &MockClient{
|
||||||
|
collections: make(map[string]*CollectionInfo),
|
||||||
|
faces: make(map[string][]FaceIndex),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollection creates a mock collection
|
||||||
|
func (m *MockClient) CreateCollection(ctx context.Context, collectionName string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.collections[collectionName] = &CollectionInfo{
|
||||||
|
CollectionID: collectionName,
|
||||||
|
FaceCount: 0,
|
||||||
|
CreationDate: time.Now(),
|
||||||
|
LastUpdated: time.Now(),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCollection deletes a mock collection
|
||||||
|
func (m *MockClient) DeleteCollection(ctx context.Context, collectionName string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
delete(m.collections, collectionName)
|
||||||
|
delete(m.faces, collectionName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectFaces returns mock face detections
|
||||||
|
func (m *MockClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
|
||||||
|
if m.DetectFacesFunc != nil {
|
||||||
|
return m.DetectFacesFunc(ctx, imageBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Return mock detections
|
||||||
|
if len(m.detections) > 0 {
|
||||||
|
return m.detections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: return one face
|
||||||
|
return []FaceDetection{
|
||||||
|
{
|
||||||
|
Confidence: 99.5,
|
||||||
|
BoundingBox: BoundingBox{
|
||||||
|
Left: 0.1,
|
||||||
|
Top: 0.1,
|
||||||
|
Width: 0.3,
|
||||||
|
Height: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexFace indexes a mock face
|
||||||
|
func (m *MockClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
|
||||||
|
if m.IndexFaceFunc != nil {
|
||||||
|
return m.IndexFaceFunc(ctx, imageBytes, photoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
detection := &FaceDetection{
|
||||||
|
Confidence: 99.5,
|
||||||
|
BoundingBox: BoundingBox{
|
||||||
|
Left: 0.1,
|
||||||
|
Top: 0.1,
|
||||||
|
Width: 0.3,
|
||||||
|
Height: 0.3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in mock database
|
||||||
|
faceIndex := FaceIndex{
|
||||||
|
FaceID: fmt.Sprintf("face-%s-%d", photoID, time.Now().UnixNano()),
|
||||||
|
ExternalImageID: photoID,
|
||||||
|
Confidence: 99.5,
|
||||||
|
BoundingBox: detection.BoundingBox,
|
||||||
|
IndexedAt: time.Now(),
|
||||||
|
}
|
||||||
|
m.faces["hatch-default"] = append(m.faces["hatch-default"], faceIndex)
|
||||||
|
|
||||||
|
return detection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFacesByImage returns mock search results
|
||||||
|
func (m *MockClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
|
||||||
|
if m.SearchFacesFunc != nil {
|
||||||
|
return m.SearchFacesFunc(ctx, imageBytes, maxFaces, threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Return mock matches
|
||||||
|
if len(m.searchResults) > 0 {
|
||||||
|
if maxFaces > 0 && len(m.searchResults) > maxFaces {
|
||||||
|
return m.searchResults[:maxFaces], nil
|
||||||
|
}
|
||||||
|
return m.searchResults, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: return one match
|
||||||
|
return []FaceMatch{
|
||||||
|
{
|
||||||
|
FaceID: "face-mock-1",
|
||||||
|
ExternalImageID: "photo-1",
|
||||||
|
Similarity: 95.0,
|
||||||
|
BoundingBox: BoundingBox{
|
||||||
|
Left: 0.1,
|
||||||
|
Top: 0.1,
|
||||||
|
Width: 0.3,
|
||||||
|
Height: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDetections configures mock face detections
|
||||||
|
func (m *MockClient) SetDetections(detections []FaceDetection) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.detections = detections
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSearchResults configures mock search results
|
||||||
|
func (m *MockClient) SetSearchResults(results []FaceMatch) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.searchResults = results
|
||||||
|
}
|
||||||
102
internal/face/config.go
Normal file
102
internal/face/config.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds configuration for the face service
|
||||||
|
type Config struct {
|
||||||
|
// AWS Configuration
|
||||||
|
Region string
|
||||||
|
CollectionID string
|
||||||
|
|
||||||
|
// Face Detection Configuration
|
||||||
|
MaxFaces int
|
||||||
|
Threshold float64
|
||||||
|
QualityFilter string
|
||||||
|
|
||||||
|
// Cache Configuration
|
||||||
|
CacheTTL time.Duration
|
||||||
|
SearchCacheTTL time.Duration
|
||||||
|
PhotoCacheTTL time.Duration
|
||||||
|
|
||||||
|
// Rate Limiting
|
||||||
|
GuestRateLimit int
|
||||||
|
GuestRateWindowMin int
|
||||||
|
AuthRateLimit int
|
||||||
|
AuthRateWindowMin int
|
||||||
|
|
||||||
|
// Image Optimization
|
||||||
|
MaxImageSize int
|
||||||
|
ResizeWidth int
|
||||||
|
ResizeHeight int
|
||||||
|
ImageQuality int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns default configuration
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Region: getEnv("AWS_REGION", "us-east-1"),
|
||||||
|
CollectionID: getEnv("REKOGNITION_COLLECTION", "hatch-faces"),
|
||||||
|
MaxFaces: getEnvInt("REKOGNITION_MAX_FACES", 10),
|
||||||
|
Threshold: getEnvFloat("REKOGNITION_THRESHOLD", 80.0),
|
||||||
|
QualityFilter: getEnv("REKOGNITION_QUALITY_FILTER", "AUTO"),
|
||||||
|
CacheTTL: 5 * time.Minute,
|
||||||
|
SearchCacheTTL: 5 * time.Minute,
|
||||||
|
PhotoCacheTTL: 24 * time.Hour,
|
||||||
|
GuestRateLimit: getEnvInt("RATE_LIMIT_GUEST_SEARCHES", 20),
|
||||||
|
GuestRateWindowMin: getEnvInt("RATE_LIMIT_GUEST_WINDOW_MINUTES", 15),
|
||||||
|
AuthRateLimit: getEnvInt("RATE_LIMIT_AUTH_SEARCHES", 100),
|
||||||
|
AuthRateWindowMin: getEnvInt("RATE_LIMIT_AUTH_WINDOW_MINUTES", 1),
|
||||||
|
MaxImageSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
ResizeWidth: 1024,
|
||||||
|
ResizeHeight: 1024,
|
||||||
|
ImageQuality: 85,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if the configuration is valid
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Region == "" {
|
||||||
|
return ErrMissingRegion
|
||||||
|
}
|
||||||
|
if c.CollectionID == "" {
|
||||||
|
return ErrMissingCollectionID
|
||||||
|
}
|
||||||
|
if c.MaxFaces <= 0 {
|
||||||
|
return ErrInvalidMaxFaces
|
||||||
|
}
|
||||||
|
if c.Threshold < 0 || c.Threshold > 100 {
|
||||||
|
return ErrInvalidThreshold
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for environment variables
|
||||||
|
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, defaultValue int) int {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
if intValue, err := strconv.Atoi(value); err == nil {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvFloat(key string, defaultValue float64) float64 {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
return floatValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
94
internal/face/errors.go
Normal file
94
internal/face/errors.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Sentinel errors for face operations
|
||||||
|
var (
|
||||||
|
// Client errors
|
||||||
|
ErrNoFacesDetected = errors.New("no faces detected in image")
|
||||||
|
ErrImageTooSmall = errors.New("image too small for face detection")
|
||||||
|
ErrImageTooLarge = errors.New("image exceeds maximum size")
|
||||||
|
ErrInvalidImageFormat = errors.New("invalid image format")
|
||||||
|
ErrImageProcessingFailed = errors.New("failed to process image")
|
||||||
|
|
||||||
|
// Collection errors
|
||||||
|
ErrCollectionNotFound = errors.New("face collection not found")
|
||||||
|
ErrCollectionExists = errors.New("face collection already exists")
|
||||||
|
ErrCollectionCreationFailed = errors.New("failed to create face collection")
|
||||||
|
|
||||||
|
// Indexing errors
|
||||||
|
ErrIndexingFailed = errors.New("failed to index face")
|
||||||
|
ErrFaceAlreadyIndexed = errors.New("face already indexed")
|
||||||
|
|
||||||
|
// Search errors
|
||||||
|
ErrSearchFailed = errors.New("face search failed")
|
||||||
|
ErrNoMatchesFound = errors.New("no matching faces found")
|
||||||
|
|
||||||
|
// Configuration errors
|
||||||
|
ErrMissingRegion = errors.New("AWS region is required")
|
||||||
|
ErrMissingCollectionID = errors.New("collection ID is required")
|
||||||
|
ErrInvalidMaxFaces = errors.New("max faces must be positive")
|
||||||
|
ErrInvalidThreshold = errors.New("threshold must be between 0 and 100")
|
||||||
|
|
||||||
|
// Rate limiting errors
|
||||||
|
ErrRateLimitExceeded = errors.New("rate limit exceeded")
|
||||||
|
|
||||||
|
// AWS service errors
|
||||||
|
ErrAWSServiceError = errors.New("AWS Rekognition service error")
|
||||||
|
ErrAWSAccessDenied = errors.New("AWS access denied")
|
||||||
|
ErrAWSThrottling = errors.New("AWS request throttled")
|
||||||
|
ErrAWSInvalidImage = errors.New("invalid image for AWS Rekognition")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FaceSearchError represents a structured error for face search operations
|
||||||
|
type FaceSearchError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface
|
||||||
|
func (e *FaceSearchError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFaceSearchError creates a new FaceSearchError
|
||||||
|
func NewFaceSearchError(code, message, details string) *FaceSearchError {
|
||||||
|
return &FaceSearchError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common error codes
|
||||||
|
const (
|
||||||
|
ErrorCodeNoFaces = "NO_FACES_DETECTED"
|
||||||
|
ErrorCodeInvalidImage = "INVALID_IMAGE"
|
||||||
|
ErrorCodeRateLimited = "RATE_LIMITED"
|
||||||
|
ErrorCodeCollectionError = "COLLECTION_ERROR"
|
||||||
|
ErrorCodeSearchFailed = "SEARCH_FAILED"
|
||||||
|
ErrorCodeIndexingFailed = "INDEXING_FAILED"
|
||||||
|
ErrorCodeInternalError = "INTERNAL_ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User-facing error messages
|
||||||
|
var UserErrorMessages = map[error]string{
|
||||||
|
ErrNoFacesDetected: "No faces detected in the image. Please upload a photo with clear faces.",
|
||||||
|
ErrImageTooSmall: "Image is too small. Please upload a larger image (minimum 100x100 pixels).",
|
||||||
|
ErrImageTooLarge: "Image is too large. Please upload an image smaller than 10MB.",
|
||||||
|
ErrInvalidImageFormat: "Invalid image format. Please upload a JPEG, PNG, or WebP image.",
|
||||||
|
ErrCollectionNotFound: "Face collection not found. Please contact support.",
|
||||||
|
ErrRateLimitExceeded: "Search limit exceeded. Please try again later.",
|
||||||
|
ErrAWSServiceError: "An error occurred while processing your request. Please try again.",
|
||||||
|
ErrAWSAccessDenied: "Access denied. Please contact support.",
|
||||||
|
ErrAWSThrottling: "Service is busy. Please try again in a few moments.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserMessage returns a user-friendly error message
|
||||||
|
func GetUserMessage(err error) string {
|
||||||
|
if msg, ok := UserErrorMessages[err]; ok {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return "An unexpected error occurred. Please try again."
|
||||||
|
}
|
||||||
281
internal/face/integration_test.go
Normal file
281
internal/face/integration_test.go
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegration_DetectFaces tests face detection with real AWS Rekognition
|
||||||
|
func TestIntegration_DetectFaces(t *testing.T) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
t.Skip("AWS_REGION not set, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Load test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect faces
|
||||||
|
detections, err := client.DetectFaces(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to detect faces: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(detections) == 0 {
|
||||||
|
t.Error("expected at least one face detection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify confidence > 99%
|
||||||
|
for _, detection := range detections {
|
||||||
|
if detection.Confidence < 99.0 {
|
||||||
|
t.Errorf("expected confidence > 99%%, got %f", detection.Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration_IndexFace tests face indexing with real AWS Rekognition
|
||||||
|
func TestIntegration_IndexFace(t *testing.T) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
t.Skip("AWS_REGION not set, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
if err := client.CreateCollection(ctx, collectionID); err != nil {
|
||||||
|
t.Fatalf("failed to create collection: %v", err)
|
||||||
|
}
|
||||||
|
defer client.DeleteCollection(ctx, collectionID)
|
||||||
|
|
||||||
|
// Load test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index face
|
||||||
|
photoID := "test-photo-" + time.Now().Format("20060102150405")
|
||||||
|
detection, err := client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to index face: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if detection == nil {
|
||||||
|
t.Error("expected face detection, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if detection.Confidence < 99.0 {
|
||||||
|
t.Errorf("expected confidence > 99%%, got %f", detection.Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration_SearchFaces tests face search with real AWS Rekognition
|
||||||
|
func TestIntegration_SearchFaces(t *testing.T) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
t.Skip("AWS_REGION not set, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
if err := client.CreateCollection(ctx, collectionID); err != nil {
|
||||||
|
t.Fatalf("failed to create collection: %v", err)
|
||||||
|
}
|
||||||
|
defer client.DeleteCollection(ctx, collectionID)
|
||||||
|
|
||||||
|
// Load and index test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
photoID := "test-photo-" + time.Now().Format("20060102150405")
|
||||||
|
_, err = client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to index face: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the indexed face
|
||||||
|
matches, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to search faces: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
t.Error("expected at least one match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify similarity > 80%
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.Similarity < 80.0 {
|
||||||
|
t.Errorf("expected similarity > 80%%, got %f", match.Similarity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration_EndToEnd tests complete face detection → index → search flow
|
||||||
|
func TestIntegration_EndToEnd(t *testing.T) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
t.Skip("AWS_REGION not set, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
if err := client.CreateCollection(ctx, collectionID); err != nil {
|
||||||
|
t.Fatalf("failed to create collection: %v", err)
|
||||||
|
}
|
||||||
|
defer client.DeleteCollection(ctx, collectionID)
|
||||||
|
|
||||||
|
// Load test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Detect faces
|
||||||
|
detections, err := client.DetectFaces(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to detect faces: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(detections) == 0 {
|
||||||
|
t.Fatal("no faces detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Index face
|
||||||
|
photoID := "e2e-test-" + time.Now().Format("20060102150405")
|
||||||
|
_, err = client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to index face: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Search for the indexed face
|
||||||
|
matches, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to search faces: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
t.Error("expected at least one match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify performance < 5 seconds
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
if elapsed > 5*time.Second {
|
||||||
|
t.Errorf("end-to-end latency exceeded 5 seconds: %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("End-to-end latency: %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration_ErrorHandling tests error handling with invalid inputs
|
||||||
|
func TestIntegration_ErrorHandling(t *testing.T) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
t.Skip("AWS_REGION not set, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with empty image data
|
||||||
|
_, err = client.DetectFaces(ctx, []byte{})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty image data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with invalid image data
|
||||||
|
_, err = client.DetectFaces(ctx, []byte("invalid image data"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid image data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func loadAWSConfig() (aws.Config, error) {
|
||||||
|
return config.LoadDefaultConfig(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTestImage(path string) ([]byte, error) {
|
||||||
|
// Try multiple locations
|
||||||
|
locations := []string{
|
||||||
|
path,
|
||||||
|
"test/fixtures/images/face.jpg",
|
||||||
|
"testdata/face.jpg",
|
||||||
|
"../test/fixtures/images/face.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, loc := range locations {
|
||||||
|
data, err := os.ReadFile(loc)
|
||||||
|
if err == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("test image not found in any of the expected locations")
|
||||||
|
}
|
||||||
35
internal/face/models.go
Normal file
35
internal/face/models.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// FaceSearchResult represents the result of a face search
|
||||||
|
type FaceSearchResult struct {
|
||||||
|
Matches []FaceMatch `json:"matches"`
|
||||||
|
PhotoCount int `json:"photoCount"`
|
||||||
|
SearchTime time.Duration `json:"searchTime"`
|
||||||
|
CacheHit bool `json:"cacheHit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionInfo represents information about a face collection
|
||||||
|
type CollectionInfo struct {
|
||||||
|
CollectionID string `json:"collectionId"`
|
||||||
|
FaceCount int `json:"faceCount"`
|
||||||
|
CreationDate time.Time `json:"creationDate"`
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRequest represents a face search request
|
||||||
|
type SearchRequest struct {
|
||||||
|
ImageBytes []byte `json:"-"`
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
MaxFaces int `json:"maxFaces,omitempty"`
|
||||||
|
Threshold float64 `json:"threshold,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexRequest represents a face indexing request
|
||||||
|
type IndexRequest struct {
|
||||||
|
PhotoID string `json:"photoId"`
|
||||||
|
SpaceID string `json:"spaceId"`
|
||||||
|
ImageBytes []byte `json:"-"`
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
}
|
||||||
225
internal/face/performance_test.go
Normal file
225
internal/face/performance_test.go
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
//go:build performance
|
||||||
|
|
||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkFaceDetection benchmarks face detection performance
|
||||||
|
func BenchmarkFaceDetection(b *testing.B) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
b.Skip("AWS_REGION not set, skipping performance test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-perf"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Load test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, err := client.DetectFaces(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to detect faces: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkFaceIndexing benchmarks face indexing performance
|
||||||
|
func BenchmarkFaceIndexing(b *testing.B) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
b.Skip("AWS_REGION not set, skipping performance test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-perf"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
if err := client.CreateCollection(ctx, collectionID); err != nil {
|
||||||
|
b.Fatalf("failed to create collection: %v", err)
|
||||||
|
}
|
||||||
|
defer client.DeleteCollection(ctx, collectionID)
|
||||||
|
|
||||||
|
// Load test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
photoID := fmt.Sprintf("perf-test-%d", time.Now().UnixNano())
|
||||||
|
_, err := client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to index face: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkFaceSearch benchmarks face search performance
|
||||||
|
func BenchmarkFaceSearch(b *testing.B) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
b.Skip("AWS_REGION not set, skipping performance test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-perf"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
if err := client.CreateCollection(ctx, collectionID); err != nil {
|
||||||
|
b.Fatalf("failed to create collection: %v", err)
|
||||||
|
}
|
||||||
|
defer client.DeleteCollection(ctx, collectionID)
|
||||||
|
|
||||||
|
// Load and index test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
photoID := "perf-search-" + time.Now().Format("20060102150405")
|
||||||
|
_, err = client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to index face: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to search faces: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPerformanceTargets validates performance targets
|
||||||
|
func TestPerformanceTargets(t *testing.T) {
|
||||||
|
if os.Getenv("AWS_REGION") == "" {
|
||||||
|
t.Skip("AWS_REGION not set, skipping performance test")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadAWSConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load AWS config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID := os.Getenv("REKOGNITION_COLLECTION")
|
||||||
|
if collectionID == "" {
|
||||||
|
collectionID = "hatch-faces-perf"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewRekognitionClient(cfg, collectionID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
if err := client.CreateCollection(ctx, collectionID); err != nil {
|
||||||
|
t.Fatalf("failed to create collection: %v", err)
|
||||||
|
}
|
||||||
|
defer client.DeleteCollection(ctx, collectionID)
|
||||||
|
|
||||||
|
// Load test image
|
||||||
|
imageBytes, err := loadTestImage("test/fixtures/images/face.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test face detection latency
|
||||||
|
start := time.Now()
|
||||||
|
_, err = client.DetectFaces(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to detect faces: %v", err)
|
||||||
|
}
|
||||||
|
detectionLatency := time.Since(start)
|
||||||
|
if detectionLatency > 2*time.Second {
|
||||||
|
t.Errorf("face detection latency exceeded 2 seconds: %v", detectionLatency)
|
||||||
|
}
|
||||||
|
t.Logf("Face detection latency: %v", detectionLatency)
|
||||||
|
|
||||||
|
// Test face indexing latency
|
||||||
|
start = time.Now()
|
||||||
|
photoID := "perf-target-" + time.Now().Format("20060102150405")
|
||||||
|
_, err = client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to index face: %v", err)
|
||||||
|
}
|
||||||
|
indexingLatency := time.Since(start)
|
||||||
|
if indexingLatency > 3*time.Second {
|
||||||
|
t.Errorf("face indexing latency exceeded 3 seconds: %v", indexingLatency)
|
||||||
|
}
|
||||||
|
t.Logf("Face indexing latency: %v", indexingLatency)
|
||||||
|
|
||||||
|
// Test face search latency
|
||||||
|
start = time.Now()
|
||||||
|
_, err = client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to search faces: %v", err)
|
||||||
|
}
|
||||||
|
searchLatency := time.Since(start)
|
||||||
|
if searchLatency > 2*time.Second {
|
||||||
|
t.Errorf("face search latency exceeded 2 seconds: %v", searchLatency)
|
||||||
|
}
|
||||||
|
t.Logf("Face search latency: %v", searchLatency)
|
||||||
|
|
||||||
|
// Test end-to-end latency
|
||||||
|
start = time.Now()
|
||||||
|
// Detect
|
||||||
|
_, err = client.DetectFaces(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to detect faces: %v", err)
|
||||||
|
}
|
||||||
|
// Index
|
||||||
|
photoID = "perf-e2e-" + time.Now().Format("20060102150405")
|
||||||
|
_, err = client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to index face: %v", err)
|
||||||
|
}
|
||||||
|
// Search
|
||||||
|
_, err = client.SearchFacesByImage(ctx, imageBytes, 10, 80.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to search faces: %v", err)
|
||||||
|
}
|
||||||
|
e2eLatency := time.Since(start)
|
||||||
|
if e2eLatency > 5*time.Second {
|
||||||
|
t.Errorf("end-to-end latency exceeded 5 seconds: %v", e2eLatency)
|
||||||
|
}
|
||||||
|
t.Logf("End-to-end latency: %v", e2eLatency)
|
||||||
|
}
|
||||||
218
internal/face/rekognition.go
Normal file
218
internal/face/rekognition.go
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/rekognition"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/rekognition/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RekognitionClient wraps AWS Rekognition operations
|
||||||
|
type RekognitionClient struct {
|
||||||
|
client *rekognition.Client
|
||||||
|
collectionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FaceDetection represents a detected face
|
||||||
|
type FaceDetection struct {
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
BoundingBox BoundingBox `json:"boundingBox"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoundingBox represents face location in image
|
||||||
|
type BoundingBox struct {
|
||||||
|
Left float64 `json:"left"`
|
||||||
|
Top float64 `json:"top"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FaceMatch represents a matched face
|
||||||
|
type FaceMatch struct {
|
||||||
|
FaceID string `json:"faceId"`
|
||||||
|
ExternalImageID string `json:"externalImageId"`
|
||||||
|
Similarity float64 `json:"similarity"`
|
||||||
|
BoundingBox BoundingBox `json:"boundingBox"`
|
||||||
|
PhotoID string `json:"photoId,omitempty"`
|
||||||
|
PhotoURL string `json:"photoUrl,omitempty"`
|
||||||
|
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRekognitionClient creates a new Rekognition client
|
||||||
|
func NewRekognitionClient(cfg aws.Config, collectionID string) *RekognitionClient {
|
||||||
|
return &RekognitionClient{
|
||||||
|
client: rekognition.NewFromConfig(cfg),
|
||||||
|
collectionID: collectionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollection creates a new face collection
|
||||||
|
func (r *RekognitionClient) CreateCollection(ctx context.Context, collectionName string) error {
|
||||||
|
_, err := r.client.CreateCollection(ctx, &rekognition.CreateCollectionInput{
|
||||||
|
CollectionId: &collectionName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create collection: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCollection deletes a face collection
|
||||||
|
func (r *RekognitionClient) DeleteCollection(ctx context.Context, collectionName string) error {
|
||||||
|
_, err := r.client.DeleteCollection(ctx, &rekognition.DeleteCollectionInput{
|
||||||
|
CollectionId: &collectionName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete collection: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectFaces detects faces in an image
|
||||||
|
func (r *RekognitionClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
|
||||||
|
input := &rekognition.DetectFacesInput{
|
||||||
|
Image: &types.Image{
|
||||||
|
Bytes: imageBytes,
|
||||||
|
},
|
||||||
|
Attributes: []types.Attribute{
|
||||||
|
types.AttributeAll,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.client.DetectFaces(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to detect faces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var detections []FaceDetection
|
||||||
|
for _, face := range result.FaceDetails {
|
||||||
|
if face.BoundingBox == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
detection := FaceDetection{
|
||||||
|
Confidence: float64(*face.Confidence),
|
||||||
|
BoundingBox: BoundingBox{
|
||||||
|
Left: float64(*face.BoundingBox.Left),
|
||||||
|
Top: float64(*face.BoundingBox.Top),
|
||||||
|
Width: float64(*face.BoundingBox.Width),
|
||||||
|
Height: float64(*face.BoundingBox.Height),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
detections = append(detections, detection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return detections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexFace indexes a face in the collection
|
||||||
|
func (r *RekognitionClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
|
||||||
|
input := &rekognition.IndexFacesInput{
|
||||||
|
CollectionId: &r.collectionID,
|
||||||
|
Image: &types.Image{
|
||||||
|
Bytes: imageBytes,
|
||||||
|
},
|
||||||
|
ExternalImageId: &photoID,
|
||||||
|
MaxFaces: aws.Int32(1),
|
||||||
|
QualityFilter: types.QualityFilterAuto,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.client.IndexFaces(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to index face: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.FaceRecords) == 0 {
|
||||||
|
return nil, fmt.Errorf("no faces found in image")
|
||||||
|
}
|
||||||
|
|
||||||
|
faceRecord := result.FaceRecords[0]
|
||||||
|
if faceRecord.FaceDetail == nil || faceRecord.FaceDetail.BoundingBox == nil {
|
||||||
|
return nil, fmt.Errorf("no face detail available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FaceDetection{
|
||||||
|
Confidence: float64(*faceRecord.Face.Confidence),
|
||||||
|
BoundingBox: BoundingBox{
|
||||||
|
Left: float64(*faceRecord.FaceDetail.BoundingBox.Left),
|
||||||
|
Top: float64(*faceRecord.FaceDetail.BoundingBox.Top),
|
||||||
|
Width: float64(*faceRecord.FaceDetail.BoundingBox.Width),
|
||||||
|
Height: float64(*faceRecord.FaceDetail.BoundingBox.Height),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFacesByImage searches for similar faces
|
||||||
|
func (r *RekognitionClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
|
||||||
|
input := &rekognition.SearchFacesByImageInput{
|
||||||
|
CollectionId: &r.collectionID,
|
||||||
|
Image: &types.Image{
|
||||||
|
Bytes: imageBytes,
|
||||||
|
},
|
||||||
|
MaxFaces: aws.Int32(int32(maxFaces)),
|
||||||
|
FaceMatchThreshold: aws.Float32(float32(threshold)),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.client.SearchFacesByImage(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search faces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []FaceMatch
|
||||||
|
for _, match := range result.FaceMatches {
|
||||||
|
if match.Face == nil || match.Face.BoundingBox == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
faceMatch := FaceMatch{
|
||||||
|
FaceID: aws.ToString(match.Face.FaceId),
|
||||||
|
ExternalImageID: aws.ToString(match.Face.ExternalImageId),
|
||||||
|
Similarity: float64(aws.ToFloat32(match.Similarity)),
|
||||||
|
BoundingBox: BoundingBox{
|
||||||
|
Left: float64(*match.Face.BoundingBox.Left),
|
||||||
|
Top: float64(*match.Face.BoundingBox.Top),
|
||||||
|
Width: float64(*match.Face.BoundingBox.Width),
|
||||||
|
Height: float64(*match.Face.BoundingBox.Height),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
matches = append(matches, faceMatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractImageBytes extracts image bytes from a multipart file
|
||||||
|
func ExtractImageBytes(file multipart.File) ([]byte, error) {
|
||||||
|
// Read all bytes
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate it's an image
|
||||||
|
_, _, err = image.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid image format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRekognitionClientFromEnv creates a client from environment variables
|
||||||
|
func NewRekognitionClientFromEnv(collectionID string) (*RekognitionClient, error) {
|
||||||
|
cfg, err := config.LoadDefaultConfig(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load AWS config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewRekognitionClient(cfg, collectionID), nil
|
||||||
|
}
|
||||||
203
internal/face/service.go
Normal file
203
internal/face/service.go
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhotoRepository defines the photo operations needed by the face service
|
||||||
|
type PhotoRepository interface {
|
||||||
|
GetPhoto(ctx context.Context, id string) (*store.Photo, error)
|
||||||
|
CreateFaceEmbedding(ctx context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service provides face detection, indexing, and search operations
|
||||||
|
type Service struct {
|
||||||
|
client Client
|
||||||
|
repo PhotoRepository
|
||||||
|
cache CacheService
|
||||||
|
config *Config
|
||||||
|
keys CacheKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new face service
|
||||||
|
func NewService(client Client, repo PhotoRepository, cache CacheService, config *Config) *Service {
|
||||||
|
return &Service{
|
||||||
|
client: client,
|
||||||
|
repo: repo,
|
||||||
|
cache: cache,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateImage validates that image bytes are a valid image
|
||||||
|
func (s *Service) ValidateImage(imageBytes []byte) error {
|
||||||
|
if len(imageBytes) > s.config.MaxImageSize {
|
||||||
|
return ErrImageTooLarge
|
||||||
|
}
|
||||||
|
if len(imageBytes) < 100 { // Minimum viable image size
|
||||||
|
return ErrImageTooSmall
|
||||||
|
}
|
||||||
|
// Check for valid image headers
|
||||||
|
if len(imageBytes) < 4 {
|
||||||
|
return ErrInvalidImageFormat
|
||||||
|
}
|
||||||
|
// JPEG: starts with FF D8 FF
|
||||||
|
// PNG: starts with 89 50 4E 47
|
||||||
|
// WebP: starts with 52 49 46 46 (RIFF)
|
||||||
|
if imageBytes[0] == 0xFF && imageBytes[1] == 0xD8 && imageBytes[2] == 0xFF {
|
||||||
|
return nil // JPEG
|
||||||
|
}
|
||||||
|
if imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47 {
|
||||||
|
return nil // PNG
|
||||||
|
}
|
||||||
|
if imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46 {
|
||||||
|
return nil // WebP (RIFF)
|
||||||
|
}
|
||||||
|
return ErrInvalidImageFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexPhoto indexes all faces in a photo
|
||||||
|
func (s *Service) IndexPhoto(ctx context.Context, photoID string, imageBytes []byte) error {
|
||||||
|
// Check if already indexed
|
||||||
|
indexKey := s.keys.Indexed(photoID)
|
||||||
|
if indexed, _ := s.cache.Get(ctx, indexKey); indexed == "true" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect faces
|
||||||
|
detections, err := s.client.DetectFaces(ctx, imageBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to detect faces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(detections) == 0 {
|
||||||
|
log.Printf("No faces detected in photo %s", photoID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get photo metadata
|
||||||
|
photo, err := s.repo.GetPhoto(ctx, photoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get photo: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index each face with confidence > 99%
|
||||||
|
indexedCount := 0
|
||||||
|
for _, detection := range detections {
|
||||||
|
if detection.Confidence < 99.0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index face in Rekognition
|
||||||
|
_, err := s.client.IndexFace(ctx, imageBytes, photoID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to index face in photo %s: %v", photoID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
embedding := &store.FaceEmbedding{
|
||||||
|
PhotoID: photoID,
|
||||||
|
SpaceID: photo.SpaceID,
|
||||||
|
Embedding: []float32{}, // Rekognition handles embeddings internally
|
||||||
|
BoundingBox: fmt.Sprintf("%.2f,%.2f,%.2f,%.2f",
|
||||||
|
detection.BoundingBox.Left,
|
||||||
|
detection.BoundingBox.Top,
|
||||||
|
detection.BoundingBox.Width,
|
||||||
|
detection.BoundingBox.Height),
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.repo.CreateFaceEmbedding(ctx, embedding); err != nil {
|
||||||
|
log.Printf("Failed to store face embedding for photo %s: %v", photoID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
indexedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as indexed in cache
|
||||||
|
s.cache.Set(ctx, indexKey, "true", s.config.PhotoCacheTTL)
|
||||||
|
|
||||||
|
log.Printf("Indexed %d faces in photo %s", indexedCount, photoID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFaces searches for faces in a space
|
||||||
|
func (s *Service) SearchFaces(ctx context.Context, spaceID string, imageBytes []byte, limit int) (*FaceSearchResult, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Check cache for similar searches
|
||||||
|
imageHash := computeHash(imageBytes)
|
||||||
|
cacheKey := s.keys.Search(spaceID, imageHash)
|
||||||
|
|
||||||
|
var cachedResult FaceSearchResult
|
||||||
|
if err := s.cache.GetJSON(ctx, cacheKey, &cachedResult); err == nil && cachedResult.Matches != nil {
|
||||||
|
cachedResult.CacheHit = true
|
||||||
|
cachedResult.SearchTime = time.Since(startTime)
|
||||||
|
return &cachedResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in Rekognition
|
||||||
|
matches, err := s.client.SearchFacesByImage(ctx, imageBytes, limit, s.config.Threshold)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search faces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get photo metadata for matches
|
||||||
|
var resultMatches []FaceMatch
|
||||||
|
for _, match := range matches {
|
||||||
|
// Get photo ID from external image ID
|
||||||
|
photoID := match.ExternalImageID
|
||||||
|
photo, err := s.repo.GetPhoto(ctx, photoID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get photo %s: %v", photoID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich match with photo data
|
||||||
|
match.PhotoID = photoID
|
||||||
|
match.PhotoURL = photo.URL
|
||||||
|
match.ThumbnailURL = photo.ThumbnailURL
|
||||||
|
resultMatches = append(resultMatches, match)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result
|
||||||
|
result := &FaceSearchResult{
|
||||||
|
Matches: resultMatches,
|
||||||
|
PhotoCount: len(resultMatches),
|
||||||
|
SearchTime: time.Since(startTime),
|
||||||
|
CacheHit: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
s.cache.SetJSON(ctx, cacheKey, result, s.config.SearchCacheTTL)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollection creates a face collection for a space
|
||||||
|
func (s *Service) CreateCollection(ctx context.Context, collectionName string) error {
|
||||||
|
return s.client.CreateCollection(ctx, collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCollection deletes a face collection for a space
|
||||||
|
func (s *Service) DeleteCollection(ctx context.Context, collectionName string) error {
|
||||||
|
return s.client.DeleteCollection(ctx, collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeHash computes a SHA256 hash of the image bytes for caching
|
||||||
|
func computeHash(data []byte) string {
|
||||||
|
// Use first 1KB for quick hashing (full hash is expensive for large images)
|
||||||
|
sampleSize := 1024
|
||||||
|
if len(data) < sampleSize {
|
||||||
|
sampleSize = len(data)
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(data[:sampleSize])
|
||||||
|
return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter key
|
||||||
|
}
|
||||||
317
internal/face/service_test.go
Normal file
317
internal/face/service_test.go
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
package face
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockPhotoRepository is a mock implementation of PhotoRepository for testing
|
||||||
|
type MockPhotoRepository struct {
|
||||||
|
photos map[string]*store.Photo
|
||||||
|
embeddings []*store.FaceEmbedding
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockPhotoRepository() *MockPhotoRepository {
|
||||||
|
return &MockPhotoRepository{
|
||||||
|
photos: make(map[string]*store.Photo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*store.Photo, error) {
|
||||||
|
if photo, ok := m.photos[id]; ok {
|
||||||
|
return photo, nil
|
||||||
|
}
|
||||||
|
return &store.Photo{
|
||||||
|
ID: id,
|
||||||
|
SpaceID: "test-space",
|
||||||
|
URL: "https://example.com/photo.jpg",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPhotoRepository) CreateFaceEmbedding(ctx context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) {
|
||||||
|
m.embeddings = append(m.embeddings, embedding)
|
||||||
|
return embedding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPhotoRepository) AddPhoto(photo *store.Photo) {
|
||||||
|
m.photos[photo.ID] = photo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test helper functions
|
||||||
|
|
||||||
|
func setupTestService() (*Service, *MockClient, *MockPhotoRepository, *MemoryCache) {
|
||||||
|
mockClient := NewMockClient()
|
||||||
|
mockRepo := NewMockPhotoRepository()
|
||||||
|
cache := NewMemoryCache()
|
||||||
|
config := DefaultConfig()
|
||||||
|
|
||||||
|
service := NewService(mockClient, mockRepo, cache, config)
|
||||||
|
return service, mockClient, mockRepo, cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
func TestService_IndexPhoto_Success(t *testing.T) {
|
||||||
|
service, mockClient, mockRepo, _ := setupTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
photoID := "test-photo-1"
|
||||||
|
imageBytes := []byte("test-image-data")
|
||||||
|
|
||||||
|
// Add photo to mock repo
|
||||||
|
mockRepo.AddPhoto(&store.Photo{
|
||||||
|
ID: photoID,
|
||||||
|
SpaceID: "test-space-1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
err := service.IndexPhoto(ctx, photoID, imageBytes)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
_ = mockClient // Client was called internally
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_IndexPhoto_AlreadyIndexed(t *testing.T) {
|
||||||
|
service, _, _, cache := setupTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
photoID := "test-photo-1"
|
||||||
|
imageBytes := []byte("test-image-data")
|
||||||
|
|
||||||
|
// Pre-populate cache
|
||||||
|
cache.Set(ctx, "indexed:"+photoID, "true", 24*time.Hour)
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
err := service.IndexPhoto(ctx, photoID, imageBytes)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_SearchFaces_Success(t *testing.T) {
|
||||||
|
service, _, mockRepo, _ := setupTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
spaceID := "test-space-1"
|
||||||
|
imageBytes := []byte("test-image-data")
|
||||||
|
limit := 10
|
||||||
|
|
||||||
|
// Add photo to mock repo
|
||||||
|
mockRepo.AddPhoto(&store.Photo{
|
||||||
|
ID: "photo-1",
|
||||||
|
SpaceID: spaceID,
|
||||||
|
URL: "https://example.com/photo-1.jpg",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected result, got nil")
|
||||||
|
}
|
||||||
|
if len(result.Matches) == 0 {
|
||||||
|
t.Error("Expected at least one match")
|
||||||
|
}
|
||||||
|
if result.CacheHit {
|
||||||
|
t.Error("Expected cache hit to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_SearchFaces_CacheHit(t *testing.T) {
|
||||||
|
service, _, _, cache := setupTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
spaceID := "test-space-1"
|
||||||
|
imageBytes := []byte("test-image-data")
|
||||||
|
limit := 10
|
||||||
|
|
||||||
|
// Pre-populate cache with a result
|
||||||
|
cachedResult := &FaceSearchResult{
|
||||||
|
Matches: []FaceMatch{
|
||||||
|
{
|
||||||
|
FaceID: "cached-face-1",
|
||||||
|
Similarity: 95.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PhotoCount: 1,
|
||||||
|
}
|
||||||
|
cache.SetJSON(ctx, "search:"+spaceID+":"+computeHash(imageBytes), cachedResult, 5*time.Minute)
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected result, got nil")
|
||||||
|
}
|
||||||
|
if !result.CacheHit {
|
||||||
|
t.Error("Expected cache hit to be true")
|
||||||
|
}
|
||||||
|
if result.PhotoCount != 1 {
|
||||||
|
t.Errorf("Expected 1 photo, got %d", result.PhotoCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_CreateCollection(t *testing.T) {
|
||||||
|
service, _, _, _ := setupTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
err := service.CreateCollection(ctx, "test-collection")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_DeleteCollection(t *testing.T) {
|
||||||
|
service, _, _, _ := setupTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
err := service.DeleteCollection(ctx, "test-collection")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_Default(t *testing.T) {
|
||||||
|
config := DefaultConfig()
|
||||||
|
|
||||||
|
if config.Region != "us-east-1" {
|
||||||
|
t.Errorf("Expected region us-east-1, got %s", config.Region)
|
||||||
|
}
|
||||||
|
if config.CollectionID != "hatch-faces" {
|
||||||
|
t.Errorf("Expected collection ID hatch-faces, got %s", config.CollectionID)
|
||||||
|
}
|
||||||
|
if config.MaxFaces != 10 {
|
||||||
|
t.Errorf("Expected max faces 10, got %d", config.MaxFaces)
|
||||||
|
}
|
||||||
|
if config.Threshold != 80.0 {
|
||||||
|
t.Errorf("Expected threshold 80.0, got %f", config.Threshold)
|
||||||
|
}
|
||||||
|
if config.SearchCacheTTL != 5*time.Minute {
|
||||||
|
t.Errorf("Expected search cache TTL 5m, got %v", config.SearchCacheTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_Validate(t *testing.T) {
|
||||||
|
// Valid config
|
||||||
|
config := DefaultConfig()
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
t.Errorf("Expected valid config, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid: missing region
|
||||||
|
invalidConfig := DefaultConfig()
|
||||||
|
invalidConfig.Region = ""
|
||||||
|
if err := invalidConfig.Validate(); err == nil {
|
||||||
|
t.Error("Expected error for missing region")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid: missing collection ID
|
||||||
|
invalidConfig2 := DefaultConfig()
|
||||||
|
invalidConfig2.CollectionID = ""
|
||||||
|
if err := invalidConfig2.Validate(); err == nil {
|
||||||
|
t.Error("Expected error for missing collection ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheKeys(t *testing.T) {
|
||||||
|
keys := CacheKeys{}
|
||||||
|
|
||||||
|
if keys.Search("space1", "hash1") != "search:space1:hash1" {
|
||||||
|
t.Error("Search key mismatch")
|
||||||
|
}
|
||||||
|
if keys.Photo("photo1") != "photo:photo1" {
|
||||||
|
t.Error("Photo key mismatch")
|
||||||
|
}
|
||||||
|
if keys.Face("face1") != "face:face1" {
|
||||||
|
t.Error("Face key mismatch")
|
||||||
|
}
|
||||||
|
if keys.Indexed("photo1") != "indexed:photo1" {
|
||||||
|
t.Error("Indexed key mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateImage(t *testing.T) {
|
||||||
|
service, _, _, _ := setupTestService()
|
||||||
|
|
||||||
|
// Test with valid JPEG header (need at least 100 bytes)
|
||||||
|
validJPEG := make([]byte, 200)
|
||||||
|
validJPEG[0] = 0xFF
|
||||||
|
validJPEG[1] = 0xD8
|
||||||
|
validJPEG[2] = 0xFF
|
||||||
|
validJPEG[3] = 0xE0
|
||||||
|
if err := service.ValidateImage(validJPEG); err != nil {
|
||||||
|
t.Errorf("Expected no error for valid JPEG, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with valid PNG header (need at least 100 bytes)
|
||||||
|
validPNG := make([]byte, 200)
|
||||||
|
validPNG[0] = 0x89
|
||||||
|
validPNG[1] = 0x50
|
||||||
|
validPNG[2] = 0x4E
|
||||||
|
validPNG[3] = 0x47
|
||||||
|
if err := service.ValidateImage(validPNG); err != nil {
|
||||||
|
t.Errorf("Expected no error for valid PNG, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with too large image
|
||||||
|
largeImage := make([]byte, 11*1024*1024) // 11MB
|
||||||
|
if err := service.ValidateImage(largeImage); err != ErrImageTooLarge {
|
||||||
|
t.Errorf("Expected ErrImageTooLarge, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with too small image
|
||||||
|
smallImage := make([]byte, 10)
|
||||||
|
if err := service.ValidateImage(smallImage); err != ErrImageTooSmall {
|
||||||
|
t.Errorf("Expected ErrImageTooSmall, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with invalid format (large enough but wrong header)
|
||||||
|
invalidImage := make([]byte, 200)
|
||||||
|
if err := service.ValidateImage(invalidImage); err != ErrInvalidImageFormat {
|
||||||
|
t.Errorf("Expected ErrInvalidImageFormat, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeHash(t *testing.T) {
|
||||||
|
data1 := []byte("test data for hashing")
|
||||||
|
data2 := []byte("different data for hashing")
|
||||||
|
|
||||||
|
hash1 := computeHash(data1)
|
||||||
|
hash2 := computeHash(data2)
|
||||||
|
|
||||||
|
if hash1 == hash2 {
|
||||||
|
t.Error("Expected different hashes for different data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same data should produce same hash
|
||||||
|
hash1Again := computeHash(data1)
|
||||||
|
if hash1 != hash1Again {
|
||||||
|
t.Error("Expected same hash for same data")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/elfoundation/hatch/internal/face"
|
||||||
"github.com/elfoundation/hatch/internal/store"
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@ -16,6 +20,7 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
Repo store.Repository
|
Repo store.Repository
|
||||||
Debug bool
|
Debug bool
|
||||||
|
FaceService *face.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handler with the given store.
|
// New creates a new Handler with the given store.
|
||||||
@ -23,6 +28,14 @@ func New(repo store.Repository) *Handler {
|
|||||||
return &Handler{Repo: repo}
|
return &Handler{Repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWithFaceService creates a new Handler with face search service.
|
||||||
|
func NewWithFaceService(repo store.Repository, faceService *face.Service) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
Repo: repo,
|
||||||
|
FaceService: faceService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRoutes mounts all routes on the given chi router.
|
// RegisterRoutes mounts all routes on the given chi router.
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@ -46,6 +59,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
// JSON API v1 routes.
|
// JSON API v1 routes.
|
||||||
h.RegisterV1Routes(r)
|
h.RegisterV1Routes(r)
|
||||||
|
|
||||||
|
// Space management routes.
|
||||||
|
h.RegisterSpaceRoutes(r)
|
||||||
|
|
||||||
// Inspect page: server-rendered request list.
|
// Inspect page: server-rendered request list.
|
||||||
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
||||||
|
|
||||||
@ -154,3 +170,115 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Security Headers Middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SecurityHeaders adds standard security headers to all responses.
|
||||||
|
func SecurityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
|
w.Header().Set("Content-Security-Policy",
|
||||||
|
"default-src 'self'; "+
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
||||||
|
"style-src 'self' 'unsafe-inline'; "+
|
||||||
|
"img-src 'self' data: https:; "+
|
||||||
|
"font-src 'self' data:; "+
|
||||||
|
"connect-src 'self'; "+
|
||||||
|
"frame-ancestors 'none';")
|
||||||
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
w.Header().Set("Permissions-Policy",
|
||||||
|
"camera=(), microphone=(), geolocation=(), payment=()")
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
w.Header().Set("Strict-Transport-Security",
|
||||||
|
"max-age=31536000; includeSubDomains; preload")
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In-Memory Rate Limiter (sliding window)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RateLimiter provides per-IP rate limiting without external dependencies.
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
windows map[string][]time.Time
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter creates a rate limiter with the given limit per window.
|
||||||
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
windows: make(map[string][]time.Time),
|
||||||
|
limit: limit,
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit returns chi-compatible middleware.
|
||||||
|
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := getClientIP(r)
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-rl.window)
|
||||||
|
|
||||||
|
rl.mu.Lock()
|
||||||
|
slots := rl.windows[ip]
|
||||||
|
fresh := slots[:0]
|
||||||
|
for _, t := range slots {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
fresh = append(fresh, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fresh) >= rl.limit {
|
||||||
|
rl.windows[ip] = fresh
|
||||||
|
rl.mu.Unlock()
|
||||||
|
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit))
|
||||||
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||||
|
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fresh = append(fresh, now)
|
||||||
|
rl.windows[ip] = fresh
|
||||||
|
rl.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.limit))
|
||||||
|
w.Header().Set("X-RateLimit-Remaining",
|
||||||
|
strconv.Itoa(rl.limit-len(fresh)))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input Validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ValidateSlug checks that a slug contains only lowercase alphanumeric and hyphens.
|
||||||
|
func ValidateSlug(slug string) bool {
|
||||||
|
if len(slug) < 3 || len(slug) > 100 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range slug {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeSearchQuery strips characters that could cause injection issues.
|
||||||
|
func SanitizeSearchQuery(q string) string {
|
||||||
|
q = strings.ReplaceAll(q, "<", "")
|
||||||
|
q = strings.ReplaceAll(q, ">", "")
|
||||||
|
q = strings.ReplaceAll(q, "'", "")
|
||||||
|
q = strings.ReplaceAll(q, "\"", "")
|
||||||
|
q = strings.ReplaceAll(q, ";", "")
|
||||||
|
q = strings.ReplaceAll(q, "--", "")
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|||||||
383
internal/handler/spaces.go
Normal file
383
internal/handler/spaces.go
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterSpaceRoutes mounts the space management routes on the given router.
|
||||||
|
func (h *Handler) RegisterSpaceRoutes(r chi.Router) {
|
||||||
|
r.Route("/api/spaces", func(r chi.Router) {
|
||||||
|
// Public endpoints
|
||||||
|
r.Post("/", h.createSpace)
|
||||||
|
r.Get("/", h.listPublicSpaces)
|
||||||
|
r.Get("/{slug}", h.getSpaceBySlug)
|
||||||
|
r.Patch("/{slug}", h.updateSpace)
|
||||||
|
r.Delete("/{slug}", h.deleteSpace)
|
||||||
|
|
||||||
|
// Photo management
|
||||||
|
r.Post("/{slug}/photos", h.createPhoto)
|
||||||
|
r.Get("/{slug}/photos", h.listPhotos)
|
||||||
|
|
||||||
|
// Face search endpoints
|
||||||
|
r.Post("/{slug}/face-search/guest", h.guestFaceSearch)
|
||||||
|
r.Post("/{slug}/face-search", h.authenticatedFaceSearch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSpace handles POST /api/spaces
|
||||||
|
func (h *Handler) createSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var space store.Space
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&space); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug if not provided
|
||||||
|
if space.Slug == "" {
|
||||||
|
space.Slug = generateSlug(space.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if space.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
createdSpace, err := h.Repo.CreateSpace(ctx, &space)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create space: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(createdSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPublicSpaces handles GET /api/spaces
|
||||||
|
func (h *Handler) listPublicSpaces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
spaces, err := h.Repo.ListPublicSpaces(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list spaces")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(spaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSpaceBySlug handles GET /api/spaces/{slug}
|
||||||
|
func (h *Handler) getSpaceBySlug(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "space not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(space)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSpace handles PATCH /api/spaces/{slug}
|
||||||
|
func (h *Handler) updateSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
existingSpace, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "space not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields from request body
|
||||||
|
var updates store.Space
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
if updates.Name != "" {
|
||||||
|
existingSpace.Name = updates.Name
|
||||||
|
}
|
||||||
|
if updates.Description != "" {
|
||||||
|
existingSpace.Description = updates.Description
|
||||||
|
}
|
||||||
|
// Note: is_public is a boolean, so we need to check if it was explicitly set
|
||||||
|
// For simplicity, we'll always update it if provided in the request
|
||||||
|
existingSpace.IsPublic = updates.IsPublic
|
||||||
|
|
||||||
|
updatedSpace, err := h.Repo.UpdateSpace(ctx, existingSpace)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update space: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(updatedSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteSpace handles DELETE /api/spaces/{slug}
|
||||||
|
func (h *Handler) deleteSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "space not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Repo.DeleteSpace(ctx, space.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete space: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPhoto handles POST /api/spaces/{slug}/photos
|
||||||
|
func (h *Handler) createPhoto(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "space not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var photo store.Photo
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&photo); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if photo.URL == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "url is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photo.SpaceID = space.ID
|
||||||
|
createdPhoto, err := h.Repo.CreatePhoto(ctx, &photo)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create photo: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(createdPhoto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPhotos handles GET /api/spaces/{slug}/photos
|
||||||
|
func (h *Handler) listPhotos(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "space not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse limit parameter
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
limit := 100
|
||||||
|
if limitStr != "" {
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
photos, err := h.Repo.ListPhotosBySpace(ctx, space.ID, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list photos")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(photos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guestFaceSearch handles POST /api/spaces/{slug}/face-search/guest
|
||||||
|
func (h *Handler) guestFaceSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "space not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit (20 requests per 15 minutes per IP)
|
||||||
|
ipAddress := getClientIP(r)
|
||||||
|
allowed, err := h.Repo.CheckRateLimit(ctx, ipAddress, space.ID, 20, 15)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to check rate limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment rate limit
|
||||||
|
if err := h.Repo.IncrementRateLimit(ctx, ipAddress, space.ID, 15); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to increment rate limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body (should contain image data or URL)
|
||||||
|
var request struct {
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
ImageData string `json:"imageData,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if face service is available
|
||||||
|
if h.FaceService == nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "face search service not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert base64 image data to bytes
|
||||||
|
var imageBytes []byte
|
||||||
|
if request.ImageData != "" {
|
||||||
|
// Decode base64 image data
|
||||||
|
imageBytes, err = base64.StdEncoding.DecodeString(request.ImageData)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid image data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if request.ImageURL != "" {
|
||||||
|
// Download image from URL
|
||||||
|
resp, err := http.Get(request.ImageURL)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "failed to download image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
imageBytes, err = io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writeError(w, http.StatusBadRequest, "image data or URL required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image
|
||||||
|
if err := h.FaceService.ValidateImage(imageBytes); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search faces
|
||||||
|
result, err := h.FaceService.SearchFaces(ctx, space.ID, imageBytes, 10)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "face search failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format response
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"matches": result.Matches,
|
||||||
|
"photoCount": result.PhotoCount,
|
||||||
|
"searchTime": result.SearchTime.Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticatedFaceSearch handles POST /api/spaces/{slug}/face-search
|
||||||
|
func (h *Handler) authenticatedFaceSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// For now, delegate to guest face search
|
||||||
|
h.guestFaceSearch(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func generateSlug(name string) string {
|
||||||
|
// Convert to lowercase, replace spaces with hyphens, remove non-alphanumeric characters
|
||||||
|
slug := strings.ToLower(name)
|
||||||
|
slug = strings.ReplaceAll(slug, " ", "-")
|
||||||
|
slug = strings.ReplaceAll(slug, "_", "-")
|
||||||
|
// Remove non-alphanumeric characters except hyphens
|
||||||
|
var result strings.Builder
|
||||||
|
for _, c := range slug {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
|
||||||
|
result.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getClientIP(r *http.Request) string {
|
||||||
|
// Check X-Forwarded-For header first
|
||||||
|
xForwardedFor := r.Header.Get("X-Forwarded-For")
|
||||||
|
if xForwardedFor != "" {
|
||||||
|
// X-Forwarded-For may contain multiple IPs, take the first one
|
||||||
|
parts := strings.Split(xForwardedFor, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check X-Real-IP header
|
||||||
|
xRealIP := r.Header.Get("X-Real-IP")
|
||||||
|
if xRealIP != "" {
|
||||||
|
return xRealIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to RemoteAddr
|
||||||
|
parts := strings.Split(r.RemoteAddr, ":")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
return strings.Join(parts[:len(parts)-1], ":")
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
296
internal/handler/spaces_test.go
Normal file
296
internal/handler/spaces_test.go
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/elfoundation/hatch/internal/store"
|
||||||
|
"github.com/elfoundation/hatch/internal/testutil"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSpaceRouter creates a chi router with all routes registered using a fake repo.
|
||||||
|
func testSpaceRouter(repo store.Repository) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
h := New(repo)
|
||||||
|
h.RegisterRoutes(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSpace(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
body := `{"name": "Test Space", "description": "A test space", "isPublic": true}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var space store.Space
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&space); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.Name != "Test Space" {
|
||||||
|
t.Errorf("expected name 'Test Space', got %q", space.Name)
|
||||||
|
}
|
||||||
|
if space.Description != "A test space" {
|
||||||
|
t.Errorf("expected description 'A test space', got %q", space.Description)
|
||||||
|
}
|
||||||
|
if !space.IsPublic {
|
||||||
|
t.Error("expected isPublic to be true")
|
||||||
|
}
|
||||||
|
if space.Slug == "" {
|
||||||
|
t.Error("expected slug to be generated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSpaceMissingName(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
body := `{"description": "Missing name"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPublicSpaces(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
// Create some spaces
|
||||||
|
space1 := &store.Space{ID: "1", Slug: "space-1", Name: "Space 1", IsPublic: true}
|
||||||
|
space2 := &store.Space{ID: "2", Slug: "space-2", Name: "Space 2", IsPublic: false}
|
||||||
|
space3 := &store.Space{ID: "3", Slug: "space-3", Name: "Space 3", IsPublic: true}
|
||||||
|
|
||||||
|
repo.CreateSpace(nil, space1)
|
||||||
|
repo.CreateSpace(nil, space2)
|
||||||
|
repo.CreateSpace(nil, space3)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/spaces", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var spaces []*store.Space
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&spaces); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(spaces) != 2 {
|
||||||
|
t.Fatalf("expected 2 spaces, got %d", len(spaces))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that only public spaces are returned
|
||||||
|
for _, space := range spaces {
|
||||||
|
if !space.IsPublic {
|
||||||
|
t.Errorf("expected only public spaces, got %q", space.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSpaceBySlug(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||||
|
repo.CreateSpace(nil, space)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var retrievedSpace store.Space
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&retrievedSpace); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedSpace.ID != space.ID {
|
||||||
|
t.Errorf("expected ID %q, got %q", space.ID, retrievedSpace.ID)
|
||||||
|
}
|
||||||
|
if retrievedSpace.Name != space.Name {
|
||||||
|
t.Errorf("expected name %q, got %q", space.Name, retrievedSpace.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSpaceBySlugNotFound(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/spaces/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSpace(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
space := &store.Space{ID: "1", Slug: "test-space", Name: "Original Name", IsPublic: true}
|
||||||
|
repo.CreateSpace(nil, space)
|
||||||
|
|
||||||
|
body := `{"name": "Updated Name", "isPublic": false}`
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/spaces/test-space", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedSpace store.Space
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&updatedSpace); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedSpace.Name != "Updated Name" {
|
||||||
|
t.Errorf("expected name 'Updated Name', got %q", updatedSpace.Name)
|
||||||
|
}
|
||||||
|
if updatedSpace.IsPublic != false {
|
||||||
|
t.Error("expected isPublic to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteSpace(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||||
|
repo.CreateSpace(nil, space)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/spaces/test-space", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify space was deleted
|
||||||
|
_, err := repo.GetSpaceBySlug(nil, "test-space")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected space to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePhoto(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||||
|
repo.CreateSpace(nil, space)
|
||||||
|
|
||||||
|
body := `{"url": "https://example.com/photo.jpg", "thumbnailUrl": "https://example.com/thumb.jpg", "faceCount": 2}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/photos", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var photo store.Photo
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&photo); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if photo.URL != "https://example.com/photo.jpg" {
|
||||||
|
t.Errorf("expected URL 'https://example.com/photo.jpg', got %q", photo.URL)
|
||||||
|
}
|
||||||
|
if photo.ThumbnailURL != "https://example.com/thumb.jpg" {
|
||||||
|
t.Errorf("expected thumbnail URL 'https://example.com/thumb.jpg', got %q", photo.ThumbnailURL)
|
||||||
|
}
|
||||||
|
if photo.FaceCount != 2 {
|
||||||
|
t.Errorf("expected face count 2, got %d", photo.FaceCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPhotos(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||||
|
repo.CreateSpace(nil, space)
|
||||||
|
|
||||||
|
// Create some photos
|
||||||
|
photo1 := &store.Photo{ID: "1", SpaceID: "1", URL: "https://example.com/photo1.jpg"}
|
||||||
|
photo2 := &store.Photo{ID: "2", SpaceID: "1", URL: "https://example.com/photo2.jpg"}
|
||||||
|
photo3 := &store.Photo{ID: "3", SpaceID: "2", URL: "https://example.com/photo3.jpg"}
|
||||||
|
|
||||||
|
repo.CreatePhoto(nil, photo1)
|
||||||
|
repo.CreatePhoto(nil, photo2)
|
||||||
|
repo.CreatePhoto(nil, photo3)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space/photos", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var photos []*store.Photo
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&photos); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(photos) != 2 {
|
||||||
|
t.Fatalf("expected 2 photos, got %d", len(photos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuestFaceSearch(t *testing.T) {
|
||||||
|
repo := testutil.NewFakeRepository()
|
||||||
|
r := testSpaceRouter(repo)
|
||||||
|
|
||||||
|
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||||
|
repo.CreateSpace(nil, space)
|
||||||
|
|
||||||
|
// Face search without face service configured should return 500
|
||||||
|
body := `{"imageUrl": "https://example.com/test.jpg"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/face-search/guest", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = "192.168.1.1:12345"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500 when face service not configured, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response["error"] != "face search service not configured" {
|
||||||
|
t.Errorf("expected error about face search service not configured, got %q", response["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,9 @@ import (
|
|||||||
//go:embed schema.sql
|
//go:embed schema.sql
|
||||||
var schemaSQL string
|
var schemaSQL string
|
||||||
|
|
||||||
|
//go:embed schema_spaces.sql
|
||||||
|
var schemaSpacesSQL string
|
||||||
|
|
||||||
func Open(dbPath string) (Repository, error) {
|
func Open(dbPath string) (Repository, error) {
|
||||||
if dbPath == "" {
|
if dbPath == "" {
|
||||||
dbPath = filepath.Join("data", "hatch.db")
|
dbPath = filepath.Join("data", "hatch.db")
|
||||||
@ -39,9 +42,17 @@ func Open(dbPath string) (Repository, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrate(db *sql.DB) error {
|
func migrate(db *sql.DB) error {
|
||||||
|
// Load main schema
|
||||||
_, err := db.Exec(schemaSQL)
|
_, err := db.Exec(schemaSQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store/migrate: %w", err)
|
return fmt.Errorf("store/migrate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load spaces schema
|
||||||
|
_, err = db.Exec(schemaSpacesSQL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store/migrate spaces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,50 @@ type MockConfig struct {
|
|||||||
Body []byte `json:"body"`
|
Body []byte `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Space represents a public or private demo space
|
||||||
|
type Space struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo represents a photo in a space gallery
|
||||||
|
type Photo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpaceID string `json:"spaceId"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
|
FaceCount int `json:"faceCount"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FaceEmbedding stores face embeddings for search
|
||||||
|
type FaceEmbedding struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PhotoID string `json:"photoId"`
|
||||||
|
SpaceID string `json:"spaceId"`
|
||||||
|
Embedding []float32 `json:"embedding"`
|
||||||
|
BoundingBox string `json:"boundingBox"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimit tracks rate limiting for guest face search
|
||||||
|
type RateLimit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IPAddress string `json:"ipAddress"`
|
||||||
|
SpaceID string `json:"spaceId"`
|
||||||
|
RequestCount int `json:"requestCount"`
|
||||||
|
WindowStart string `json:"windowStart"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
|
// Endpoint operations
|
||||||
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
|
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
|
||||||
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
|
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
|
||||||
AppendRequest(ctx context.Context, endpointID string, req *Request) error
|
AppendRequest(ctx context.Context, endpointID string, req *Request) error
|
||||||
@ -37,6 +80,29 @@ type Repository interface {
|
|||||||
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
|
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
|
||||||
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
||||||
SetMock(ctx context.Context, mock *MockConfig) error
|
SetMock(ctx context.Context, mock *MockConfig) error
|
||||||
|
|
||||||
|
// Space operations
|
||||||
|
CreateSpace(ctx context.Context, space *Space) (*Space, error)
|
||||||
|
GetSpaceBySlug(ctx context.Context, slug string) (*Space, error)
|
||||||
|
UpdateSpace(ctx context.Context, space *Space) (*Space, error)
|
||||||
|
DeleteSpace(ctx context.Context, id string) error
|
||||||
|
ListPublicSpaces(ctx context.Context) ([]*Space, error)
|
||||||
|
|
||||||
|
// Photo operations
|
||||||
|
CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error)
|
||||||
|
GetPhoto(ctx context.Context, id string) (*Photo, error)
|
||||||
|
ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error)
|
||||||
|
DeletePhoto(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// Face embedding operations
|
||||||
|
CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error)
|
||||||
|
ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error)
|
||||||
|
SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error)
|
||||||
|
|
||||||
|
// Rate limiting operations
|
||||||
|
CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error)
|
||||||
|
IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error
|
||||||
|
|
||||||
Close() error
|
Close() error
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|||||||
51
internal/store/schema_spaces.sql
Normal file
51
internal/store/schema_spaces.sql
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
-- Space tables for public demo spaces with face search
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spaces (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spaces_slug ON spaces(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spaces_is_public ON spaces(is_public);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photos (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
face_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_space_id ON photos(space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_created_at ON photos(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS face_embeddings (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
embedding BLOB NOT NULL, -- JSON array of float32
|
||||||
|
bounding_box TEXT NOT NULL, -- JSON bounding box
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_face_embeddings_space_id ON face_embeddings(space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_face_embeddings_photo_id ON face_embeddings(photo_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
ip_address TEXT NOT NULL,
|
||||||
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
window_start TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_space ON rate_limits(ip_address, space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rate_limits_window_start ON rate_limits(window_start);
|
||||||
248
internal/store/sqlite_repo_spaces.go
Normal file
248
internal/store/sqlite_repo_spaces.go
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Space operations
|
||||||
|
|
||||||
|
func (r *sqliteRepo) CreateSpace(ctx context.Context, space *Space) (*Space, error) {
|
||||||
|
now := utcNow()
|
||||||
|
space.ID = uuid.NewString()
|
||||||
|
space.CreatedAt = now
|
||||||
|
space.UpdatedAt = now
|
||||||
|
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO spaces (id, slug, name, description, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
space.ID, space.Slug, space.Name, space.Description, space.IsPublic, space.CreatedAt, space.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: create space: %w", err)
|
||||||
|
}
|
||||||
|
return space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) GetSpaceBySlug(ctx context.Context, slug string) (*Space, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE slug = ?`, slug)
|
||||||
|
space := &Space{}
|
||||||
|
if err := row.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: get space by slug: %w", err)
|
||||||
|
}
|
||||||
|
return space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) UpdateSpace(ctx context.Context, space *Space) (*Space, error) {
|
||||||
|
space.UpdatedAt = utcNow()
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE spaces SET name = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
space.Name, space.Description, space.IsPublic, space.UpdatedAt, space.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: update space: %w", err)
|
||||||
|
}
|
||||||
|
return space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) DeleteSpace(ctx context.Context, id string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM spaces WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete space: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) ListPublicSpaces(ctx context.Context) ([]*Space, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE is_public = TRUE ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list public spaces: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var spaces []*Space
|
||||||
|
for rows.Next() {
|
||||||
|
space := &Space{}
|
||||||
|
if err := rows.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan space: %w", err)
|
||||||
|
}
|
||||||
|
spaces = append(spaces, space)
|
||||||
|
}
|
||||||
|
return spaces, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo operations
|
||||||
|
|
||||||
|
func (r *sqliteRepo) CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error) {
|
||||||
|
photo.ID = uuid.NewString()
|
||||||
|
photo.CreatedAt = utcNow()
|
||||||
|
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO photos (id, space_id, url, thumbnail_url, face_count, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
photo.ID, photo.SpaceID, photo.URL, photo.ThumbnailURL, photo.FaceCount, photo.Metadata, photo.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: create photo: %w", err)
|
||||||
|
}
|
||||||
|
return photo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) GetPhoto(ctx context.Context, id string) (*Photo, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE id = ?`, id)
|
||||||
|
photo := &Photo{}
|
||||||
|
if err := row.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: get photo: %w", err)
|
||||||
|
}
|
||||||
|
return photo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE space_id = ? ORDER BY created_at DESC LIMIT ?`,
|
||||||
|
spaceID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list photos by space: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var photos []*Photo
|
||||||
|
for rows.Next() {
|
||||||
|
photo := &Photo{}
|
||||||
|
if err := rows.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan photo: %w", err)
|
||||||
|
}
|
||||||
|
photos = append(photos, photo)
|
||||||
|
}
|
||||||
|
return photos, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) DeletePhoto(ctx context.Context, id string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM photos WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete photo: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face embedding operations
|
||||||
|
|
||||||
|
func (r *sqliteRepo) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error) {
|
||||||
|
embedding.ID = uuid.NewString()
|
||||||
|
embedding.CreatedAt = utcNow()
|
||||||
|
|
||||||
|
// Convert embedding to JSON
|
||||||
|
embeddingJSON, err := json.Marshal(embedding.Embedding)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: marshal face embedding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO face_embeddings (id, photo_id, space_id, embedding, bounding_box, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
embedding.ID, embedding.PhotoID, embedding.SpaceID, embeddingJSON, embedding.BoundingBox, embedding.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: create face embedding: %w", err)
|
||||||
|
}
|
||||||
|
return embedding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, photo_id, space_id, embedding, bounding_box, created_at FROM face_embeddings WHERE space_id = ?`,
|
||||||
|
spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list face embeddings by space: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var embeddings []*FaceEmbedding
|
||||||
|
for rows.Next() {
|
||||||
|
embedding := &FaceEmbedding{}
|
||||||
|
var embeddingJSON []byte
|
||||||
|
if err := rows.Scan(&embedding.ID, &embedding.PhotoID, &embedding.SpaceID, &embeddingJSON, &embedding.BoundingBox, &embedding.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan face embedding: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(embeddingJSON, &embedding.Embedding); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: unmarshal face embedding: %w", err)
|
||||||
|
}
|
||||||
|
embeddings = append(embeddings, embedding)
|
||||||
|
}
|
||||||
|
return embeddings, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, return all embeddings in the space (cosine similarity search would be implemented here)
|
||||||
|
// In a real implementation, you'd compute cosine similarity between the query embedding and all embeddings
|
||||||
|
embeddings, err := r.ListFaceEmbeddingsBySpace(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return up to limit results
|
||||||
|
if len(embeddings) > limit {
|
||||||
|
embeddings = embeddings[:limit]
|
||||||
|
}
|
||||||
|
return embeddings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting operations
|
||||||
|
|
||||||
|
func (r *sqliteRepo) CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
|
||||||
|
// Calculate window start time
|
||||||
|
windowStart := time.Now().UTC().Add(-time.Duration(windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(SUM(request_count), 0) FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start >= ?`,
|
||||||
|
ipAddress, spaceID, windowStart).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("store: check rate limit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count < limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error {
|
||||||
|
// Calculate window start time (rounded to the nearest window)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
windowStart := now.Add(-time.Duration(now.Minute()%windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
|
||||||
|
|
||||||
|
// Check if there's an existing record for this IP, space, and window
|
||||||
|
var id string
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start = ?`,
|
||||||
|
ipAddress, spaceID, windowStart).Scan(&id)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Create new record
|
||||||
|
id = uuid.NewString()
|
||||||
|
_, err = r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO rate_limits (id, ip_address, space_id, request_count, window_start, created_at) VALUES (?, ?, ?, 1, ?, ?)`,
|
||||||
|
id, ipAddress, spaceID, windowStart, utcNow())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: create rate limit: %w", err)
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
// Update existing record
|
||||||
|
_, err = r.db.ExecContext(ctx,
|
||||||
|
`UPDATE rate_limits SET request_count = request_count + 1 WHERE id = ?`,
|
||||||
|
id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: increment rate limit: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("store: get rate limit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -12,6 +12,12 @@ type FakeRepository struct {
|
|||||||
Endpoints map[string]*store.Endpoint
|
Endpoints map[string]*store.Endpoint
|
||||||
Requests []*store.Request
|
Requests []*store.Request
|
||||||
Mocks map[string]*store.MockConfig
|
Mocks map[string]*store.MockConfig
|
||||||
|
|
||||||
|
// Space-related fields
|
||||||
|
Spaces map[string]*store.Space
|
||||||
|
Photos map[string]*store.Photo
|
||||||
|
FaceEmbeddings map[string]*store.FaceEmbedding
|
||||||
|
RateLimits map[string]*store.RateLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFakeRepository creates a new FakeRepository.
|
// NewFakeRepository creates a new FakeRepository.
|
||||||
@ -19,6 +25,10 @@ func NewFakeRepository() *FakeRepository {
|
|||||||
return &FakeRepository{
|
return &FakeRepository{
|
||||||
Endpoints: map[string]*store.Endpoint{},
|
Endpoints: map[string]*store.Endpoint{},
|
||||||
Mocks: map[string]*store.MockConfig{},
|
Mocks: map[string]*store.MockConfig{},
|
||||||
|
Spaces: map[string]*store.Space{},
|
||||||
|
Photos: map[string]*store.Photo{},
|
||||||
|
FaceEmbeddings: map[string]*store.FaceEmbedding{},
|
||||||
|
RateLimits: map[string]*store.RateLimit{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +103,118 @@ func (f *FakeRepository) Close() error { return nil }
|
|||||||
|
|
||||||
func (f *FakeRepository) Ping(_ context.Context) error { return nil }
|
func (f *FakeRepository) Ping(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
// Space operations
|
||||||
|
|
||||||
|
func (f *FakeRepository) CreateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
|
||||||
|
f.Spaces[space.Slug] = space
|
||||||
|
return space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) GetSpaceBySlug(_ context.Context, slug string) (*store.Space, error) {
|
||||||
|
space, ok := f.Spaces[slug]
|
||||||
|
if !ok {
|
||||||
|
return nil, errNotFound
|
||||||
|
}
|
||||||
|
return space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) UpdateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
|
||||||
|
f.Spaces[space.Slug] = space
|
||||||
|
return space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) DeleteSpace(_ context.Context, id string) error {
|
||||||
|
for slug, space := range f.Spaces {
|
||||||
|
if space.ID == id {
|
||||||
|
delete(f.Spaces, slug)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) ListPublicSpaces(_ context.Context) ([]*store.Space, error) {
|
||||||
|
var spaces []*store.Space
|
||||||
|
for _, space := range f.Spaces {
|
||||||
|
if space.IsPublic {
|
||||||
|
spaces = append(spaces, space)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo operations
|
||||||
|
|
||||||
|
func (f *FakeRepository) CreatePhoto(_ context.Context, photo *store.Photo) (*store.Photo, error) {
|
||||||
|
f.Photos[photo.ID] = photo
|
||||||
|
return photo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) GetPhoto(_ context.Context, id string) (*store.Photo, error) {
|
||||||
|
photo, ok := f.Photos[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errNotFound
|
||||||
|
}
|
||||||
|
return photo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) ListPhotosBySpace(_ context.Context, spaceID string, limit int) ([]*store.Photo, error) {
|
||||||
|
var photos []*store.Photo
|
||||||
|
for _, photo := range f.Photos {
|
||||||
|
if photo.SpaceID == spaceID {
|
||||||
|
photos = append(photos, photo)
|
||||||
|
if limit > 0 && len(photos) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return photos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) DeletePhoto(_ context.Context, id string) error {
|
||||||
|
for photoID := range f.Photos {
|
||||||
|
if photoID == id {
|
||||||
|
delete(f.Photos, photoID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face embedding operations
|
||||||
|
|
||||||
|
func (f *FakeRepository) CreateFaceEmbedding(_ context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) {
|
||||||
|
f.FaceEmbeddings[embedding.ID] = embedding
|
||||||
|
return embedding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) ListFaceEmbeddingsBySpace(_ context.Context, spaceID string) ([]*store.FaceEmbedding, error) {
|
||||||
|
var embeddings []*store.FaceEmbedding
|
||||||
|
for _, embedding := range f.FaceEmbeddings {
|
||||||
|
if embedding.SpaceID == spaceID {
|
||||||
|
embeddings = append(embeddings, embedding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return embeddings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) SearchFaceEmbeddings(_ context.Context, spaceID string, embedding []float32, limit int) ([]*store.FaceEmbedding, error) {
|
||||||
|
// For fake repo, just return all embeddings in the space
|
||||||
|
return f.ListFaceEmbeddingsBySpace(nil, spaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting operations
|
||||||
|
|
||||||
|
func (f *FakeRepository) CheckRateLimit(_ context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
|
||||||
|
// For fake repo, always allow
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeRepository) IncrementRateLimit(_ context.Context, ipAddress, spaceID string, windowMinutes int) error {
|
||||||
|
// For fake repo, do nothing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type notFoundError struct{}
|
type notFoundError struct{}
|
||||||
|
|
||||||
func (e *notFoundError) Error() string { return "not found" }
|
func (e *notFoundError) Error() string { return "not found" }
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "export",
|
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
@ -5515,6 +5516,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -67,6 +67,13 @@
|
|||||||
<h1 data-animate="fade-up" data-delay="80">
|
<h1 data-animate="fade-up" data-delay="80">
|
||||||
Self-hostable HTTP request<br>inspector + mocker
|
Self-hostable HTTP request<br>inspector + mocker
|
||||||
</h1>
|
</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">
|
<p class="hero-sub" data-animate="fade-up" data-delay="160">
|
||||||
One Go binary. SQLite under the hood.<br>
|
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.
|
<code>docker compose up</code>, and you have an inspection endpoint and a live feed in under 30 seconds.
|
||||||
|
|||||||
26
site/main.js
26
site/main.js
@ -420,6 +420,32 @@
|
|||||||
|
|
||||||
initEntranceAnimations(animate, stagger);
|
initEntranceAnimations(animate, stagger);
|
||||||
initTerminalGlow(animate);
|
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
|
// Defer boot by one frame so anime.js engine is ready
|
||||||
|
|||||||
@ -363,6 +363,43 @@ nav {
|
|||||||
color: var(--text-primary);
|
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 {
|
.hero-sub {
|
||||||
font-size: clamp(1rem, 2vw, 1.2rem);
|
font-size: clamp(1rem, 2vw, 1.2rem);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user