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