commit 1253955070b343d04f82878dec8300b440902140 Author: Daniel Noulet Date: Wed Oct 7 15:14:50 2026 +0200 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..86fb66f --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Go API Configuration +ADDR=:8080 # Go API listen address +AUTH_MODE=dev # Authentication mode: dev or jwt +ENABLE_REST=true # Enable REST API endpoints (/api/places, etc.) + +# React UI Configuration +VITE_PROXY_TARGET=http://localhost:8080 # API target for Vite proxy + +# Optional overrides +# LOG_LEVEL=info # Debug level: debug, warn, error +# LOG_FORMAT=json # Output format: json or text \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c35146b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/node_modules/ +/bin/ +/api/bin/ +/api/go-places.db +/api/go-places.db-shm +/api/go-places.db-wal +/web/dist/ +/web/node_modules/ +.idea \ No newline at end of file diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000..4b3fbf2 --- /dev/null +++ b/DEVELOPER.md @@ -0,0 +1,162 @@ +# Developer Workflow Guide for Go Places Project + +This document outlines the standard operating procedure for building, testing, and deploying **go-places**, the service in this repository. It authenticates callers against **go-loose**, a separate SSO service that is not part of this repository. + +## 🚀 Core Dependencies & Structure +The system relies on the following structure: +- **Root:** `/home/dnoulet/go/go-places/` +- **API:** `api/` — the Go module root (`module go-places-api`) +- **Dependency:** `go-loose` — the SSO/authentication service. It lives in a **separate repository** and is *not* present here; only its Helm chart (`charts/go-loose/`) is kept in this repository. +- **Web:** `web/` — React front-end +- **Infrastructure:** all Kubernetes deployment definitions are in `charts/` + +## 🛠️ Local Development & Building (The Makefile) +All development tasks should begin by referencing the `Makefile` in the project root. + +### 1. Building Local Binaries (For testing/local runs) +This simulates the container image build process locally. + +```bash +make build +``` + +* `make build-api`: compiles the service to `bin/go-places`. +* `make build-loose`: **not available here.** `go-loose` is a separate repository, so this target reports that clearly instead of failing obscurely. + +### 2. Running Locally + +```bash +make run +``` + +Starts the API with `AUTH_MODE=dev` on `:8080`, backed by a local SQLite file and `./data/media`. Open in a browser for a small query console. + +### 3. Testing + +```bash +make test # go test ./... +make race # go test -race ./... +make vet # go vet ./... +make fmt # go fmt ./... +make check # everything CI runs: fmt, vet, race, web typecheck, helm lint +``` + +### 4. Validating Infrastructure +Before attempting deployment, always lint the Helm charts to ensure all YAML templates are valid. + +```bash +make validate +``` + +This runs `helm lint` against `charts/api` (go-places) and `charts/go-loose`. + +### 5. Container Image + +```bash +docker build -t go-places:latest . +``` + +The build context is the repository root, because the Dockerfile copies `api/`. The container runs unprivileged and exposes the API on port 8080. + +## ☁️ Deployment to Cluster (Helm/Rancher) + +The production deployment strategy leverages Kubernetes via Helm, managed by Rancher. + +```bash +make deploy +``` + +This runs `helm upgrade --install` for the `go-places` chart (from `charts/api`, whose internal name is `go-places`) and then the `go-loose` chart, both into the `dev` namespace. + +## 📡 HTTP Surface + +| Route | Auth | Purpose | +| --- | --- | --- | +| `GET /healthz` | no | Liveness probe. | +| `GET /readyz` | no | Readiness probe; pings the database. | +| `POST /graphql` | bearer | Queries and mutations. This is the only route that executes a query. | +| `GET /graphql` | no | Browser query console. A `?query=` parameter is rejected, because this route is unauthenticated. | +| `POST /places/{placeId}/images` | bearer | Multipart picture upload (`image` field, optional `caption`). | +| `POST /upload` | bearer | Same upload, with `placeId` given as a form field. | +| `GET /media/{key}` | no | Serves a stored picture. | +| `GET /api/places` | bearer | REST list, `ENABLE_REST=true`. | +| `GET /api/places/{placeId}` | bearer | REST read, including `relatedPlaces` and each picture's `linkedImages`. | +| `POST /api/places` | bearer | REST create. | +| `GET /api/places/{placeId}/images` | bearer | REST picture list. | + +Health probes and `/media/` are the only unauthenticated data routes, and neither +carries user data: a kubelet cannot present a token, and an `` tag cannot +attach one. Everything else requires a bearer token. + +Every setting is read from the environment by `internal/config`; see `api/internal/config/config.go` for the full list and its defaults. + +**Authentication.** With `AUTH_MODE=dev`, the token `valid_token` identifies the user `dev`. With `AUTH_MODE=jwt`, an HS256 token issued by `go-loose` must carry `sub` and `exp`, and may carry `username` and `email`. The algorithm is pinned to HS256, so a token cannot select `none` or an asymmetric algorithm. + +**Uploads.** Analysis runs inline: the picture is stored, decoded, and turned into badges before the response is written. A picture whose bytes cannot be decoded is still stored, with `analysisStatus: failed` and the reason in `analysisError`. + +**Linking.** `Image.linkedImages` and `Place.relatedPlaces` are computed on read from the pictures' own coordinates (EXIF GPS when present, otherwise the place's coordinate). Nothing is persisted, so links always reflect the current set of pictures. + +## 💾 Storage and durability + +SQLite and the uploaded pictures share a single volume, mounted at `/app/data` (`persistence.mountPath`). The chart requests a `ReadWriteOnce` claim and forces a `Recreate` strategy, because SQLite permits one writer and a rolling update would briefly run two pods against the same database file. `replicaCount` must stay at `1`. + +The volume is what makes the travel log survive a pod being rescheduled. To confirm: + +```bash +kubectl delete pod -n dev -l app.kubernetes.io/name=go-places +``` + +A place and its pictures are still there afterwards. The image runs as uid `10001`, and `securityContext.fsGroup` is set to the same value so the non-root process can create the database file inside the volume. + +Two limits worth knowing: + +- **The claim cannot be resized.** The cluster's `local-path` StorageClass does not allow volume expansion, so editing `persistence.size` makes the API server reject the resized claim. `helm upgrade` then aborts part-way and leaves the release in `STATUS=failed` (the running pod and its data are unaffected); recover with `helm rollback` or by upgrading again without the size change. Grow the storage or add an expandable StorageClass first. +- **`local-path` is node-local.** Data lives on the node's own disk under `/var/lib/rancher/k3s/storage`, so it survives pod replacement but not loss of that node. It also means the service is pinned to one node. Its reclaim policy is `Retain`, so deleting the claim does not destroy the data directory. + +Set `persistence.enabled=false` to fall back to an `emptyDir`. That is useful for a throwaway demo and wrong for anything real: the database and every picture are lost on each pod replacement. + +## 📚 Workflow Summary +1. **Develop:** make code changes in `api/` or `web/`. +2. **Test:** `make build && make check`. +3. **Run:** `make run`, then `cd web && npm run dev`. +4. **Validate:** `make validate`. +5. **Deploy:** `make deploy`. + +## 🖥️ Web front-end + +`web/` is a Vite + React client for the REST routes above, not for GraphQL. + +```bash +cd web +npm install +npm run dev # http://localhost:5173 +``` + +The dev server proxies `/api`, `/media`, `/healthz` and `/graphql` to the API, so +the browser only ever makes same-origin requests and CORS never enters the +picture. It expects the API on `http://localhost:8080`; override with +`VITE_PROXY_TARGET`. + +The client has no login flow: identity belongs to go-loose, so it asks for a +token and keeps it in `localStorage`. `VITE_API_TOKEN` bakes one into the bundle +at build time, and `VITE_API_BASE` points the client at an API on another origin. +A build without either asks for a token, which is what you want in production — +a token in a public bundle is public. + +```bash +cd web +npm run build # -> web/dist, a static bundle +npx tsc --noEmit # type check +``` + +`web/dist` is not served by the API image and `charts/api` does not deploy it. +See the front-end section of `INFRASTRUCTURE.md` for the two ways to publish it. + +## 🚫 Known Gaps / TODOs +- There is no Docker Compose setup, so RabbitMQ and a remote inference endpoint are not exercised locally by default. Without `AMQP_URL` the service logs events instead. +- Object recognition is heuristic. Detecting an arbitrary subject ("a dog") needs the remote analyzer at `ANALYZER_URL/v1/analyze`; the local analyzer only produces scene and property badges such as Water, Sky, Vivid and Nature. +- `shared-interfaces/schema/models.graphql` documents the domain structs. It is maintained by hand, so the executable schema in `api/internal/transport/schema.go` stays the source of truth and a drift check is still worth adding. +- `go-loose` is consumed purely as an HTTP/SSO dependency; there is no in-process client to it. +- Only comments can be read over REST. Adding one, or deleting a place or picture from the UI, needs a GraphQL call or new REST routes. + +This guide assumes familiarity with Go, Docker, and Kubernetes concepts. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..44323f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Dockerfile for go-places. +# +# The Go module lives in api/, so the build runs with api/ as the working +# directory. The version here has to be at least the `go` directive in +# api/go.mod, otherwise the toolchain refuses to build. + +# --- Build stage --- +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +# Copy the module definition first so dependency downloads stay cached when only +# application code changes. +COPY api/go.mod api/go.sum ./ +RUN go mod download + +COPY api/ ./ + +# CGO is not needed: the SQLite driver is the pure-Go modernc.org/sqlite. +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags '-s -w' -o /out/go-places ./cmd + +# --- Final stage --- +FROM alpine:3.20 + +# ca-certificates lets the remote analyzer reach an HTTPS inference service. +# wget backs the container healthcheck below. +RUN apk add --no-cache ca-certificates wget + +# The service runs unprivileged and needs to own the directories it writes to. +RUN adduser -D -u 10001 places \ + && mkdir -p /app/data/media \ + && chown -R places:places /app +WORKDIR /app + +COPY --from=builder /out/go-places /app/go-places + +USER places + +ENV ADDR=":8080" \ + DATABASE_URL="file:/app/data/go-places.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" \ + MEDIA_DIR="/app/data/media" + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- "http://127.0.0.1:8080/healthz" || exit 1 + +ENTRYPOINT ["/app/go-places"] \ No newline at end of file diff --git a/INFRASTRUCTURE.md b/INFRASTRUCTURE.md new file mode 100644 index 0000000..872740b --- /dev/null +++ b/INFRASTRUCTURE.md @@ -0,0 +1,102 @@ +# 🏰 Ecosystem Installation Playbook: End-to-End Guide +**Target System:** Kubernetes Cluster managed by Rancher +**Ecosystem Components:** go-places (this repository), go-loose, go-guess, go-tell, etc. +**Goal:** Achieve a "Press Start" configuration where the entire stack runs reliably, persistently, and securely. +**Author:** AI Assistant +**Date Generated:** 2026-10-03 + +--- + +## ⚠️ IMPORTANT WARNINGS & SAFETY CHECKLIST +*** DO NOT PROCEED WITHOUT READING THIS SECTION. *** + +* **Destructive Actions:** This guide includes commands to prune/reset system environments (e.g., Docker). Ensure all necessary repositories are backed up *before* executing Phase 0 cleanup steps. +* **Security:** All networking relies on the Ingress Controller and Cert-Manager for TLS termination. Ensure your DNS points correctly to the Ingress LoadBalancer IP. +* **Service Mesh:** An innovative Service Mesh (e.g., Istio/Linkerd) will be implemented to handle mTLS, traffic routing, and observability. This adds significant complexity; proceed with caution. + +--- + +## ⚙️ Phase 0: Prerequisites, Cleanup, and Backup (The "Start From Scratch" State) + +This phase ensures a known, clean state. **Execute these steps only when ready for a fresh deployment.** + +### 0.1 Tool Installation +* Install Docker Engine. +* Install Helm 3. +* Install `kubectl` and configure access to the target K8s cluster (`kubectl config use-context `). + +### 0.2 System Cleanup (Mandatory for Clean Slate) +* *Action:* Run the following commands to stop all services and remove all unused images/volumes. +* *Command:* `docker stop $(docker ps -q) && docker rm $(docker ps -aq) && docker image prune -f && docker system prune -a -f` + +### 0.3 Repository Persistence Setup (Forgejo Mounts) +To ensure code history persists across host reboots, we must map local paths to persistent volumes. +* *Action:* Identify the root path for the source code (e.g., `/home/dnoulet/go/`). +* *Command (Example):* Configure the K8s PersistentVolumeClaim (PVC) for Forgejo to mount at `/mnt/forgejo-repo`. +* *Guidance:* Refer to the Forgejo installation guide for exact PVC definitions. + +## 🗺️ Phase 1: Core Infrastructure Deployment (The Foundation) + +This phase sets up the networking and security backbone before deploying any application logic. + +### 1.1 Service Mesh Implementation +* *Recommendation:* Implement **Istio** for a robust Service Mesh. +* *Action:* Apply Istio's base installation manifest (`istioctl install --set profile=default`). + +### 1.2 TLS Management (Cert-Manager) +* *Action:* Deploy `cert-manager`. This allows dynamic issuance of certificates for external traffic. +* *Resource:* Define the necessary `Certificate` resources referencing your public domains. + +### 1.3 Entry Point (Ingress Controller) +* *Action:* Deploy the Ingress Controller (e.g., Nginx Ingress Controller). +* *Configuration:* Configure the Ingress resource to reference the `Certificate` Secret generated by Cert-Manager. + +## 🛠️ Phase 2: Application Build and Deployment (The Go Code) + +This phase builds the Go services and deploys them into the cluster, using the Service Mesh for communication. + +### 2.1 Building Docker Images +* *Action:* Navigate to the service root and build the image. +* *Command:* `make build`, then `docker build -t go-places:latest .` (build context is the repository root). + +### 2.2 Deploying Services via Helm +* **Order of Deployment is Critical:** + 1. **`go-loose` (Auth):** Deploy first as it is a core dependency. + `helm upgrade --install go-loose ./charts/go-loose -n dev` + 2. **`go-places`:** Deploy next. It reaches `go-loose` only by verifying that service's JWTs, so there is no service URL to wire up; set `AUTH_MODE=jwt` and `JWT_SECRET` to the shared signing secret. + `helm upgrade --install go-places ./charts/api -n dev` + 3. **`go-guess`, `go-tell`:** Repeat the above for all other applications. + +## ✅ Phase 3: Verification and Finalization + +* **Service Health Check:** Verify all pods are running (`kubectl get pods -n dev`). +* **Dependency Checks:** `curl -fsS https://api.yourdomain.com/healthz` and `curl -fsS https://api.yourdomain.com/readyz`. Both are unauthenticated so a probe or a quick `curl` works; `/readyz` additionally pings SQLite and answers `503` when the database is unreachable. +* **API Functionality Test:** Send an authenticated request. The GraphQL executor only answers `POST`, so a browser link cannot leak data: + + ```bash + curl -H "Authorization: Bearer $GO_LOOSE_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ places { id title } }"}' \ + https://api.yourdomain.com/graphql + ``` + +* **Media Check:** `curl -I https://api.yourdomain.com/media/` should return `200` and `Cache-Control: public, max-age=31536000, immutable`. Media is deliberately unauthenticated so `` tags work; if that is not acceptable for your data, put the Ingress in front of it with an auth subrequest. + +## 🌐 Phase 4: Web Front-end + +The React app in `web/` is a static bundle. It is **not** served by the `go-places` image and **not** deployed by `charts/api`; the API has no front-end assets. Two ways to publish it: + +* **Recommended:** build once and serve from a static host (or an nginx Deployment) on a subdomain, e.g. `https://places.yourdomain.com`. Point it at the API by configuring the CORS origins allowed by the Ingress, or serve it under the same host so the browser makes same-origin requests. +* **Quick path:** serve `web/dist` from any web server and use the Vite dev proxy locally (see `DEVELOPER.md`), which avoids CORS entirely because everything is same-origin. + +The client needs two things from its build environment: + +| Variable | Meaning | +| --- | --- | +| `VITE_API_BASE` | Optional. Base URL for the API when it is not same-origin. Empty means same-origin. | +| `VITE_API_TOKEN` | Optional. A token baked into the bundle. Leave unset and let the user paste a go-loose token into the "Set token" field in the UI. | + +Baking a token into a public bundle publishes it to every visitor, so only do that for a throwaway demo. + +--- +*End of Initial Playbook Draft.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f1ad515 --- /dev/null +++ b/Makefile @@ -0,0 +1,110 @@ +# Makefile for Go Places Project +# --------------------------------- +# Port Configuration: +# - Go API: defaults to :8080 (via ADDR env var) +# - React UI: defaults to :5173 (via Vite) +# - Proxy: forwards /api, /graphql, /media, /healthz to Go API +# --------------------------------- + +# --- Configuration --- +API_CHART_PATH := ./charts/api +LOOSE_CHART_PATH := ./charts/go-loose +API_DIR := api +BIN_DIR := bin +API_BINARY := $(BIN_DIR)/go-places + +# --- Targets --- + +.PHONY: all build build-api build-loose run test fmt vet validate dry-run-deploy deploy clean help + +# Default target: build the service that lives in this repository. +all: build-api + +# Main build target. +build: build-api + +# Build go-places. The module root is api/, so the build has to run there +# rather than from the repository root. +build-api: + @echo "--- Building go-places ---" + @mkdir -p $(BIN_DIR) + cd $(API_DIR) && go build -o ../$(API_BINARY) ./cmd + @echo "built $(API_BINARY)" + +# go-loose is the authentication service this API depends on. Its source lives in +# its own repository, so it cannot be built from here. +build-loose: + @echo "go-loose is a separate service and its source is not part of this repository." >&2 + @echo "See charts/$(notdir $(LOOSE_CHART_PATH))/values.yaml for how it is deployed." >&2 + @exit 1 + +# Run the service locally with development defaults. +# The already-built binary is exec'd rather than run through "go run", so the +# process make starts is the service itself: startup is immediate (no second +# compile) and Ctrl-C reaches it directly. +# Environment variables: +# ADDR - Go API listen address (default: :8080) +# AUTH_MODE - Authentication mode: dev or jwt (default: dev) +# ENABLE_REST - Enable REST endpoints (default: true) +run: build-api + cd $(API_DIR) && AUTH_MODE=dev ENABLE_REST=true exec ../$(API_BINARY) + +# React UI development server +# Forwards /api, /graphql, /media, /healthz to the Go API via Vite proxy. +# Vite dev server runs on :5173 by default (or next available port). +# Environment variable: +# VITE_PROXY_TARGET - API target URL (default: http://localhost:8080) +web-dev: + cd web && npm run dev + +test: + cd $(API_DIR) && go test ./... + +fmt: + cd $(API_DIR) && go fmt ./... + +vet: + cd $(API_DIR) && go vet ./... + +# Validate all Helm charts. +validate: + @echo "--- Validating Helm Charts ---" + helm lint $(API_CHART_PATH) $(LOOSE_CHART_PATH) + @echo "Helm linting complete." + +# Dry run deployment to validate manifests before applying to a cluster. +dry-run-deploy: + @echo "--- DRY RUN: Validating API Helm Chart ---" + helm lint $(API_CHART_PATH) + @echo "--- DRY RUN: Validating go-loose Helm Chart ---" + helm lint $(LOOSE_CHART_PATH) + @echo "Dry run validation complete. Manifests look correct." + +# Deploy all charts to a target environment (requires configured k8s context). +deploy: validate + @echo "--- Deploying all charts to Kubernetes ---" + helm upgrade --install go-places $(API_CHART_PATH) --namespace dev --create-namespace -f $(API_CHART_PATH)/values.yaml + helm upgrade --install go-loose $(LOOSE_CHART_PATH) --namespace dev --create-namespace -f $(LOOSE_CHART_PATH)/values.yaml + @echo "Deployment process initiated. Check cluster status." + +# Clean up generated files or images (if any) +clean: + @echo "--- Cleaning up build artifacts ---" + rm -rf $(BIN_DIR) + +# Help target +help: + @echo "==========================================" + @echo "Go Places Project Makefile Help" + @echo "make all : Build the go-places service." + @echo "make build : Alias for 'make all'." + @echo "make build-api : Build bin/go-places from ./api." + @echo "make run : Run the API locally with AUTH_MODE=dev." + @echo "make web-dev : Start Vite dev server for React UI (ports: API:8080, UI:5173)" + @echo "make test : Run the Go tests." + @echo "make fmt / vet : Format / vet the Go sources." + @echo "make build-loose : Not buildable here; go-loose is a separate repo." + @echo "make validate : Run 'helm lint' on both charts." + @echo "make dry-run-deploy : Validate Helm charts without deploying." + @echo "make deploy : Validate charts and deploy to Kubernetes." + @echo "make clean : Remove local build artifacts." diff --git a/api/cmd/main.go b/api/cmd/main.go new file mode 100644 index 0000000..959ff7c --- /dev/null +++ b/api/cmd/main.go @@ -0,0 +1,211 @@ +// Command go-places runs the go-places HTTP service: the GraphQL endpoint, +// the picture upload endpoint, media serving and the health probes. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "go-places-api/internal/analysis" + "go-places-api/internal/auth" + "go-places-api/internal/blobstore" + "go-places-api/internal/config" + "go-places-api/internal/events" + "go-places-api/internal/service" + "go-places-api/internal/store" + "go-places-api/internal/transport" +) + +func main() { + if err := run(); err != nil { + slog.Error("startup failed", "error", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + logger := newLogger() + slog.SetDefault(logger) + + logger.Info("starting go-places", + "addr", cfg.Addr, + "database", cfg.DatabaseURL, + "mediaDir", cfg.MediaDir, + "authMode", cfg.AuthMode, + "analyzerMode", cfg.AnalyzerMode, + ) + + // The store is opened before anything else so a bad database path fails + // startup rather than the first request. + db, err := store.OpenSQL(cfg.DatabaseURL) + if err != nil { + return err + } + defer db.Close() + + blobs, err := blobstore.NewLocalFS(cfg.MediaDir) + if err != nil { + return err + } + + publisher, err := newPublisher(cfg, logger) + if err != nil { + return err + } + defer publisher.Close() + + svc := service.New(service.Options{ + Store: db, + Blobs: blobs, + Analyzer: newAnalyzer(cfg), + Events: publisher, + Config: cfg, + Logger: slogAdapter{logger}, + }) + + verifier := newVerifier(cfg, logger) + logger.Info("token verifier ready", "strategy", verifier.Name()) + + server, err := transport.NewServer(transport.Options{ + Service: svc, + Config: cfg, + Auth: auth.NewMiddleware(verifier), + Blobs: blobs, + Logger: logger, + }) + if err != nil { + return err + } + + return serve(cfg, server.Handler(), logger) +} + +// newPublisher returns the configured event sink. Without AMQP_URL the events go +// to the log, which keeps the service runnable on its own. +func newPublisher(cfg config.Config, logger *slog.Logger) (events.Publisher, error) { + if cfg.AMQPURL == "" { + logger.Info("no AMQP_URL configured, publishing events to the log only") + return events.NewLogPublisher(logger), nil + } + p, err := events.NewAMQP(cfg.AMQPURL, cfg.EventsExchange, logger) + if err != nil { + // A broker that is down at boot should not stop the service: events are + // best effort and the log publisher keeps them visible. + logger.Warn("could not connect to the event broker, falling back to the log", + "error", err) + return events.NewLogPublisher(logger), nil + } + logger.Info("connected to the event broker", "exchange", cfg.EventsExchange) + return p, nil +} + +// newAnalyzer builds the image analyzer. In remote mode the local pass is still +// chained in front, so a picture gets badges even if the inference service is +// unreachable. +func newAnalyzer(cfg config.Config) analysis.Analyzer { + local := analysis.NewLocal(analysis.DefaultMaxDimension) + if cfg.AnalyzerMode != "remote" { + return local + } + remote := analysis.NewRemote(cfg.AnalyzerURL, cfg.AnalyzerKey, cfg.AnalyzerTimeout) + return analysis.NewComposite(local, remote) +} + +// newVerifier selects how bearer tokens are checked. The dev verifier is +// refused unless it was asked for explicitly, so it cannot be reached by +// accident in a deployed environment. +func newVerifier(cfg config.Config, logger *slog.Logger) auth.Verifier { + switch cfg.AuthMode { + case "jwt": + return auth.NewJWTVerifier(cfg.JWTSecret, cfg.JWTAudience, "") + default: + logger.Warn("AUTH_MODE=dev: tokens are checked against a fixed table, " + + "do not use this outside development") + return auth.NewDevVerifier(nil) + } +} + +// serve runs the HTTP server until the process is asked to stop, then drains +// in-flight requests before returning. +func serve(cfg config.Config, handler http.Handler, logger *slog.Logger) error { + srv := &http.Server{ + Addr: cfg.Addr, + Handler: handler, + // Generous timeouts: uploads of several megabytes over a slow link are + // legitimate, so the write window has to outlast one. + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 2 * time.Minute, + WriteTimeout: 2 * time.Minute, + IdleTimeout: 2 * time.Minute, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errc := make(chan error, 1) + go func() { + logger.Info("listening", "addr", cfg.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errc <- fmt.Errorf("http server: %w", err) + return + } + errc <- nil + }() + + select { + case err := <-errc: + return err + case <-ctx.Done(): + logger.Info("shutdown signal received, draining connections") + } + + // A second signal aborts the drain immediately. + stop() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("http shutdown: %w", err) + } + logger.Info("shutdown complete") + return nil +} + +// newLogger returns a structured logger reading LOG_LEVEL (debug, info, warn, +// error) and LOG_FORMAT (json or text). +func newLogger() *slog.Logger { + level := slog.LevelInfo + switch os.Getenv("LOG_LEVEL") { + case "debug": + level = slog.LevelDebug + case "warn", "warning": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + opts := &slog.HandlerOptions{Level: level} + if os.Getenv("LOG_FORMAT") == "text" { + return slog.New(slog.NewTextHandler(os.Stdout, opts)) + } + return slog.New(slog.NewJSONHandler(os.Stdout, opts)) +} + +// slogAdapter narrows *slog.Logger to the small logging surface the service +// declares, which keeps slog out of the service package. +type slogAdapter struct{ l *slog.Logger } + +func (a slogAdapter) Warn(msg string, args ...any) { a.l.Warn(msg, args...) } +func (a slogAdapter) Info(msg string, args ...any) { a.l.Info(msg, args...) } diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 0000000..6dc01a5 --- /dev/null +++ b/api/go.mod @@ -0,0 +1,23 @@ +module go-places-api + +go 1.24 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/graphql-go/graphql v0.8.1 + github.com/rabbitmq/amqp091-go v1.15.0 + golang.org/x/image v0.23.0 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 0000000..8b6c47f --- /dev/null +++ b/api/go.sum @@ -0,0 +1,53 @@ +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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc= +github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= +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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/rabbitmq/amqp091-go v1.15.0 h1:LEQL4/yp48/Wigt6A6XOu18RQRo8ZHtB5I/KZJn+gkw= +github.com/rabbitmq/amqp091-go v1.15.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/api/internal/analysis/analysis.go b/api/internal/analysis/analysis.go new file mode 100644 index 0000000..0d558df --- /dev/null +++ b/api/internal/analysis/analysis.go @@ -0,0 +1,122 @@ +// Package analysis inspects an uploaded picture and derives badges describing +// what can be recognised in it. +// +// Two implementations are provided. LocalAnalyzer measures the picture's own +// pixels and EXIF metadata and turns those measurements into scene and content +// badges; it needs no external service and is fully deterministic. RemoteAnalyzer +// delegates to an external inference service (a trained object-detection model) +// and returns the labels that model predicts. The two can be combined, in which +// case model labels take precedence over heuristics of the same name. +package analysis + +import ( + "context" + "time" + + "go-places-api/internal/domain" +) + +// Input is the picture to analyse. +type Input struct { + // ContentType is the MIME type as supplied by the client. + ContentType string + // Bytes is the complete encoded picture. + Bytes []byte + // MaxDimension bounds the longest edge used for pixel measurements. The + // picture is downscaled before sampling so that cost does not grow with + // megapixels. Zero means use the package default. + MaxDimension int + // Hints carries scene labels already known about the picture, which lets a + // remote analyzer disambiguate using earlier results. + Hints []string +} + +// Metadata is the capture information recovered from EXIF, if any. +type Metadata struct { + // Coordinate is the GPS position from the EXIF GPS IFD. + Coordinate *domain.Coordinate + // TakenAt is the original capture timestamp. + TakenAt *time.Time + // Make and Model identify the camera or phone. + Make string + Model string +} + +// Signals are the measurements taken from the pixels. Every field is +// normalised to 0..1 so the badge rules can use a single comparable scale. +type Signals struct { + // Width and Height are the dimensions of the sampled image. + Width int + Height int + + // Brightness is the mean perceptual luminance. + Brightness float64 + // Saturation is the mean colour saturation. + Saturation float64 + // Colorfulness is the spread of hue across the image, a stand-in for how + // varied the scene is. + Colorfulness float64 + // EdgeDensity is the fraction of pixels sitting on a strong gradient, which + // indicates how much fine detail the picture holds. + EdgeDensity float64 + + // HueWeights holds the fraction of pixels falling into each colour class: + // blue, green, warm (red/orange/yellow), purple and achromatic. + HueWeights HueWeights + + // Top, Middle and Bottom hold the same measurements restricted to the + // upper, central and lower horizontal bands of the picture. Sky, ground + // and water all separate cleanly this way. + Top Band + Middle Band + Bottom Band +} + +// Band is the measurement set for one horizontal third of a picture. +type Band struct { + Brightness float64 + Saturation float64 + EdgeDensity float64 + HueWeights HueWeights + PixelShare float64 + PixelCount int +} + +// HueWeights is the distribution of pixels across colour classes. The values +// sum to approximately 1 for a fully populated band. +type HueWeights struct { + Blue float64 + Green float64 + Warm float64 + // Purple covers magenta and violet, which read as neither blue nor warm. + Purple float64 + // Achromatic is the share of pixels with little or no hue: greys, whites, + // blacks and near-neutrals. + Achromatic float64 +} + +// Result is everything the pipeline learned about a picture. +type Result struct { + // Width and Height are the true dimensions of the encoded picture. + Width int + Height int + // Signals are the pixel measurements, retained for debugging and for the + // remote analyzer's fallback. + Signals Signals + // Metadata is what EXIF reported. + Metadata Metadata + // Badges are the conclusions drawn from the above. + Badges []domain.Badge + // Note carries a non-fatal warning, such as a degraded analyzer, so the + // caller can record why the badge set may be incomplete. + Note string +} + +// Analyzer inspects a picture. +type Analyzer interface { + // Name identifies the analyzer in badge sources. + Name() string + // Analyze inspects the picture. It returns an error only when the picture + // cannot be inspected at all; a picture that yields no badges is a success. + Analyze(ctx context.Context, in Input) (*Result, error) +} diff --git a/api/internal/analysis/analysis_test.go b/api/internal/analysis/analysis_test.go new file mode 100644 index 0000000..cc99864 --- /dev/null +++ b/api/internal/analysis/analysis_test.go @@ -0,0 +1,410 @@ +package analysis + +import ( + "bytes" + "context" + "image" + "image/color" + "image/png" + "testing" + "time" + + "go-places-api/internal/domain" +) + +// pngOf encodes an RGBA image as PNG bytes. +func pngOf(t *testing.T, img image.Image) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +// solid returns a uniformly coloured picture. +func solid(w, h int, c color.RGBA) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetRGBA(x, y, c) + } + } + return img +} + +// banded returns a picture whose horizontal thirds are the given colours, which +// is how the tests construct skies, ground and horizons. +func banded(top, middle, bottom color.RGBA) *image.RGBA { + const w, h = 60, 60 + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + c := top + switch { + case y >= h*2/3: + c = bottom + case y >= h/3: + c = middle + } + for x := 0; x < w; x++ { + img.SetRGBA(x, y, c) + } + } + return img +} + +func analyzePNG(t *testing.T, img image.Image) *Result { + t.Helper() + a := NewLocal(DefaultMaxDimension) + res, err := a.Analyze(context.Background(), Input{ + ContentType: "image/png", + Bytes: pngOf(t, img), + }) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + return res +} + +// labels collects the badge labels from a result. +func labels(res *Result) []string { + out := make([]string, 0, len(res.Badges)) + for _, b := range res.Badges { + out = append(out, b.Label) + } + return out +} + +func hasBadge(res *Result, label string) bool { + for _, b := range res.Badges { + if b.Label == label { + return true + } + } + return false +} + +func confidenceOf(res *Result, label string) (float64, bool) { + for _, b := range res.Badges { + if b.Label == label { + return b.Confidence, true + } + } + return 0, false +} + +var ( + skyBlue = color.RGBA{R: 60, G: 130, B: 220, A: 255} + grassGreen = color.RGBA{R: 60, G: 160, B: 70, A: 255} + sandColor = color.RGBA{R: 220, G: 195, B: 140, A: 255} + sunsetRed = color.RGBA{R: 230, G: 110, B: 60, A: 255} + midGrey = color.RGBA{R: 128, G: 128, B: 128, A: 255} + darkGrey = color.RGBA{R: 30, G: 30, B: 30, A: 255} + white = color.RGBA{R: 250, G: 250, B: 250, A: 255} +) + +func TestAnalyzeReportsTrueDimensions(t *testing.T) { + t.Parallel() + + // A small picture is sampled at its own size; a large one is downscaled, + // but the reported dimensions must remain the encoded ones. + for _, dims := range [][2]int{{40, 30}, {800, 600}} { + w, h := dims[0], dims[1] + res := analyzePNG(t, solid(w, h, skyBlue)) + if res.Width != w || res.Height != h { + t.Errorf("dimensions = %dx%d, want %dx%d", res.Width, res.Height, w, h) + } + } +} + +func TestAnalyzeDetectsSky(t *testing.T) { + t.Parallel() + + // Blue upper band over green ground: the classic outdoor landscape. + res := analyzePNG(t, banded(skyBlue, grassGreen, grassGreen)) + + if !hasBadge(res, labelSky) { + t.Errorf("expected a sky badge, got %v", labels(res)) + } + if !hasBadge(res, labelVegetation) { + t.Errorf("expected a vegetation badge, got %v", labels(res)) + } + conf, ok := confidenceOf(res, labelSky) + if !ok { + t.Fatal("sky badge has no confidence") + } + if conf <= 0 || conf > 1 { + t.Errorf("sky confidence = %v, want a value in (0,1]", conf) + } +} + +func TestAnalyzeDetectsBeach(t *testing.T) { + t.Parallel() + + // Sky above warm sand below. + res := analyzePNG(t, banded(skyBlue, sandColor, sandColor)) + if !hasBadge(res, labelBeach) { + t.Errorf("expected a beach badge, got %v", labels(res)) + } +} + +func TestAnalyzeDetectsWater(t *testing.T) { + t.Parallel() + + // Blue concentrated in the lower band is water rather than sky. + res := analyzePNG(t, banded(midGrey, skyBlue, skyBlue)) + if !hasBadge(res, labelWater) { + t.Errorf("expected a water badge, got %v", labels(res)) + } + if hasBadge(res, labelSky) { + t.Errorf("did not expect a sky badge when the blue is in the lower band, got %v", + labels(res)) + } +} + +func TestAnalyzeDetectsMonochrome(t *testing.T) { + t.Parallel() + + res := analyzePNG(t, solid(50, 50, midGrey)) + if !hasBadge(res, labelMonochrome) { + t.Errorf("expected a monochrome badge, got %v", labels(res)) + } + if hasBadge(res, labelVivid) { + t.Errorf("did not expect a vivid badge from a grey picture, got %v", labels(res)) + } +} + +func TestAnalyzeDetectsLowLightAndNight(t *testing.T) { + t.Parallel() + + dark := analyzePNG(t, solid(50, 50, darkGrey)) + if !hasBadge(dark, labelLowLight) { + t.Errorf("expected a low-light badge, got %v", labels(dark)) + } + if !hasBadge(dark, labelNight) { + t.Errorf("expected a night badge from brightness alone, got %v", labels(dark)) + } +} + +func TestAnalyzeDetectsOverexposed(t *testing.T) { + t.Parallel() + + bright := analyzePNG(t, solid(50, 50, white)) + if !hasBadge(bright, labelOverexposed) { + t.Errorf("expected an overexposed badge, got %v", labels(bright)) + } +} + +func TestAnalyzeDetectsOrientation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + w, h int + want, notWant string + }{ + // Ratios sit comfortably inside each band; a picture whose ratio lands + // exactly on a band edge scores zero confidence, which is by design. + {"tall", 45, 60, labelPortrait, labelLandscape}, + {"wide", 60, 45, labelLandscape, labelPortrait}, + {"square", 40, 40, "", labelPanorama}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + res := analyzePNG(t, solid(tc.w, tc.h, midGrey)) + if tc.want != "" && !hasBadge(res, tc.want) { + t.Errorf("expected %q badge, got %v", tc.want, labels(res)) + } + if tc.notWant != "" && hasBadge(res, tc.notWant) { + t.Errorf("did not expect %q badge, got %v", tc.notWant, labels(res)) + } + }) + } +} + +func TestAnalyzeDetectsPanorama(t *testing.T) { + t.Parallel() + + res := analyzePNG(t, solid(120, 40, midGrey)) + if !hasBadge(res, labelPanorama) { + t.Errorf("expected a panorama badge for a 3:1 picture, got %v", labels(res)) + } +} + +func TestAnalyzeIsDeterministic(t *testing.T) { + t.Parallel() + + img := banded(skyBlue, grassGreen, sandColor) + first := labels(analyzePNG(t, img)) + for i := 0; i < 3; i++ { + got := labels(analyzePNG(t, img)) + if len(got) != len(first) { + t.Fatalf("run %d produced %v, want %v", i, got, first) + } + for j := range got { + if got[j] != first[j] { + t.Errorf("run %d produced %v, want %v", i, got, first) + break + } + } + } +} + +func TestAnalyzeRejectsBadInput(t *testing.T) { + t.Parallel() + + a := NewLocal(DefaultMaxDimension) + + if _, err := a.Analyze(context.Background(), Input{Bytes: nil}); err == nil { + t.Error("expected an error for an empty payload") + } + if _, err := a.Analyze(context.Background(), Input{Bytes: []byte("not an image")}); err == nil { + t.Error("expected an error for a payload that is not an image") + } +} + +func TestAnalyzeRespectsContextCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + a := NewLocal(DefaultMaxDimension) + _, err := a.Analyze(ctx, Input{ContentType: "image/png", Bytes: pngOf(t, solid(20, 20, midGrey))}) + if err == nil { + t.Error("expected a cancelled context to fail the analysis") + } +} + +// TestCombinePrefersModelLabels checks the merge rule: a model label wins the +// display name, but a confident heuristic is not thrown away. +func TestCombinePrefersModelLabels(t *testing.T) { + t.Parallel() + + local := []domain.Badge{ + {Label: labelSky, Display: "Sky", Confidence: 0.9, Source: SourceLocal}, + {Label: "beach", Display: "Beach", Confidence: 0.8, Source: SourceLocal}, + } + model := []domain.Badge{ + {Label: "dog", Display: "Dog", Confidence: 0.95, Source: SourceRemote}, + // The model also claims sky, less confidently than the heuristic. + {Label: labelSky, Display: "Clear sky", Confidence: 0.4, Source: SourceRemote}, + } + + got := Combine(local, model) + + byLabel := map[string]domain.Badge{} + for _, b := range got { + byLabel[b.Label] = b + } + + if len(got) != 3 { + t.Fatalf("expected 3 distinct badges, got %d: %v", len(got), got) + } + if _, ok := byLabel["dog"]; !ok { + t.Error("the model label was dropped") + } + sky := byLabel[labelSky] + if sky.Source != SourceRemote { + t.Errorf("sky source = %q, want %q", sky.Source, SourceRemote) + } + if sky.Display != "Clear sky" { + t.Errorf("sky display = %q, want the model's %q", sky.Display, "Clear sky") + } + if sky.Confidence != 0.9 { + t.Errorf("sky confidence = %v, want the heuristic's higher 0.9", sky.Confidence) + } +} + +func TestCombineIsSortedByConfidence(t *testing.T) { + t.Parallel() + + badges := Combine( + []domain.Badge{{Label: "a", Confidence: 0.2}, {Label: "b", Confidence: 0.9}}, + []domain.Badge{{Label: "c", Confidence: 0.5, Source: SourceRemote}}, + ) + for i := 1; i < len(badges); i++ { + if badges[i-1].Confidence < badges[i].Confidence { + t.Errorf("badges are not sorted by confidence: %v", badges) + break + } + } +} + +func TestCombineFillsMissingDisplayName(t *testing.T) { + t.Parallel() + + got := Combine(nil, []domain.Badge{{Label: "low-light", Confidence: 0.7, Source: SourceRemote}}) + if len(got) != 1 { + t.Fatalf("expected 1 badge, got %d", len(got)) + } + if got[0].Display != "Low light" { + t.Errorf("display = %q, want %q", got[0].Display, "Low light") + } +} + +func TestHumanize(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "dog": "Dog", + "low-light": "Low light", + "close_up": "Close up", + "already Done": "Already done", + "": "", + "sandwich-board": "Sandwich board", + } + for in, want := range cases { + if got := Humanize(in); got != want { + t.Errorf("Humanize(%q) = %q, want %q", in, got, want) + } + } +} + +// TestRuleNightPrefersClockTime confirms the EXIF timestamp takes precedence +// over brightness, so a dark frame shot at noon is not called a night scene. +func TestRuleNightPrefersClockTime(t *testing.T) { + t.Parallel() + + dark := Signals{Brightness: 0.01, Width: 100, Height: 100} + + noon := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + if got := ruleNight(dark, Metadata{TakenAt: &noon}); got != 0 { + t.Errorf("a dark midday frame scored %v for night, want 0", got) + } + + midnight := time.Date(2026, 6, 1, 23, 30, 0, 0, time.UTC) + if got := ruleNight(dark, Metadata{TakenAt: &midnight}); got <= 0 { + t.Errorf("a bright frame shot at 23:30 scored %v for night, want > 0", got) + } +} + +// TestAllBadgesCarrySourceAndBounds checks the invariants every badge must +// satisfy regardless of which rule produced it. +func TestAllBadgesCarrySourceAndBounds(t *testing.T) { + t.Parallel() + + res := analyzePNG(t, banded(skyBlue, grassGreen, sandColor)) + if len(res.Badges) == 0 { + t.Fatal("expected the sample picture to produce badges") + } + for _, b := range res.Badges { + if b.Label == "" { + t.Error("a badge has an empty label") + } + if b.Source != SourceLocal { + t.Errorf("badge %q has source %q, want %q", b.Label, b.Source, SourceLocal) + } + if b.Confidence <= 0 || b.Confidence > 1 { + t.Errorf("badge %q confidence = %v, want a value in (0,1]", b.Label, b.Confidence) + } + if b.Display == "" { + t.Errorf("badge %q has no display name", b.Label) + } + } +} + +// newTestContext returns a background context, kept as a helper so tests that +// build payloads read consistently. +func newTestContext() context.Context { return context.Background() } diff --git a/api/internal/analysis/badges.go b/api/internal/analysis/badges.go new file mode 100644 index 0000000..451b6e5 --- /dev/null +++ b/api/internal/analysis/badges.go @@ -0,0 +1,380 @@ +package analysis + +import ( + "math" + "sort" + "strings" + + "go-places-api/internal/domain" +) + +// SourceLocal identifies badges derived from the picture's own pixels and +// metadata rather than from a trained model. +const SourceLocal = "local" + +// badge is a candidate rule. A rule contributes a badge when its condition +// holds, with a confidence derived from how far past the threshold the signal +// sits. +type badge struct { + Label string + Display string + // Condition returns a confidence in 0..1, or a negative value to reject. + Condition func(s Signals, md Metadata) float64 +} + +// DeriveBadges turns measurements into badges. +// +// These are honest heuristics over colour, brightness, detail and EXIF. They +// recognise scene and subject *categories* such as sky, vegetation or a sunset, +// not arbitrary objects; object-level recognition belongs behind the remote +// analyzer, whose labels are merged in by Combine. +func DeriveBadges(res *Result) []domain.Badge { + if res == nil { + return nil + } + rules := []badge{ + {labelSky, "Sky", ruleSky}, + {labelVegetation, "Vegetation", ruleVegetation}, + {labelNature, "Nature", ruleNature}, + {labelWater, "Water", ruleWater}, + {labelBeach, "Beach", ruleBeach}, + {labelSunset, "Sunset", ruleSunset}, + {labelSnow, "Snow", ruleSnow}, + {labelIndoor, "Indoor", ruleIndoor}, + {labelPortrait, "Portrait", rulePortrait}, + {labelLandscape, "Landscape", ruleLandscape}, + {labelPanorama, "Panorama", rulePanorama}, + {labelLowLight, "Low light", ruleLowLight}, + {labelNight, "Night", ruleNight}, + {labelOverexposed, "Overexposed", ruleOverexposed}, + {labelMonochrome, "Monochrome", ruleMonochrome}, + {labelVivid, "Vivid", ruleVivid}, + {labelCloseUp, "Close-up", ruleCloseUp}, + {labelGraphic, "Graphic", ruleGraphic}, + {labelScreenshot, "Screenshot or document", ruleScreenshot}, + } + + var out []domain.Badge + for _, r := range rules { + conf := r.Condition(res.Signals, res.Metadata) + if conf <= 0 { + continue + } + out = append(out, domain.Badge{ + Label: r.Label, + Display: r.Display, + Confidence: round2(clamp01(conf)), + Source: SourceLocal, + }) + } + return out +} + +const ( + labelSky = "sky" + labelVegetation = "vegetation" + labelNature = "nature" + labelWater = "water" + labelBeach = "beach" + labelSunset = "sunset" + labelSnow = "snow" + labelIndoor = "indoor" + labelPortrait = "portrait" + labelLandscape = "landscape" + labelPanorama = "panorama" + labelLowLight = "low-light" + labelNight = "night" + labelOverexposed = "overexposed" + labelMonochrome = "monochrome" + labelVivid = "vivid" + labelCloseUp = "close-up" + labelGraphic = "graphic" + labelScreenshot = "screenshot" +) + +// Thresholds are collected here so they can be read, and tuned, in one place. +const ( + skyBlueShare = 0.40 + greenShare = 0.25 + waterBlueShare = 0.22 + waterBottomSmooth = 0.06 + beachSandShare = 0.28 + sunsetWarmShare = 0.18 + snowBrightness = 0.82 + snowLowSaturation = 0.12 + lowLightBrightness = 0.20 + nightBrightness = 0.16 + overexposed = 0.88 + monochromeSat = 0.10 + vividSat = 0.42 + closeUpEdges = 0.30 + graphicEdges = 0.38 + graphicSaturation = 0.28 + screenshotEdges = 0.22 + screenshotBright = 0.62 +) + +// scale maps a signal against a threshold into a confidence that grows with +// the margin, saturating at full confidence once the signal is twice the +// threshold. A signal at exactly the threshold yields 0 and is therefore not +// emitted, which keeps thresholds meaning "at least this much". +func scale(value, threshold float64) float64 { + if threshold <= 0 { + return 0 + } + ratio := value / threshold + if ratio <= 1 { + return 0 + } + // Ease out so confidence approaches 1 asymptotically. + t := (ratio - 1) / (ratio + 1) + return 0.5 + 0.5*t*2 +} + +// scaleBelow is the inverse of scale, for signals where a *low* value is the +// positive signal, such as saturation for a monochrome picture. It returns zero +// at or above the threshold and rises as the value falls towards zero. +// +// Thresholding an inverted signal by subtracting it and feeding the result to +// scale would compare a margin against a threshold and silently emit nothing, +// so the two directions are deliberately given separate helpers. +func scaleBelow(value, threshold float64) float64 { + if threshold <= 0 || value >= threshold { + return 0 + } + // ratio runs 0 at the threshold to 1 at zero. + ratio := (threshold - value) / threshold + if ratio <= 0 { + return 0 + } + return 0.4 + 0.5*ratio +} + +func ruleSky(s Signals, _ Metadata) float64 { + // Sky sits in the upper band and is blue with little detail. + blue := scale(s.Top.HueWeights.Blue, skyBlueShare) + smooth := 1 - scale(s.Top.EdgeDensity, 0.10) + return 0.75*blue + 0.25*smooth*blue +} + +func ruleVegetation(s Signals, _ Metadata) float64 { + // Vegetation is green and can appear in any band, weighted towards the + // lower half where ground and trees are. + overall := scale(s.HueWeights.Green, greenShare) + lower := scale(s.Bottom.HueWeights.Green, greenShare) + return 0.5*overall + 0.5*lower +} + +func ruleNature(s Signals, _ Metadata) float64 { + green := s.HueWeights.Green + blue := s.HueWeights.Blue + // Nature means the frame is dominated by vegetation or sky and is not a + // document-like composition. + if s.EdgeDensity > screenshotEdges { + return 0 + } + return 0.6*scale(green, greenShare) + 0.4*scale(blue, skyBlueShare) +} + +func ruleWater(s Signals, _ Metadata) float64 { + // Water is blue in the lower band and has little structure, unlike a sky + // which occupies the upper band. + if s.Top.HueWeights.Blue > s.Bottom.HueWeights.Blue { + return 0 + } + blue := scale(s.Bottom.HueWeights.Blue, waterBlueShare) + smooth := 1 - scale(s.Bottom.EdgeDensity, waterBottomSmooth) + return 0.8*blue + 0.2*smooth*blue +} + +func ruleBeach(s Signals, _ Metadata) float64 { + // A beach is sky above and warm sand below. + sky := scale(s.Top.HueWeights.Blue, skyBlueShare) + sand := scale(s.Bottom.HueWeights.Warm, beachSandShare) + return 0.5 * sky * sand +} + +func ruleSunset(s Signals, _ Metadata) float64 { + warm := scale(s.Middle.HueWeights.Warm, sunsetWarmShare) + // Sunset keeps some light: a pitch-dark frame is night, not sunset. + bright := 1 - scale(1-s.Brightness, 0.25) + return warm * bright +} + +func ruleSnow(s Signals, _ Metadata) float64 { + // Snow is very bright and almost colourless. + bright := scale(s.Brightness, snowBrightness) + flat := 1 - scale(s.Saturation, snowLowSaturation) + return 0.7*bright + 0.3*flat*bright +} + +func ruleIndoor(s Signals, _ Metadata) float64 { + // Indoor shots are shot close with plenty of detail and no sky. + if s.Top.HueWeights.Blue > skyBlueShare/2 { + return 0 + } + detail := scale(s.EdgeDensity, closeUpEdges) + return detail +} + +func rulePortrait(s Signals, _ Metadata) float64 { + return orientationConfidence(s, 0.5, 0.95) +} + +func ruleLandscape(s Signals, _ Metadata) float64 { + return orientationConfidence(s, 1.06, 2.2) +} + +func rulePanorama(s Signals, _ Metadata) float64 { + return orientationConfidence(s, 2.5, 6.0) +} + +// orientationConfidence scores how strongly the aspect ratio sits inside the +// band [min,max]. It returns zero outside the band, one at its centre, and +// falls off towards the edges. +func orientationConfidence(s Signals, min, max float64) float64 { + if s.Width <= 0 || s.Height <= 0 { + return 0 + } + ratio := float64(s.Width) / float64(s.Height) + if ratio < min || ratio > max { + return 0 + } + // The comparison happens in log space so a wide band and a tall band are + // treated symmetrically. + halfSpan := math.Log(max/min) / 2 + if halfSpan <= 0 { + return 0 + } + drift := abs(math.Log(ratio/math.Sqrt(min*max))) / halfSpan + return 1 - clamp01(drift) +} + +// ruleNight prefers the clock time from EXIF and falls back to brightness alone +// when the picture carries no timestamp. +func ruleNight(s Signals, md Metadata) float64 { + if md.TakenAt != nil { + if h := md.TakenAt.UTC().Hour(); h >= 21 || h < 5 { + return 0.9 + } + // Daytime capture means the frame is not a night scene even if it is + // dark, which usually means a window or an interior. + return 0 + } + return scaleBelow(s.Brightness, nightBrightness) +} + +func ruleLowLight(s Signals, _ Metadata) float64 { + return scaleBelow(s.Brightness, lowLightBrightness) +} + +func ruleOverexposed(s Signals, _ Metadata) float64 { + return scale(s.Brightness, overexposed) +} + +func ruleMonochrome(s Signals, _ Metadata) float64 { + return scaleBelow(s.Saturation, monochromeSat) +} + +func ruleVivid(s Signals, _ Metadata) float64 { + return scale(s.Saturation, vividSat) +} + +func ruleCloseUp(s Signals, _ Metadata) float64 { + return scale(s.EdgeDensity, closeUpEdges) +} + +func ruleGraphic(s Signals, _ Metadata) float64 { + // Graphics and illustrations have heavy outlines but muted colour. + edges := scale(s.EdgeDensity, graphicEdges) + muted := 1 - scale(s.Saturation, graphicSaturation) + return 0.6*edges + 0.4*muted*edges +} + +func ruleScreenshot(s Signals, _ Metadata) float64 { + // Screenshots and scans are flat, bright and edge-dense because text + // produces many small high-contrast strokes. + if s.Brightness < screenshotBright { + return 0 + } + edges := scale(s.EdgeDensity, screenshotEdges) + return edges +} + +// Combine merges heuristic badges with model badges. Where both sources report +// the same label the model wins, because a trained detector is more reliable +// than a colour heuristic, but the heuristic confidence is retained as a floor +// so a confident measurement is not discarded. +func Combine(local, model []domain.Badge) []domain.Badge { + byLabel := map[string]domain.Badge{} + order := []string{} + + add := func(b domain.Badge) { + if b.Label == "" { + return + } + if existing, ok := byLabel[b.Label]; ok { + existing.Confidence = maxf(existing.Confidence, b.Confidence) + // A model label is what actually identified the object, so it wins + // the display name and keeps its own source. + if b.Source != SourceLocal { + existing.Display = b.Display + existing.Source = b.Source + } + byLabel[b.Label] = existing + return + } + byLabel[b.Label] = b + order = append(order, b.Label) + } + + for _, b := range model { + add(b) + } + for _, b := range local { + add(b) + } + + out := make([]domain.Badge, 0, len(order)) + for _, label := range order { + b := byLabel[label] + if b.Display == "" { + b.Display = Humanize(label) + } + b.Confidence = round2(clamp01(b.Confidence)) + out = append(out, b) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Confidence != out[j].Confidence { + return out[i].Confidence > out[j].Confidence + } + return out[i].Label < out[j].Label + }) + return out +} + +// Humanize turns a kebab-case label such as "low-light" into "Low light". +func Humanize(label string) string { + parts := strings.FieldsFunc(label, func(r rune) bool { + return r == '-' || r == '_' || r == ' ' + }) + if len(parts) == 0 { + return label + } + for i, p := range parts { + p = strings.ToLower(p) + // Only the first word is capitalised, so "low-light" reads "Low light" + // rather than "Low Light". + if i == 0 && p != "" { + p = strings.ToUpper(p[:1]) + p[1:] + } + parts[i] = p + } + return strings.Join(parts, " ") +} + +func round2(v float64) float64 { return math.Round(v*100) / 100 } + +func abs(v float64) float64 { return math.Abs(v) } + +func maxf(a, b float64) float64 { return math.Max(a, b) } diff --git a/api/internal/analysis/composite.go b/api/internal/analysis/composite.go new file mode 100644 index 0000000..4838946 --- /dev/null +++ b/api/internal/analysis/composite.go @@ -0,0 +1,99 @@ +package analysis + +import ( + "context" + "fmt" + + "go-places-api/internal/domain" +) + +// Composite runs the local analyzer first and then, when a remote service is +// configured, feeds the locally derived scene labels to it as hints. Running +// the local pass first is what makes the hints available, and it also means the +// picture still gets badges if the remote service is unreachable. +type Composite struct { + local Analyzer + remote Analyzer +} + +var _ Analyzer = (*Composite)(nil) + +// NewComposite chains two analyzers. Either may be nil, in which case it is +// skipped. +func NewComposite(local, remote Analyzer) *Composite { + return &Composite{local: local, remote: remote} +} + +// Name implements Analyzer. +func (c *Composite) Name() string { + if c.remote == nil { + return SourceLocal + } + return SourceLocal + "+" + SourceRemote +} + +// Analyze measures locally, then augments with model labels when available. +// +// A failure in the remote service is reported as a non-fatal note rather than +// an error: the picture was still understood, just less precisely. Callers can +// inspect Note to surface the degradation. +func (c *Composite) Analyze(ctx context.Context, in Input) (*Result, error) { + var ( + base *Result + local []domain.Badge + note string + ) + + if c.local != nil { + res, err := c.local.Analyze(ctx, in) + if err != nil { + return nil, err + } + base = res + local = res.Badges + } else { + base = &Result{} + } + + if c.remote != nil { + hinted := in + hinted.Hints = labelsOf(local) + res, err := c.remote.Analyze(ctx, hinted) + switch { + case err != nil: + note = fmt.Sprintf("%s analyzer unavailable: %v", SourceRemote, err) + if ctx.Err() != nil { + return nil, ctx.Err() + } + default: + base.Width, base.Height = pickDimension(base.Width, res.Width), pickDimension(base.Height, res.Height) + base.Badges = Combine(local, res.Badges) + } + } else { + base.Badges = local + } + + base.Note = note + return base, nil +} + +// labelsOf extracts badge labels to use as upstream hints. +func labelsOf(badges []domain.Badge) []string { + if len(badges) == 0 { + return nil + } + out := make([]string, 0, len(badges)) + for _, b := range badges { + if b.Confidence >= 0.5 { + out = append(out, b.Label) + } + } + return out +} + +func pickDimension(current, candidate int) int { + if candidate > 0 { + return candidate + } + return current +} diff --git a/api/internal/analysis/exif.go b/api/internal/analysis/exif.go new file mode 100644 index 0000000..3591b66 --- /dev/null +++ b/api/internal/analysis/exif.go @@ -0,0 +1,384 @@ +package analysis + +import ( + "encoding/binary" + "errors" + "math" + "strings" + "time" +) + +// This file implements a deliberately small EXIF reader. Only the fields the +// application actually uses are decoded: +// +// Make, Model IFD0, tags 0x010F / 0x0110 +// DateTimeOriginal Exif SubIFD, tag 0x9003 +// GPSLatitude / GPSLongitude GPS SubIFD, tags 0x0002 / 0x0004 +// +// EXIF is a TIFF structure embedded in a file, so parsing it means walking an +// image file directory. Every offset read from the data is treated as untrusted +// and bounds-checked, because the input is a user upload. + +// errNoEXIF is returned when the payload carries no EXIF block at all. That is +// the normal case for pictures exported from messaging apps and screenshots. +var errNoEXIF = errors.New("no EXIF block found") + +// exifHeaderLen is the length of the "Exif\x00\x00" identifier that introduces +// the APP1 payload. +const exifHeaderLen = 6 + +// searchWindow bounds how far into the file the EXIF identifier is searched. +// APP1 comes near the front of a JPEG; a small window avoids scanning a +// multi-megabyte payload. +const searchWindow = 128 << 10 + +// TIFF field types from the TIFF 6.0 specification. +const ( + tiffByte = 1 + tiffASCII = 2 + tiffShort = 3 + tiffLong = 4 + tiffRational = 5 + tiffUndefined = 7 +) + +type tiffTag uint16 + +const ( + tagMake tiffTag = 0x010F + tagModel tiffTag = 0x0110 + tagExifIFDPointer tiffTag = 0x8769 + tagGPSIFDPointer tiffTag = 0x8825 + tagDateTimeOriginal tiffTag = 0x9003 + tagGPSLatitudeRef tiffTag = 0x0001 + tagGPSLatitude tiffTag = 0x0002 + tagGPSLongitudeRef tiffTag = 0x0003 + tagGPSLongitude tiffTag = 0x0004 +) + +// exifData holds the decoded fields. The zero value means "not present". +type exifData struct { + Make string + Model string + // TakenAt is the capture time. EXIF records no time zone, so it is + // interpreted as UTC. + TakenAt *time.Time + // Latitude and Longitude are signed decimal degrees. + Latitude float64 + Longitude float64 + HasGPS bool +} + +// readEXIF extracts EXIF from a JPEG or PNG payload. A payload without EXIF +// yields errNoEXIF, which callers treat as a normal outcome. +func readEXIF(data []byte) (exifData, error) { + var out exifData + + block, err := findExifBlock(data) + if err != nil { + return out, err + } + if err := parseTIFF(block, &out); err != nil { + return exifData{}, err + } + return out, nil +} + +// findExifBlock locates the TIFF structure inside the payload. JPEG stores it +// in an APP1 segment and PNG in an "eXIf" chunk; both are preceded by the +// "Exif\x00\x00" identifier in practice, so scanning for that identifier keeps +// the reader format-agnostic. +func findExifBlock(data []byte) ([]byte, error) { + window := data + if len(window) > searchWindow { + window = window[:searchWindow] + } + + const marker = "Exif\x00\x00" + for i := 0; i+exifHeaderLen <= len(window); i++ { + if string(window[i:i+exifHeaderLen]) != marker { + continue + } + start := i + exifHeaderLen + if start >= len(data) { + return nil, errNoEXIF + } + return data[start:], nil + } + return nil, errNoEXIF +} + +// parseTIFF walks the directory structure starting at the beginning of block. +func parseTIFF(block []byte, out *exifData) error { + if len(block) < 8 { + return errors.New("EXIF: TIFF header is truncated") + } + + var order binary.ByteOrder + switch { + case block[0] == 'I' && block[1] == 'I': + order = binary.LittleEndian + case block[0] == 'M' && block[1] == 'M': + order = binary.BigEndian + default: + return errors.New("EXIF: unknown byte order marker") + } + + if magic := order.Uint16(block[2:4]); magic != 42 { + return errors.New("EXIF: bad TIFF magic number") + } + ifd0 := order.Uint32(block[4:8]) + + entries, next, err := readIFD(block, order, ifd0) + if err != nil { + return err + } + for _, e := range entries { + switch e.tag { + case tagMake: + if s, ok := e.asASCII(block, order); ok { + out.Make = s + } + case tagModel: + if s, ok := e.asASCII(block, order); ok { + out.Model = s + } + case tagExifIFDPointer: + if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil { + readExifSubIFD(block, order, sub, out) + } + case tagGPSIFDPointer: + if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil { + readGPSIFD(block, order, sub, out) + } + } + } + _ = next + return nil +} + +// entry is one directory entry. +type entry struct { + tag tiffTag + typ uint16 + count uint32 + // inline is the 4-byte value field as read, used when the payload fits. + inline [4]byte +} + +func readExifSubIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) { + for _, e := range entries { + if e.tag != tagDateTimeOriginal { + continue + } + if s, ok := e.asASCII(block, order); ok { + if t, err := time.Parse(exifTimeLayout, s); err == nil { + utc := t.UTC() + out.TakenAt = &utc + } + } + } +} + +func readGPSIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) { + var ( + lat, lon float64 + latOK, lonOK bool + latRef, lonRef string + ) + + for _, e := range entries { + switch e.tag { + case tagGPSLatitudeRef: + if s, ok := e.asASCII(block, order); ok { + latRef = s + } + case tagGPSLongitudeRef: + if s, ok := e.asASCII(block, order); ok { + lonRef = s + } + case tagGPSLatitude: + if v, ok := e.asRationals(block, order); ok && len(v) >= 3 { + lat, latOK = dmsToDecimal(v[0], v[1], v[2]) + } + case tagGPSLongitude: + if v, ok := e.asRationals(block, order); ok && len(v) >= 3 { + lon, lonOK = dmsToDecimal(v[0], v[1], v[2]) + } + } + } + + if latOK && lonOK { + // Southern latitudes and western longitudes are stored as magnitudes + // with a separate hemisphere reference. + if strings.EqualFold(latRef, "S") { + lat = -lat + } + if strings.EqualFold(lonRef, "W") { + lon = -lon + } + if lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 { + out.Latitude = lat + out.Longitude = lon + out.HasGPS = true + } + } +} + +// readIFD reads the directory at the given offset and returns its entries plus +// the offset of the next directory, which is unused in practice. +func readIFD(block []byte, order binary.ByteOrder, offset uint32) ([]entry, uint32, error) { + // Each entry is 12 bytes and a directory of more than a few hundred + // entries indicates a malformed or hostile file. + const maxEntries = 512 + + if !inRange(block, offset, 2) { + return nil, 0, errors.New("EXIF: directory offset is out of range") + } + count := order.Uint16(block[offset : offset+2]) + if int(count) > maxEntries { + return nil, 0, errors.New("EXIF: implausible directory entry count") + } + + entries := make([]entry, 0, count) + base := int(offset) + 2 + for i := 0; i < int(count); i++ { + at := base + i*12 + if at < 0 || !inRange(block, uint32(at), 12) { + return nil, 0, errors.New("EXIF: directory entry is out of range") + } + rec := block[at : at+12] + e := entry{ + tag: tiffTag(order.Uint16(rec[0:2])), + typ: order.Uint16(rec[2:4]), + count: order.Uint32(rec[4:8]), + } + copy(e.inline[:], rec[8:12]) + entries = append(entries, e) + } + + nextAt := base + int(count)*12 + var next uint32 + if nextAt >= 0 && inRange(block, uint32(nextAt), 4) { + next = order.Uint32(block[nextAt : nextAt+4]) + } + return entries, next, nil +} + +// exifTimeLayout is the non-conforming timestamp format used by EXIF. +const exifTimeLayout = "2006:01:02 15:04:05" + +// typeSize returns the byte width of one element of a TIFF type. +// +// RATIONAL and SRATIONAL are two LONGs, so they are eight bytes rather than +// four. Getting this wrong makes every subsequent offset in the directory wrong, +// which is why it is worth stating explicitly. +func typeSize(typ uint16) (int, bool) { + switch typ { + case tiffByte, tiffASCII, tiffUndefined: + return 1, true + case tiffShort: + return 2, true + case tiffLong: + return 4, true + case tiffRational: + return 8, true + default: + return 0, false + } +} + +// payload locates the entry's value, which is either inline in the 4-byte value +// field or at a pointer stored in that field. +func (e entry) payload(block []byte, order binary.ByteOrder) ([]byte, bool) { + size, ok := typeSize(e.typ) + if !ok { + return nil, false + } + total := uint64(e.count) * uint64(size) + if total == 0 { + return nil, false + } + // More than a few kilobytes of "value" means a malformed file; the fields + // this application reads are all short. + if total > 4096 { + return nil, false + } + + if total <= 4 { + return e.inline[:total], true + } + offset := e.firstValue(order) + if !inRange(block, offset, uint32(total)) { + return nil, false + } + return block[int(offset) : int(offset)+int(total)], true +} + +func (e entry) firstValue(order binary.ByteOrder) uint32 { + return order.Uint32(e.inline[:4]) +} + +func (e entry) asASCII(block []byte, order binary.ByteOrder) (string, bool) { + if e.typ != tiffASCII { + return "", false + } + raw, ok := e.payload(block, order) + if !ok { + return "", false + } + s := string(raw) + if i := strings.IndexByte(s, 0); i >= 0 { + s = s[:i] + } + return strings.TrimSpace(s), true +} + +// asRationals decodes an array of unsigned rationals. Each is a numerator and +// denominator pair, and a zero denominator makes the entry unusable. +func (e entry) asRationals(block []byte, order binary.ByteOrder) ([]float64, bool) { + if e.typ != tiffRational { + return nil, false + } + raw, ok := e.payload(block, order) + if !ok || len(raw)%8 != 0 { + return nil, false + } + n := len(raw) / 8 + out := make([]float64, 0, n) + for i := 0; i < n; i++ { + num := order.Uint32(raw[i*8 : i*8+4]) + den := order.Uint32(raw[i*8+4 : i*8+8]) + if den == 0 { + return nil, false + } + out = append(out, float64(num)/float64(den)) + } + return out, true +} + +// dmsToDecimal converts EXIF's degrees/minutes/seconds triple to decimal +// degrees. It reports false for out-of-range or non-finite values. +func dmsToDecimal(degrees, minutes, seconds float64) (float64, bool) { + if math.IsNaN(degrees) || math.IsNaN(minutes) || math.IsNaN(seconds) { + return 0, false + } + if degrees < 0 || degrees > 180 { + return 0, false + } + if minutes < 0 || minutes >= 60 { + return 0, false + } + if seconds < 0 || seconds >= 60 { + return 0, false + } + v := degrees + minutes/60 + seconds/3600 + return v, true +} + +func inRange(block []byte, offset, length uint32) bool { + end := uint64(offset) + uint64(length) + return end <= uint64(len(block)) +} + +func math_isNaN(v float64) bool { return v != v } diff --git a/api/internal/analysis/exif_test.go b/api/internal/analysis/exif_test.go new file mode 100644 index 0000000..f6eb314 --- /dev/null +++ b/api/internal/analysis/exif_test.go @@ -0,0 +1,423 @@ +package analysis + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +// The tests build real TIFF/EXIF structures byte by byte and check the reader +// recovers the values, including for inputs a hostile client could supply. + +// buildTIFF assembles a TIFF block from the given directories. The layout is +// header, IFD0, Exif SubIFD, GPS SubIFD, then a value area holding any entry +// payload larger than the four inline bytes. +// +// Pointer entries for the two sub-directories are filled in automatically, so a +// caller only has to list the tags it cares about. +func buildTIFF(t *testing.T, order binary.ByteOrder, ifd0, exifSub, gpsSub []tiffEntry) []byte { + t.Helper() + + const headerLen = 8 + // IFD0 is always emitted, even when empty, because readIFD reads the + // next-directory pointer that follows the entries. + ifdLen := func(n int) int { return 2 + n*12 + 4 } + areaLen := func(entries []tiffEntry) int { + total := 0 + for _, e := range entries { + if len(e.payload) > 4 { + total += len(e.payload) + } + } + return total + } + + ifd0Offset := headerLen + // Sub-directories are only emitted when the caller supplies entries, but + // IFD0 always is, so it always occupies space. + exifOffset := ifd0Offset + ifdLen(len(ifd0)) + gpsOffset := exifOffset + if len(exifSub) > 0 { + gpsOffset += ifdLen(len(exifSub)) + } + dataOffset := gpsOffset + if len(gpsSub) > 0 { + dataOffset += ifdLen(len(gpsSub)) + } + + out := make([]byte, dataOffset+areaLen(ifd0)+areaLen(exifSub)+areaLen(gpsSub)) + + if order == binary.LittleEndian { + copy(out[0:2], "II") + } else { + copy(out[0:2], "MM") + } + order.PutUint16(out[2:4], 42) + order.PutUint32(out[4:8], uint32(ifd0Offset)) + + // TIFF records the element count, not the byte count, so a 24-byte + // rational triple is count 3. + elementCount := func(typ uint16, payload []byte) uint32 { + var size uint32 = 1 + switch typ { + case tiffShort: + size = 2 + case tiffLong: + size = 4 + case tiffRational: + size = 8 // two LONGs + } + return uint32(len(payload)) / size + } + + areaCursor := dataOffset + writeIFD := func(entries []tiffEntry, offset uint32) { + order.PutUint16(out[offset:offset+2], uint16(len(entries))) + at := offset + 2 + for _, e := range entries { + order.PutUint16(out[at:at+2], uint16(e.tag)) + order.PutUint16(out[at+2:at+4], e.typ) + order.PutUint32(out[at+4:at+8], elementCount(e.typ, e.payload)) + + // Sub-IFD pointers are always four inline bytes and are resolved + // from the layout rather than supplied by the caller. + switch e.tag { + case tagExifIFDPointer: + order.PutUint32(out[at+8:at+12], uint32(exifOffset)) + case tagGPSIFDPointer: + order.PutUint32(out[at+8:at+12], uint32(gpsOffset)) + default: + if len(e.payload) <= 4 { + copy(out[at+8:at+12], e.payload) + } else { + order.PutUint32(out[at+8:at+12], uint32(areaCursor)) + copy(out[areaCursor:areaCursor+len(e.payload)], e.payload) + areaCursor += len(e.payload) + } + } + at += 12 + } + // No second directory follows. + } + + writeIFD(ifd0, uint32(ifd0Offset)) + if len(exifSub) > 0 { + writeIFD(exifSub, uint32(exifOffset)) + } + if len(gpsSub) > 0 { + writeIFD(gpsSub, uint32(gpsOffset)) + } + return out +} + +type tiffEntry struct { + tag tiffTag + typ uint16 + payload []byte +} + +func asciiEntry(tag tiffTag, value string) tiffEntry { + // EXIF ASCII values are NUL-terminated. + return tiffEntry{tag: tag, typ: tiffASCII, payload: append([]byte(value), 0)} +} + +func rationalEntry(order binary.ByteOrder, tag tiffTag, values ...[2]uint32) tiffEntry { + payload := make([]byte, 0, len(values)*8) + for _, v := range values { + var b [8]byte + order.PutUint32(b[0:4], v[0]) + order.PutUint32(b[4:8], v[1]) + payload = append(payload, b[:]...) + } + return tiffEntry{tag: tag, typ: tiffRational, payload: payload} +} + +// jpegWithExif splices an APP1 segment carrying the TIFF block into a valid +// JPEG, which is how EXIF reaches a real JPEG file. The segment has to go +// immediately after the start-of-image marker, so the image is encoded first and +// the segment inserted into it. +func jpegWithExif(t *testing.T, tiffBlock []byte) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255}) + } + } + var enc bytes.Buffer + if err := jpeg.Encode(&enc, img, nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + encoded := enc.Bytes() + + // APP1: marker, length, "Exif\0\0", then the TIFF block. + app1Payload := append([]byte("Exif\x00\x00"), tiffBlock...) + segment := make([]byte, 0, 4+len(app1Payload)) + segment = append(segment, 0xFF, 0xE1) + lenBuf := make([]byte, 2) + binary.BigEndian.PutUint16(lenBuf, uint16(len(app1Payload)+2)) + segment = append(segment, lenBuf...) + segment = append(segment, app1Payload...) + + out := make([]byte, 0, len(encoded)+len(segment)) + out = append(out, encoded[:2]...) // SOI + out = append(out, segment...) + out = append(out, encoded[2:]...) + return out +} + +// pngWithExifChunk writes a PNG carrying an eXIf chunk, the other common +// container for EXIF. +func pngWithExifChunk(t *testing.T, tiffBlock []byte) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + out := buf.Bytes() + + // Splice an eXIf chunk in right after the 8-byte PNG signature. + const sigLen = 8 + var chunk bytes.Buffer + chunk.WriteString("eXIf") + b4 := make([]byte, 4) + binary.BigEndian.PutUint32(b4, uint32(len(tiffBlock))) + chunk.Write(b4) + chunk.Write(tiffBlock) + binary.BigEndian.PutUint32(b4, crc32IEEE(chunk.Bytes())) + chunk.Write(b4) + + return append(append(append([]byte{}, out[:sigLen]...), chunk.Bytes()...), out[sigLen:]...) +} + +// crc32IEEE computes the PNG chunk CRC. +func crc32IEEE(data []byte) uint32 { + var table [256]uint32 + for i := range table { + c := uint32(i) + for k := 0; k < 8; k++ { + if c&1 != 0 { + c = 0xedb88320 ^ (c >> 1) + } else { + c >>= 1 + } + } + table[i] = c + } + crc := uint32(0xffffffff) + for _, b := range data { + crc = table[(crc^uint32(b))&0xff] ^ (crc >> 8) + } + return crc ^ 0xffffffff +} + +func TestReadEXIFExtractsMetadata(t *testing.T) { + t.Parallel() + + for _, order := range []binary.ByteOrder{binary.LittleEndian, binary.BigEndian} { + name := "big-endian" + if order == binary.LittleEndian { + name = "little-endian" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + + block := buildTIFF(t, order, + []tiffEntry{ + asciiEntry(tagMake, "Canon"), + asciiEntry(tagModel, "EOS R6"), + // Empty payloads: buildTIFF fills the offsets in. + {tag: tagExifIFDPointer, typ: tiffLong}, + {tag: tagGPSIFDPointer, typ: tiffLong}, + }, + []tiffEntry{ + asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45"), + }, + []tiffEntry{ + asciiEntry(tagGPSLatitudeRef, "N"), + // 48 deg 51' 23.76" N + rationalEntry(order, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}), + asciiEntry(tagGPSLongitudeRef, "E"), + // 2 deg 17' 39.6" E + rationalEntry(order, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}), + }, + ) + got, err := readEXIF(withExifHeader(block)) + if err != nil { + t.Fatalf("readEXIF: %v", err) + } + + if got.Make != "Canon" { + t.Errorf("Make = %q, want %q", got.Make, "Canon") + } + if got.Model != "EOS R6" { + t.Errorf("Model = %q, want %q", got.Model, "EOS R6") + } + if got.TakenAt == nil { + t.Fatal("TakenAt was not decoded") + } + if got.TakenAt.Format("2006:01:02 15:04:05") != "2026:06:01 14:23:45" { + t.Errorf("TakenAt = %v, want 2026:06:01 14:23:45", got.TakenAt) + } + if !got.HasGPS { + t.Fatal("HasGPS = false, want true") + } + // 48 + 51/60 + 23.76/3600 = 48.8566 + if diff := got.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 { + t.Errorf("Latitude = %v, want ~48.8566", got.Latitude) + } + // 2 + 17/60 + 39.6/3600 = 2.29433 + if diff := got.Longitude - 2.29433; diff > 1e-3 || diff < -1e-3 { + t.Errorf("Longitude = %v, want ~2.29433", got.Longitude) + } + }) + } +} + +func TestReadEXIFHandlesSouthAndWest(t *testing.T) { + t.Parallel() + + block := buildTIFF(t, binary.LittleEndian, + []tiffEntry{ + {tag: tagExifIFDPointer, typ: tiffLong}, + {tag: tagGPSIFDPointer, typ: tiffLong}, + }, + nil, + []tiffEntry{ + asciiEntry(tagGPSLatitudeRef, "S"), + rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{33, 1}, [2]uint32{52, 1}, [2]uint32{0, 1}), + asciiEntry(tagGPSLongitudeRef, "W"), + rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{151, 1}, [2]uint32{12, 1}, [2]uint32{0, 1}), + }, + ) + got, err := readEXIF(withExifHeader(block)) + if err != nil { + t.Fatalf("readEXIF: %v", err) + } + if got.Latitude >= 0 { + t.Errorf("Latitude = %v, want a negative value for a south ref", got.Latitude) + } + if got.Longitude >= 0 { + t.Errorf("Longitude = %v, want a negative value for a west ref", got.Longitude) + } +} + +func TestReadEXIFWithoutMetadata(t *testing.T) { + t.Parallel() + + block := buildTIFF(t, binary.LittleEndian, nil, nil, nil) + got, err := readEXIF(withExifHeader(block)) + if err != nil { + t.Fatalf("readEXIF: %v", err) + } + if got.Make != "" || got.Model != "" || got.TakenAt != nil || got.HasGPS { + t.Errorf("expected an empty result, got %+v", got) + } +} + +func TestReadEXIFRejectsMalformed(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + data []byte + }{ + {"not exif at all", []byte("this is a text file, not a picture")}, + {"truncated header", []byte("Exif\x00\x00II")}, + {"unknown byte order", append([]byte("Exif\x00\x00"), []byte("XY\x00\x2a\x08\x00\x00\x00")...)}, + {"bad magic", append([]byte("Exif\x00\x00"), []byte("II\x00\x00\x08\x00\x00\x00")...)}, + // A directory offset pointing past the end of the block. + {"offset out of range", append([]byte("Exif\x00\x00"), []byte("II\x2a\x00\x08\x00\x00\xff\xff\xff\xff")...)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if _, err := readEXIF(tc.data); err == nil { + t.Error("expected an error, got nil") + } + }) + } +} + +// TestFindExifBlockIgnoresAbsentMetadata confirms a payload with no EXIF is +// reported as such rather than as a parse failure. +func TestFindExifBlockIgnoresAbsentMetadata(t *testing.T) { + t.Parallel() + + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + if _, err := readEXIF(buf.Bytes()); err == nil { + t.Error("expected an error for a PNG with no EXIF") + } +} + +// TestReadEXIFRejectsAbsurdEntryCount guards the bound on directory size: a +// hostile file claiming a huge entry count must not cause a huge allocation. +func TestReadEXIFRejectsAbsurdEntryCount(t *testing.T) { + t.Parallel() + + block := append([]byte("Exif\x00\x00"), + 0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, + 0xff, 0xff, // 65535 entries + ) + if _, err := readEXIF(block); err == nil { + t.Error("expected an error for an implausible entry count") + } +} + +// TestAnalyzeUsesEXIFGPS verifies the metadata reaches the analysis result, +// which is what lets an upload be located without the client sending a +// coordinate. +func TestAnalyzeUsesEXIFGPS(t *testing.T) { + t.Parallel() + + block := buildTIFF(t, binary.LittleEndian, + []tiffEntry{ + {tag: tagExifIFDPointer, typ: tiffLong}, + {tag: tagGPSIFDPointer, typ: tiffLong}, + }, + []tiffEntry{asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45")}, + []tiffEntry{ + asciiEntry(tagGPSLatitudeRef, "N"), + rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}), + asciiEntry(tagGPSLongitudeRef, "E"), + rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}), + }, + ) + data := jpegWithExif(t, block) + a := NewLocal(DefaultMaxDimension) + res, err := a.Analyze(newTestContext(), Input{ContentType: "image/jpeg", Bytes: data}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if res.Metadata.Coordinate == nil { + t.Fatal("expected the GPS coordinate to be recovered from EXIF") + } + if diff := res.Metadata.Coordinate.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 { + t.Errorf("latitude = %v, want ~48.8566", res.Metadata.Coordinate.Latitude) + } + if res.Metadata.TakenAt == nil { + t.Error("expected the capture timestamp to be recovered from EXIF") + } +} + +// withExifHeader prefixes a raw TIFF block with the identifier that introduces +// an APP1 payload, which is what readEXIF scans for. +func withExifHeader(tiffBlock []byte) []byte { + return append([]byte("Exif\x00\x00"), tiffBlock...) +} diff --git a/api/internal/analysis/local.go b/api/internal/analysis/local.go new file mode 100644 index 0000000..080d9cb --- /dev/null +++ b/api/internal/analysis/local.go @@ -0,0 +1,427 @@ +package analysis + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "math" + + // Registering the decoders is what makes image.Decode understand the + // formats named in the configured allow-list. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "golang.org/x/image/draw" + + "go-places-api/internal/domain" +) + +// DefaultMaxDimension is the longest edge sampled when Input does not set one. +// 256px keeps the work per picture small while preserving the broad colour and +// edge statistics the badge rules rely on. +const DefaultMaxDimension = 256 + +// LocalAnalyzer measures a picture's own pixels and metadata. It requires no +// network access and always produces the same badges for the same bytes. +type LocalAnalyzer struct { + maxDimension int +} + +var _ Analyzer = (*LocalAnalyzer)(nil) + +// NewLocal returns a LocalAnalyzer sampling down to maxDimension on the longest +// edge. Values below 32 are raised to 32 so the statistics stay meaningful. +func NewLocal(maxDimension int) *LocalAnalyzer { + if maxDimension < 32 { + maxDimension = 32 + } + return &LocalAnalyzer{maxDimension: maxDimension} +} + +// Name implements Analyzer. +func (l *LocalAnalyzer) Name() string { return "local" } + +// Analyze measures the picture and derives badges from the measurements. +func (l *LocalAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) { + if len(in.Bytes) == 0 { + return nil, errors.New("analysis: empty image payload") + } + if err := ctx.Err(); err != nil { + return nil, err + } + + // DecodeConfig reads only the header, giving true dimensions cheaply and + // letting an unsupported format be rejected before the pixel work starts. + cfg, _, err := image.DecodeConfig(bytes.NewReader(in.Bytes)) + if err != nil { + return nil, fmt.Errorf("analysis: read image header: %w", err) + } + if cfg.Width <= 0 || cfg.Height <= 0 { + return nil, fmt.Errorf("analysis: invalid image dimensions %dx%d", cfg.Width, cfg.Height) + } + + sample, err := sample(in.Bytes, l.maxDimension) + if err != nil { + return nil, err + } + + res := &Result{ + Width: cfg.Width, + Height: cfg.Height, + Signals: measure(sample), + Metadata: readMetadata(in.Bytes), + } + res.Badges = DeriveBadges(res) + return res, nil +} + +// sample decodes the picture and downscales it so the longest edge is at most +// maxDimension. Decoders in the standard library always materialise the full +// image, so the payload size limit enforced by the upload handler is what keeps +// this bounded. +func sample(data []byte, maxDimension int) (image.Image, error) { + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("analysis: decode image: %w", err) + } + + b := src.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxDimension && h <= maxDimension { + return src, nil + } + + // Preserve aspect ratio and never upscale. + scale := float64(maxDimension) / float64(max(w, h)) + dstW := int(math.Round(float64(w) * scale)) + dstH := int(math.Round(float64(h) * scale)) + if dstW < 1 { + dstW = 1 + } + if dstH < 1 { + dstH = 1 + } + + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, b, draw.Over, nil) + return dst, nil +} + +// measure converts the sampled pixels into the normalised signals used by the +// badge rules. +func measure(img image.Image) Signals { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w == 0 || h == 0 { + return Signals{} + } + + s := Signals{Width: w, Height: h} + + // Accumulate per-band so sky and ground can be told apart. + bandAcc := [3]*bandAccumulator{newBandAccumulator(), newBandAccumulator(), newBandAccumulator()} + hueAcc := newHueAccumulator() + rgbAcc := newRGBAccumulator() + + // The sampled picture is at most maxDimension on its longest edge, so a + // single byte per pixel is a small amount of memory and lets the edge pass + // below see a proper 3x3 neighbourhood. + lumas := make([]luma, w*h) + brightnessSum := 0.0 + saturationSum := 0.0 + + for y := 0; y < h; y++ { + rowBase := y * w + for x := 0; x < w; x++ { + c := colorAt(img, b.Min.X+x, b.Min.Y+y) + lu := lumaOf(c) + + lumas[rowBase+x] = lu + brightnessSum += float64(lu) / 255 + saturationSum += saturationOf(c) + + rgbAcc.add(c) + hueAcc.add(c) + + // Band index 0 top, 1 middle, 2 bottom. + bi := (y * 3) / h + bandAcc[bi].add(c, lu) + } + } + + totalPixels := w * h + inv := 1 / float64(totalPixels) + s.Brightness = brightnessSum * inv + s.Saturation = saturationSum * inv + s.EdgeDensity = float64(countEdges(lumas, w, h)) * inv + s.HueWeights = hueAcc.weights(totalPixels) + s.Colorfulness = rgbAcc.colorfulness() + + for i, acc := range bandAcc { + switch i { + case 0: + s.Top = acc.finish(totalPixels) + case 1: + s.Middle = acc.finish(totalPixels) + case 2: + s.Bottom = acc.finish(totalPixels) + } + } + return s +} + +// countEdges applies a Sobel operator at every pixel with a full 3x3 +// neighbourhood, returning how many exceed edgeThreshold. Border pixels are +// skipped because their neighbourhood would fall outside the image. +func countEdges(lumas []luma, w, h int) int { + if w < 3 || h < 3 { + return 0 + } + edges := 0 + for y := 1; y < h-1; y++ { + top := (y - 1) * w + mid := y * w + bot := (y + 1) * w + for x := 1; x < w-1; x++ { + if isEdge( + lumas[top+x-1], lumas[top+x], lumas[top+x+1], + lumas[mid+x-1], lumas[mid+x], lumas[mid+x+1], + lumas[bot+x-1], lumas[bot+x], lumas[bot+x+1], + ) { + edges++ + } + } + } + return edges +} + +// edgeThreshold is the normalised gradient magnitude a pixel must exceed to +// count as an edge. +const edgeThreshold = 0.12 + +// isEdge applies a Sobel operator to the 3x3 neighbourhood around the centre +// pixel and reports whether the gradient magnitude exceeds edgeThreshold. +func isEdge(tl, tc, tr, ml, mc, mr, bl, bc, br luma) bool { + gx := -float64(tl) + float64(tr) + gx += -2*float64(ml) + 2*float64(mr) + gx += -float64(bl) + float64(br) + + gy := -float64(tl) - 2*float64(tc) - float64(tr) + gy += float64(bl) + 2*float64(bc) + float64(br) + + gx /= 4 * 255 + gy /= 4 * 255 + return gx*gx+gy*gy > edgeThreshold*edgeThreshold +} + +type luma uint8 + +func lumaOf(c rgb) luma { + // Rec. 601 luma, which tracks perceived brightness closely enough here. + r, g, b := c.r, c.g, c.b + return luma((299*int(r) + 587*int(g) + 114*int(b)) / 1000) +} + +// rgb is a plain 8-bit RGB triple, kept private so the sampling loop avoids +// repeated interface method calls. +type rgb struct{ r, g, b uint8 } + +func colorAt(img image.Image, x, y int) rgb { + r, g, b, _ := img.At(x, y).RGBA() + // RGBA returns alpha-premultiplied 16-bit values. + return rgb{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)} +} + +// saturationOf returns the HSV saturation of a pixel in 0..1. +func saturationOf(c rgb) float64 { + maxV := max(c.r, max(c.g, c.b)) + minV := min(c.r, min(c.g, c.b)) + if maxV == 0 { + return 0 + } + return float64(maxV-minV) / float64(maxV) +} + +// hueClass buckets a pixel by hue, treating low-saturation pixels as +// achromatic since their hue is not meaningful. +func hueClass(c rgb) (blue, green, warm, purple, achromatic bool) { + const achromaticSat = 0.20 + + hsvSat := saturationOf(c) + if hsvSat < achromaticSat { + return false, false, false, false, true + } + + maxV := float64(max(c.r, max(c.g, c.b))) + minV := float64(min(c.r, min(c.g, c.b))) + delta := maxV - minV + if delta == 0 { + return false, false, false, false, true + } + + var hue float64 + switch maxV { + case float64(c.r): + hue = math.Mod(60*(float64(c.g)-float64(c.b))/delta+360, 360) + case float64(c.g): + hue = 60*(float64(c.b)-float64(c.r))/delta + 120 + default: + hue = 60*(float64(c.r)-float64(c.g))/delta + 240 + } + + // Boundaries are the conventional hue ranges: blue 180-260, green 60-170, + // purple 260-320, warm everything else including red and yellow. + switch { + case hue >= 180 && hue < 260: + blue = true + case hue >= 60 && hue < 170: + green = true + case hue >= 260 && hue < 320: + purple = true + default: + warm = true + } + return +} + +type hueAccumulator struct { + n int + w HueWeights +} + +func newHueAccumulator() *hueAccumulator { return &hueAccumulator{} } + +func (a *hueAccumulator) add(c rgb) { + a.n++ + blue, green, warm, purple, achromatic := hueClass(c) + if blue { + a.w.Blue++ + } + if green { + a.w.Green++ + } + if warm { + a.w.Warm++ + } + if purple { + a.w.Purple++ + } + if achromatic { + a.w.Achromatic++ + } +} + +func (a *hueAccumulator) weights(total int) HueWeights { + if total == 0 { + return HueWeights{} + } + inv := 1 / float64(total) + return HueWeights{ + Blue: float64(a.w.Blue) * inv, + Green: float64(a.w.Green) * inv, + Warm: float64(a.w.Warm) * inv, + Purple: float64(a.w.Purple) * inv, + Achromatic: float64(a.w.Achromatic) * inv, + } +} + +type bandAccumulator struct { + pixels int + lumaSum uint64 + satSum float64 + hue hueAccumulator +} + +func newBandAccumulator() *bandAccumulator { + return &bandAccumulator{hue: *newHueAccumulator()} +} + +func (a *bandAccumulator) add(c rgb, lu luma) { + a.pixels++ + a.lumaSum += uint64(lu) + a.satSum += saturationOf(c) + a.hue.add(c) +} + +func (a *bandAccumulator) finish(totalPixels int) Band { + if a.pixels == 0 { + return Band{} + } + return Band{ + Brightness: float64(a.lumaSum) / float64(a.pixels) / 255, + Saturation: a.satSum / float64(a.pixels), + HueWeights: a.hue.weights(a.pixels), + PixelShare: float64(a.pixels) / float64(totalPixels), + PixelCount: a.pixels, + } +} + +// rgbAccumulator tracks per-channel means and spreads for the colorfulness +// measure of Hasler and Suesstrunk, a standard perceptual metric. +type rgbAccumulator struct { + rgSum, rgSumSq float64 + ybSum, ybSumSq float64 + n int +} + +func newRGBAccumulator() *rgbAccumulator { return &rgbAccumulator{} } + +func (a *rgbAccumulator) add(c rgb) { + rg := float64(c.r) - float64(c.g) + yb := 0.5*(float64(c.r)+float64(c.g)) - float64(c.b) + + a.n++ + a.rgSum += rg + a.rgSumSq += rg * rg + a.ybSum += yb + a.ybSumSq += yb * yb +} + +func (a *rgbAccumulator) colorfulness() float64 { + if a.n == 0 { + return 0 + } + n := float64(a.n) + rgMean, rgStd := a.rgSum/n, stddev(a.rgSumSq/n, (a.rgSum/n)*(a.rgSum/n)) + ybMean, ybStd := a.ybSum/n, stddev(a.ybSumSq/n, (a.ybSum/n)*(a.ybSum/n)) + + magnitude := math.Hypot(rgMean, ybMean) + spread := math.Hypot(rgStd, ybStd) + // Normalised against a saturation and contrast of 0.3, which is the point + // where the metric is conventionally considered "highly colourful". + return clamp01((magnitude + spread) / 105) +} + +func stddev(meanOfSquares, squareOfMean float64) float64 { + v := meanOfSquares - squareOfMean + if v <= 0 { + return 0 + } + return math.Sqrt(v) +} + +func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) } + +// readMetadata extracts what EXIF knows about the capture. A picture without +// usable EXIF is normal, not an error. +func readMetadata(data []byte) Metadata { + var md Metadata + + parsed, err := readEXIF(data) + if err != nil { + return md + } + md.Make = parsed.Make + md.Model = parsed.Model + md.TakenAt = parsed.TakenAt + if parsed.HasGPS { + c := domain.Coordinate{Latitude: parsed.Latitude, Longitude: parsed.Longitude} + if c.Valid() { + md.Coordinate = &c + } + } + return md +} diff --git a/api/internal/analysis/remote.go b/api/internal/analysis/remote.go new file mode 100644 index 0000000..29abfa7 --- /dev/null +++ b/api/internal/analysis/remote.go @@ -0,0 +1,139 @@ +package analysis + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "go-places-api/internal/domain" +) + +// SourceRemote identifies badges produced by an external inference service. +const SourceRemote = "remote" + +// RemoteAnalyzer forwards a picture to an external inference service that runs +// a trained object or scene classifier, and turns the labels it returns into +// badges. This is the path that recognises specific objects; the local analyzer +// only produces scene-level categories. +type RemoteAnalyzer struct { + baseURL string + apiKey string + client *http.Client + // maxBytes bounds the payload sent upstream so a large upload cannot be + // forwarded without limit. + maxBytes int64 +} + +var _ Analyzer = (*RemoteAnalyzer)(nil) + +// NewRemote returns a RemoteAnalyzer calling baseURL. The timeout bounds the +// whole request including reading the response. +func NewRemote(baseURL, apiKey string, timeout time.Duration) *RemoteAnalyzer { + if timeout <= 0 { + timeout = 10 * time.Second + } + return &RemoteAnalyzer{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + client: &http.Client{Timeout: timeout}, + maxBytes: 12 << 20, + } +} + +// remoteRequest is the payload sent to the inference service. +type remoteRequest struct { + ContentType string `json:"contentType"` + ImageBase64 string `json:"imageBase64"` + // Hint carries locally derived scene badges so the service can disambiguate + // using the signals we already computed. + Hint []string `json:"hint,omitempty"` +} + +// remoteResponse is the expected reply. Width and height are optional because +// the service may not report them. +type remoteResponse struct { + Width int `json:"width"` + Height int `json:"height"` + Labels []struct { + Label string `json:"label"` + Display string `json:"display"` + Confidence float64 `json:"confidence"` + } `json:"labels"` +} + +// Analyze sends the picture upstream and converts the reply into badges. +func (r *RemoteAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) { + if len(in.Bytes) == 0 { + return nil, fmt.Errorf("analysis: empty image payload") + } + if int64(len(in.Bytes)) > r.maxBytes { + return nil, fmt.Errorf("analysis: payload of %d bytes exceeds remote limit of %d", + len(in.Bytes), r.maxBytes) + } + + body, err := json.Marshal(remoteRequest{ + ContentType: in.ContentType, + ImageBase64: base64.StdEncoding.EncodeToString(in.Bytes), + }) + if err != nil { + return nil, fmt.Errorf("analysis: encode request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/v1/analyze", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("analysis: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if r.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+r.apiKey) + } + + resp, err := r.client.Do(req) + if err != nil { + return nil, fmt.Errorf("analysis: call inference service: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Read a bounded amount of the error body to aid debugging without + // letting an error response stream unbounded data. + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("analysis: inference service returned %s: %s", + resp.Status, strings.TrimSpace(string(snippet))) + } + + // Bound the response so a misbehaving service cannot exhaust memory. + var decoded remoteResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&decoded); err != nil { + return nil, fmt.Errorf("analysis: decode inference response: %w", err) + } + + res := &Result{Width: decoded.Width, Height: decoded.Height} + for _, l := range decoded.Labels { + label := strings.TrimSpace(strings.ToLower(l.Label)) + if label == "" { + continue + } + display := strings.TrimSpace(l.Display) + if display == "" { + display = Humanize(label) + } + res.Badges = append(res.Badges, domain.Badge{ + Label: label, + Display: display, + Confidence: round2(clamp01(l.Confidence)), + Source: SourceRemote, + }) + } + return res, nil +} + +// Name implements Analyzer. +func (r *RemoteAnalyzer) Name() string { return SourceRemote } diff --git a/api/internal/auth/auth.go b/api/internal/auth/auth.go new file mode 100644 index 0000000..9d1aa97 --- /dev/null +++ b/api/internal/auth/auth.go @@ -0,0 +1,200 @@ +// Package auth validates the bearer tokens issued by the go-loose SSO service +// and identifies the caller. +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/golang-jwt/jwt/v5" + + "go-places-api/internal/domain" +) + +// Errors returned by verifiers. They are deliberately coarse: a caller learns +// that a token is unacceptable, not why, so probing cannot distinguish an +// expired token from a forged one. +var ( + // ErrMissingToken means no credential was presented. + ErrMissingToken = errors.New("auth: no token presented") + // ErrInvalidToken means the token failed verification for any reason. + ErrInvalidToken = errors.New("auth: invalid token") +) + +// Claims are the fields the application reads from a go-loose token. +type Claims struct { + jwt.RegisteredClaims + // Username and Email are copied from the identity provider. They are + // optional because a token may only assert the subject. + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` +} + +// User is the identity resolved from a token. +type User struct { + domain.User +} + +// Verifier turns a bearer token into an identity. +type Verifier interface { + // Verify returns the identity the token asserts. It returns + // ErrMissingToken or ErrInvalidToken for unusable credentials. + Verify(ctx context.Context, token string) (User, error) + // Name identifies the verification strategy, for logging. + Name() string +} + +// JWTVerifier verifies HS256-signed tokens issued by go-loose. +// +// Only HS256 is accepted. The algorithm is pinned rather than read from the +// token header so that a token cannot select "none" or an asymmetric algorithm +// and slip past verification. +type JWTVerifier struct { + secret []byte + audience string + issuer string +} + +var _ Verifier = (*JWTVerifier)(nil) + +// NewJWTVerifier returns a verifier for tokens signed with secret. audience may +// be empty to skip the audience check; issuer likewise. +func NewJWTVerifier(secret, audience, issuer string) *JWTVerifier { + return &JWTVerifier{secret: []byte(secret), audience: audience, issuer: issuer} +} + +// Name implements Verifier. +func (v *JWTVerifier) Name() string { return "jwt" } + +// Verify parses and validates the token, then extracts the identity. +func (v *JWTVerifier) Verify(ctx context.Context, token string) (User, error) { + if err := ctx.Err(); err != nil { + return User{}, err + } + if token == "" { + return User{}, ErrMissingToken + } + if len(v.secret) == 0 { + return User{}, fmt.Errorf("%w: no signing secret configured", ErrInvalidToken) + } + + opts := []jwt.ParserOption{ + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithExpirationRequired(), + } + if v.audience != "" { + opts = append(opts, jwt.WithAudience(v.audience)) + } + if v.issuer != "" { + opts = append(opts, jwt.WithIssuer(v.issuer)) + } + + var claims Claims + parsed, err := jwt.ParseWithClaims(token, &claims, keyFunc(v.secret), opts...) + if err != nil { + // The underlying reason stays unexported so the caller cannot use the + // error text to learn why a token was rejected. + return User{}, ErrInvalidToken + } + if !parsed.Valid { + return User{}, ErrInvalidToken + } + if claims.Subject == "" { + // Without a subject there is nobody to attribute the request to. + return User{}, ErrInvalidToken + } + + return User{domain.User{ + ID: claims.Subject, + Username: firstNonEmpty(claims.Username, claims.Subject), + Email: claims.Email, + }}, nil +} + +// keyFunc supplies the verification key. The token's own "alg" header is +// deliberately ignored because the parser is already pinned to HS256. +func keyFunc(secret []byte) jwt.Keyfunc { + return func(t *jwt.Token) (any, error) { + return secret, nil + } +} + +// DevVerifier accepts a small set of fixed tokens for local development. It is +// only wired up when AUTH_MODE=dev, and refuses to start in production. +type DevVerifier struct { + tokens map[string]domain.User +} + +var _ Verifier = (*DevVerifier)(nil) + +// DefaultDevUser is the identity assumed by the "valid_token" dev token. +var DefaultDevUser = domain.User{ + ID: "dev-user", + Username: "dev", + Email: "dev@localhost", +} + +// NewDevVerifier returns a verifier accepting the given token-to-user mapping. +func NewDevVerifier(tokens map[string]domain.User) *DevVerifier { + if tokens == nil { + tokens = map[string]domain.User{"valid_token": DefaultDevUser} + } + return &DevVerifier{tokens: tokens} +} + +// Name implements Verifier. +func (v *DevVerifier) Name() string { return "dev" } + +// Verify looks the token up in the fixed table. +func (v *DevVerifier) Verify(ctx context.Context, token string) (User, error) { + if err := ctx.Err(); err != nil { + return User{}, err + } + if token == "" { + return User{}, ErrMissingToken + } + u, ok := v.tokens[token] + if !ok { + return User{}, ErrInvalidToken + } + return User{u}, nil +} + +// BearerToken extracts the credential from an Authorization header value. +// Both "Bearer " and a bare token are accepted, since some clients send +// the latter. +func BearerToken(header string) (string, error) { + if strings.TrimSpace(header) == "" { + return "", ErrMissingToken + } + + // The scheme is split before trimming the credential, so "Bearer " with + // nothing after it is reported as a missing token rather than read as a + // bare token whose value is the word "Bearer". + scheme, rest, hasSpace := strings.Cut(header, " ") + if strings.EqualFold(scheme, "bearer") { + token := strings.TrimSpace(rest) + if !hasSpace || token == "" { + return "", ErrMissingToken + } + return token, nil + } + + header = strings.TrimSpace(header) + // A bare token is accepted, but reject anything shaped like another scheme. + if i := strings.IndexByte(header, ' '); i > 0 { + return "", ErrInvalidToken + } + return header, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/api/internal/auth/auth_test.go b/api/internal/auth/auth_test.go new file mode 100644 index 0000000..efcdbfa --- /dev/null +++ b/api/internal/auth/auth_test.go @@ -0,0 +1,360 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testSecret = "test-signing-secret" + +// sign builds a token with the given claims and method. +func sign(t *testing.T, method jwt.SigningMethod, claims jwt.Claims) string { + t.Helper() + + s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(testSecret)) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + return s +} + +// validClaims returns an unexpired claim set for the verifier under test. +func validClaims() Claims { + return Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-42", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + }, + Username: "ada", + Email: "ada@example.com", + } +} + +func TestJWTVerifierAcceptsValidToken(t *testing.T) { + t.Parallel() + + v := NewJWTVerifier(testSecret, "", "") + got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims())) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got.ID != "user-42" { + t.Errorf("ID = %q, want user-42", got.ID) + } + if got.Username != "ada" { + t.Errorf("Username = %q, want ada", got.Username) + } + if got.Email != "ada@example.com" { + t.Errorf("Email = %q", got.Email) + } + if v.Name() != "jwt" { + t.Errorf("Name = %q, want jwt", v.Name()) + } +} + +func TestJWTVerifierFallsBackToSubjectForUsername(t *testing.T) { + t.Parallel() + + // A token that only asserts a subject still has to yield a usable identity, + // so the username defaults to the subject. + claims := Claims{RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-7", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }} + v := NewJWTVerifier(testSecret, "", "") + got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, claims)) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got.Username != "user-7" { + t.Errorf("Username = %q, want the subject as fallback", got.Username) + } +} + +func TestJWTVerifierRejections(t *testing.T) { + t.Parallel() + + expired := Claims{RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-42", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), + }} + noExp := Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-42"}} + noSub := Claims{RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }} + + tests := []struct { + name string + token string + }{ + {"empty", ""}, + {"expired", sign(t, jwt.SigningMethodHS256, expired)}, + {"missing expiration", sign(t, jwt.SigningMethodHS256, noExp)}, + {"missing subject", sign(t, jwt.SigningMethodHS256, noSub)}, + {"wrong secret", signWithSecret(t, jwt.SigningMethodHS256, validClaims(), "other-secret")}, + // The algorithm is pinned, so a token cannot pick "none" and skip + // signature verification entirely. + {"alg none", unsignedToken(t, validClaims())}, + {"garbage", "not-a-token"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + v := NewJWTVerifier(testSecret, "", "") + if _, err := v.Verify(context.Background(), tc.token); err == nil { + t.Error("Verify accepted a token it should have rejected") + } + }) + } +} + +func TestJWTVerifierReportsMissingTokenDistinctly(t *testing.T) { + t.Parallel() + + // The middleware needs to tell "you sent nothing" from "what you sent is + // bad" so it can word the challenge properly. + v := NewJWTVerifier(testSecret, "", "") + if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) { + t.Errorf("err = %v, want ErrMissingToken", err) + } + if _, err := v.Verify(context.Background(), "garbage"); !errors.Is(err, ErrInvalidToken) { + t.Errorf("err = %v, want ErrInvalidToken", err) + } +} + +func TestJWTVerifierRequiresConfiguredSecret(t *testing.T) { + t.Parallel() + + // An empty secret must reject everything rather than accept a token signed + // with an empty key. + v := NewJWTVerifier("", "", "") + _, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims())) + if !errors.Is(err, ErrInvalidToken) { + t.Errorf("err = %v, want ErrInvalidToken", err) + } +} + +func TestJWTVerifierEnforcesAudienceAndIssuer(t *testing.T) { + t.Parallel() + + claims := validClaims() + claims.Audience = jwt.ClaimStrings{"go-places"} + claims.Issuer = "go-loose" + token := sign(t, jwt.SigningMethodHS256, claims) + + matching := NewJWTVerifier(testSecret, "go-places", "go-loose") + if _, err := matching.Verify(context.Background(), token); err != nil { + t.Errorf("Verify with matching aud/iss: %v", err) + } + + // A token minted for a different client must not be accepted, or a token + // issued for another service in the same cluster would grant access. + wrongAudience := NewJWTVerifier(testSecret, "some-other-app", "go-loose") + if _, err := wrongAudience.Verify(context.Background(), token); err == nil { + t.Error("Verify accepted a token with the wrong audience") + } + wrongIssuer := NewJWTVerifier(testSecret, "go-places", "some-other-issuer") + if _, err := wrongIssuer.Verify(context.Background(), token); err == nil { + t.Error("Verify accepted a token from the wrong issuer") + } + + // Skipping the checks is the documented behaviour of the empty arguments. + unpinned := NewJWTVerifier(testSecret, "", "") + if _, err := unpinned.Verify(context.Background(), token); err != nil { + t.Errorf("Verify with no aud/iss configured: %v", err) + } +} + +func TestJWTVerifierHonoursContextCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + v := NewJWTVerifier(testSecret, "", "") + _, err := v.Verify(ctx, sign(t, jwt.SigningMethodHS256, validClaims())) + if !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", err) + } +} + +func TestDevVerifierAcceptsOnlyConfiguredTokens(t *testing.T) { + t.Parallel() + + v := NewDevVerifier(nil) + got, err := v.Verify(context.Background(), "valid_token") + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got.ID != DefaultDevUser.ID { + t.Errorf("ID = %q, want %q", got.ID, DefaultDevUser.ID) + } + if v.Name() != "dev" { + t.Errorf("Name = %q, want dev", v.Name()) + } + + if _, err := v.Verify(context.Background(), "anything-else"); !errors.Is(err, ErrInvalidToken) { + t.Errorf("err = %v, want ErrInvalidToken", err) + } + if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) { + t.Errorf("err = %v, want ErrMissingToken", err) + } +} + +func TestBearerToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + header string + want string + wantErr error + }{ + {name: "standard", header: "Bearer abc.def.ghi", want: "abc.def.ghi"}, + {name: "lowercase scheme", header: "bearer abc", want: "abc"}, + {name: "mixed case scheme", header: "BeArEr abc", want: "abc"}, + {name: "extra spaces", header: "Bearer abc ", want: "abc"}, + {name: "bare token", header: "abc.def.ghi", want: "abc.def.ghi"}, + {name: "empty", header: "", wantErr: ErrMissingToken}, + {name: "whitespace only", header: " ", wantErr: ErrMissingToken}, + {name: "scheme without token", header: "Bearer ", wantErr: ErrMissingToken}, + // A header naming some other scheme must not be silently read as a + // bearer token. + {name: "other scheme", header: "Basic dXNlcjpwYXNz", wantErr: ErrInvalidToken}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := BearerToken(tc.header) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("token = %q, want %q", got, tc.want) + } + }) + } +} + +func TestMiddlewareAttachesUserToContext(t *testing.T) { + t.Parallel() + + m := NewMiddleware(NewDevVerifier(nil)) + + var seen User + var ok bool + h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) { + seen, ok = FromContext(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodGet, "/places", nil) + req.Header.Set("Authorization", "Bearer valid_token") + rec := httptest.NewRecorder() + h(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if !ok { + t.Fatal("the handler could not read the identity from the context") + } + if seen.ID != DefaultDevUser.ID { + t.Errorf("user = %q, want %q", seen.ID, DefaultDevUser.ID) + } +} + +func TestMiddlewareRejectsUnusableCredentials(t *testing.T) { + t.Parallel() + + m := NewMiddleware(NewDevVerifier(nil)) + + tests := []struct { + name string + header string + body string + }{ + {name: "no header", body: "a bearer token is required"}, + {name: "bad token", header: "Bearer nope", body: "the token was rejected"}, + {name: "other scheme", header: "Basic dXNlcjpwYXNz", body: "the token was rejected"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Local to the subtest: the subtests run in parallel, so a shared + // flag would be a data race. + called := false + h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + + req := httptest.NewRequest(http.MethodGet, "/places", nil) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if called { + t.Error("the protected handler ran without a valid credential") + } + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(got, "Bearer") { + t.Errorf("WWW-Authenticate = %q, want a Bearer challenge", got) + } + if body := rec.Body.String(); !strings.Contains(body, tc.body) { + t.Errorf("body = %q, want it to mention %q", body, tc.body) + } + }) + } +} + +func TestFromContextReportsAbsentIdentity(t *testing.T) { + t.Parallel() + + if _, ok := FromContext(context.Background()); ok { + t.Error("FromContext found an identity on a bare context") + } +} + +// signWithSecret signs with a different key to model a forged token. +func signWithSecret(t *testing.T, method jwt.SigningMethod, claims jwt.Claims, secret string) string { + t.Helper() + + s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + return s +} + +// unsignedToken builds an alg=none token, the classic JWT downgrade attack. +func unsignedToken(t *testing.T, claims jwt.Claims) string { + t.Helper() + + s, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims). + SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + return s +} diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go new file mode 100644 index 0000000..be929da --- /dev/null +++ b/api/internal/auth/middleware.go @@ -0,0 +1,63 @@ +package auth + +import ( + "context" + "errors" + "net/http" +) + +type ctxKey struct{} + +// WithUser stores the verified identity on the request context. +func WithUser(ctx context.Context, u User) context.Context { + return context.WithValue(ctx, ctxKey{}, u) +} + +// FromContext returns the identity attached by the middleware. +func FromContext(ctx context.Context) (User, bool) { + u, ok := ctx.Value(ctxKey{}).(User) + return u, ok +} + +// Middleware verifies the Authorization header and rejects requests that carry +// no usable credential. +type Middleware struct { + verifier Verifier +} + +// NewMiddleware returns middleware backed by the verifier. +func NewMiddleware(v Verifier) *Middleware { + return &Middleware{verifier: v} +} + +// Wrap protects h, making the verified identity available to it. +func (m *Middleware) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, err := BearerToken(r.Header.Get("Authorization")) + if err != nil { + unauthorized(w, err) + return + } + user, err := m.verifier.Verify(r.Context(), token) + if err != nil { + unauthorized(w, err) + return + } + next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user))) + }) +} + +// WrapFunc protects a handler function. +func (m *Middleware) WrapFunc(next http.HandlerFunc) http.HandlerFunc { + return m.Wrap(next).ServeHTTP +} + +func unauthorized(w http.ResponseWriter, err error) { + w.Header().Set("WWW-Authenticate", `Bearer realm="go-places"`) + // The response deliberately does not echo the reason. + if errors.Is(err, ErrMissingToken) { + http.Error(w, "unauthorized: a bearer token is required", http.StatusUnauthorized) + return + } + http.Error(w, "unauthorized: the token was rejected", http.StatusUnauthorized) +} diff --git a/api/internal/blobstore/blobstore.go b/api/internal/blobstore/blobstore.go new file mode 100644 index 0000000..15bdb4f --- /dev/null +++ b/api/internal/blobstore/blobstore.go @@ -0,0 +1,29 @@ +// Package blobstore stores the bytes of uploaded pictures. Keeping it behind an +// interface means the local filesystem can later be replaced by object storage +// without changing the upload handler. +package blobstore + +import ( + "context" + "errors" + "io" +) + +var ( + // ErrNotFound is returned when a key has no stored object. + ErrNotFound = errors.New("blob not found") + // ErrTooLarge is returned when the payload exceeds the configured limit. + ErrTooLarge = errors.New("blob exceeds maximum size") +) + +// BlobStore saves and serves opaque byte payloads addressed by key. +type BlobStore interface { + // Put stores the contents of r under key and returns the number of bytes + // written. It must not read more than maxBytes from r. + Put(ctx context.Context, key string, r io.Reader, maxBytes int64) (int64, error) + // Get opens the object stored under key. The caller closes the reader. + Get(ctx context.Context, key string) (io.ReadCloser, error) + // Delete removes the object stored under key. Deleting a missing key is not + // an error. + Delete(ctx context.Context, key string) error +} diff --git a/api/internal/blobstore/localfs.go b/api/internal/blobstore/localfs.go new file mode 100644 index 0000000..4f5c1bc --- /dev/null +++ b/api/internal/blobstore/localfs.go @@ -0,0 +1,132 @@ +package blobstore + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// LocalFS stores blobs as files beneath a root directory. +type LocalFS struct { + root string +} + +var _ BlobStore = (*LocalFS)(nil) + +// NewLocalFS returns a blob store rooted at dir, creating it if necessary. +func NewLocalFS(dir string) (*LocalFS, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("blobstore: resolve root %q: %w", dir, err) + } + if err := os.MkdirAll(abs, 0o750); err != nil { + return nil, fmt.Errorf("blobstore: create root %q: %w", abs, err) + } + return &LocalFS{root: abs}, nil +} + +// Root returns the absolute directory holding the blobs. +func (l *LocalFS) Root() string { return l.root } + +// Put writes the stream to a temporary file and renames it into place, so a +// failed or oversized upload never leaves a partial object visible. +func (l *LocalFS) Put(ctx context.Context, key string, r io.Reader, maxBytes int64) (int64, error) { + path, err := l.resolve(key) + if err != nil { + return 0, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return 0, fmt.Errorf("blobstore: create parent of %q: %w", key, err) + } + + tmp, err := os.CreateTemp(filepath.Dir(path), ".upload-*") + if err != nil { + return 0, fmt.Errorf("blobstore: create temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { + tmp.Close() + os.Remove(tmpName) // no-op once the rename has succeeded + }() + + // Copy one byte past the limit so an oversized payload is detectable. + written, err := io.Copy(tmp, io.LimitReader(r, maxBytes+1)) + if err != nil { + return 0, fmt.Errorf("blobstore: write %q: %w", key, err) + } + if written > maxBytes { + return written, fmt.Errorf("%w: limit is %d bytes", ErrTooLarge, maxBytes) + } + if err := tmp.Sync(); err != nil { + return 0, fmt.Errorf("blobstore: sync %q: %w", key, err) + } + if err := tmp.Close(); err != nil { + return 0, fmt.Errorf("blobstore: close %q: %w", key, err) + } + if err := ctx.Err(); err != nil { + return written, err + } + if err := os.Rename(tmpName, path); err != nil { + return written, fmt.Errorf("blobstore: commit %q: %w", key, err) + } + return written, nil +} + +// Get opens the stored file. +func (l *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) { + path, err := l.resolve(key) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: %s", ErrNotFound, key) + } + if err != nil { + return nil, fmt.Errorf("blobstore: open %q: %w", key, err) + } + return f, nil +} + +// Delete removes the stored file, ignoring a missing one. +func (l *LocalFS) Delete(ctx context.Context, key string) error { + path, err := l.resolve(key) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("blobstore: delete %q: %w", key, err) + } + return nil +} + +// resolve maps a key to a path inside the root, rejecting any key that escapes +// it. Keys reach this code from upload filenames, so traversal has to fail +// closed rather than being normalised away. +func (l *LocalFS) resolve(key string) (string, error) { + if key == "" { + return "", errors.New("blobstore: empty key") + } + if filepath.IsAbs(key) { + return "", fmt.Errorf("blobstore: key %q must be relative", key) + } + + clean := filepath.Clean("/" + key) + // filepath.Clean on an absolute path collapses any ".." segments. + rel := strings.TrimPrefix(clean, "/") + if rel == "" { + return "", fmt.Errorf("blobstore: key %q resolves to the root", key) + } + + path := filepath.Join(l.root, rel) + // Defence in depth: confirm the result really is under the root. + if path != l.root && !strings.HasPrefix(path, l.root+string(os.PathSeparator)) { + return "", fmt.Errorf("blobstore: key %q escapes the store root", key) + } + return path, nil +} diff --git a/api/internal/config/config.go b/api/internal/config/config.go new file mode 100644 index 0000000..93bc9be --- /dev/null +++ b/api/internal/config/config.go @@ -0,0 +1,260 @@ +// Package config loads runtime settings from the environment. Every setting has +// a working default so the service starts without configuration in development. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config is the fully resolved runtime configuration. +type Config struct { + // Addr is the listen address for the HTTP server. + Addr string + // DatabaseURL points at the SQLite file backing the store. + DatabaseURL string + // MediaDir is the root directory that uploaded pictures are written to. + MediaDir string + // PublicBaseURL is prefixed to stored media paths to build absolute URLs. + PublicBaseURL string + + // AuthMode selects the token validator: "jwt" or "dev". + AuthMode string + // JWTSecret is the shared secret used to verify go-loose issued tokens. + JWTSecret string + // JWTAudience is the expected "aud" claim; empty disables the check. + JWTAudience string + + // DefaultLinkRadiusMeters is how close two pictures must be to be linked. + DefaultLinkRadiusMeters float64 + // MaxLinkRadiusMeters caps what a client may request per place. + MaxLinkRadiusMeters float64 + + // MaxUploadBytes is the largest accepted upload request. + MaxUploadBytes int64 + // AllowedImageTypes are the content types accepted for pictures. + AllowedImageTypes []string + + // AnalyzerMode is "local" for the built-in heuristics or "remote" to + // delegate to an external inference service. + AnalyzerMode string + // AnalyzerURL is the base URL of the external inference service. + AnalyzerURL string + // AnalyzerKey authenticates requests to the inference service. + AnalyzerKey string + // AnalyzerTimeout bounds a single analysis request. + AnalyzerTimeout time.Duration + + // AMQPURL enables the event publisher when non-empty. + AMQPURL string + // EventsExchange is the exchange domain events are published to. + EventsExchange string + + // EnableREST if true, adds REST endpoints alongside GraphQL. + EnableREST bool +} + +// Default returns the configuration used when nothing is set. +func Default() Config { + return Config{ + Addr: ":8080", + DatabaseURL: "file:go-places.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", + MediaDir: "./data/media", + PublicBaseURL: "", + AuthMode: "dev", + JWTSecret: "", + JWTAudience: "", + DefaultLinkRadiusMeters: 150, + MaxLinkRadiusMeters: 5000, + MaxUploadBytes: 20 << 20, + AllowedImageTypes: []string{"image/jpeg", "image/png", "image/webp"}, + AnalyzerMode: "local", + AnalyzerURL: "", + AnalyzerKey: "", + AnalyzerTimeout: 10 * time.Second, + AMQPURL: "", + EventsExchange: "go-places.events", + EnableREST: false, + } +} + +// Load builds a Config from the environment on top of the defaults. +func Load() (Config, error) { + c := Default() + + var err error + if c.Addr, err = envString("ADDR", c.Addr); err != nil { + return c, err + } + if c.DatabaseURL, err = envString("DATABASE_URL", c.DatabaseURL); err != nil { + return c, err + } + if c.MediaDir, err = envString("MEDIA_DIR", c.MediaDir); err != nil { + return c, err + } + if c.PublicBaseURL, err = envString("PUBLIC_BASE_URL", c.PublicBaseURL); err != nil { + return c, err + } + if c.AuthMode, err = envString("AUTH_MODE", c.AuthMode); err != nil { + return c, err + } + if c.JWTSecret, err = envString("JWT_SECRET", c.JWTSecret); err != nil { + return c, err + } + if c.JWTAudience, err = envString("JWT_AUDIENCE", c.JWTAudience); err != nil { + return c, err + } + if c.DefaultLinkRadiusMeters, err = envFloat("DEFAULT_LINK_RADIUS_METERS", c.DefaultLinkRadiusMeters); err != nil { + return c, err + } + if c.MaxLinkRadiusMeters, err = envFloat("MAX_LINK_RADIUS_METERS", c.MaxLinkRadiusMeters); err != nil { + return c, err + } + if c.MaxUploadBytes, err = envInt64("MAX_UPLOAD_BYTES", c.MaxUploadBytes); err != nil { + return c, err + } + if c.AnalyzerMode, err = envString("ANALYZER_MODE", c.AnalyzerMode); err != nil { + return c, err + } + if c.AnalyzerURL, err = envString("ANALYZER_URL", c.AnalyzerURL); err != nil { + return c, err + } + if c.AnalyzerKey, err = envString("ANALYZER_KEY", c.AnalyzerKey); err != nil { + return c, err + } + if an, err := envInt("ANALYZER_TIMEOUT_SECONDS", int(c.AnalyzerTimeout/time.Second)); err == nil { + c.AnalyzerTimeout = time.Duration(an) * time.Second + } else { + return c, err + } + if c.AMQPURL, err = envString("AMQP_URL", c.AMQPURL); err != nil { + return c, err + } + if c.EventsExchange, err = envString("EVENTS_EXCHANGE", c.EventsExchange); err != nil { + return c, err + } + if v := os.Getenv("ENABLE_REST"); v != "" { + c.EnableREST, err = strconv.ParseBool(v) + if err != nil { + return c, fmt.Errorf("config: ENABLE_REST must be true or false: %w", err) + } + } + if v := os.Getenv("ALLOWED_IMAGE_TYPES"); v != "" { + c.AllowedImageTypes = splitAndTrim(v) + } + + if err := c.Validate(); err != nil { + return c, err + } + return c, nil +} + +// Validate rejects configurations that cannot work, so misconfiguration is +// caught at startup instead of on the first request. +func (c Config) Validate() error { + switch c.AuthMode { + case "jwt": + if c.JWTSecret == "" { + return fmt.Errorf("config: AUTH_MODE=jwt requires JWT_SECRET to be set") + } + case "dev": + default: + return fmt.Errorf("config: AUTH_MODE must be %q or %q, got %q", "jwt", "dev", c.AuthMode) + } + + switch c.AnalyzerMode { + case "local": + case "remote": + if c.AnalyzerURL == "" { + return fmt.Errorf("config: ANALYZER_MODE=remote requires ANALYZER_URL to be set") + } + default: + return fmt.Errorf("config: ANALYZER_MODE must be %q or %q, got %q", "local", "remote", c.AnalyzerMode) + } + + if c.DefaultLinkRadiusMeters <= 0 { + return fmt.Errorf("config: DEFAULT_LINK_RADIUS_METERS must be positive") + } + if c.MaxLinkRadiusMeters < c.DefaultLinkRadiusMeters { + return fmt.Errorf("config: MAX_LINK_RADIUS_METERS must not be smaller than DEFAULT_LINK_RADIUS_METERS") + } + if c.MaxUploadBytes <= 0 { + return fmt.Errorf("config: MAX_UPLOAD_BYTES must be positive") + } + if len(c.AllowedImageTypes) == 0 { + return fmt.Errorf("config: at least one image content type must be allowed") + } + return nil +} + +// AllowsContentType reports whether a content type may be uploaded. +func (c Config) AllowsContentType(contentType string) bool { + // Ignore any parameters such as "; charset=utf-8". + if i := strings.IndexByte(contentType, ';'); i >= 0 { + contentType = contentType[:i] + } + contentType = strings.ToLower(strings.TrimSpace(contentType)) + for _, allowed := range c.AllowedImageTypes { + if allowed == contentType { + return true + } + } + return false +} + +func envString(key, def string) (string, error) { + if v, ok := os.LookupEnv(key); ok && v != "" { + return v, nil + } + return def, nil +} + +func envInt(key string, def int) (int, error) { + v, ok := os.LookupEnv(key) + if !ok || v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return def, fmt.Errorf("config: %s must be an integer: %w", key, err) + } + return n, nil +} + +func envInt64(key string, def int64) (int64, error) { + v, ok := os.LookupEnv(key) + if !ok || v == "" { + return def, nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return def, fmt.Errorf("config: %s must be an integer: %w", key, err) + } + return n, nil +} + +func envFloat(key string, def float64) (float64, error) { + v, ok := os.LookupEnv(key) + if !ok || v == "" { + return def, nil + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return def, fmt.Errorf("config: %s must be a number: %w", key, err) + } + return f, nil +} + +func splitAndTrim(v string) []string { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/api/internal/domain/models.go b/api/internal/domain/models.go new file mode 100644 index 0000000..a0228d3 --- /dev/null +++ b/api/internal/domain/models.go @@ -0,0 +1,166 @@ +// Package domain contains the core entities of the application. It has no +// dependencies on transport, storage or database layers so that the business +// rules can be reasoned about in isolation. +package domain + +import "time" + +// User represents a logged-in user. Identity is owned by the external go-loose +// SSO service; this service only stores the identifier it asserts. +type User struct { + ID string `json:"id" gql:"id"` + Username string `json:"username" gql:"username"` + Email string `json:"email" gql:"email"` +} + +// Coordinate is a WGS-84 geographic point. +type Coordinate struct { + Latitude float64 `json:"latitude" gql:"latitude"` + Longitude float64 `json:"longitude" gql:"longitude"` +} + +// Valid reports whether the coordinate is a real point on Earth. +func (c Coordinate) Valid() bool { + if c.Latitude < -90 || c.Latitude > 90 { + return false + } + return c.Longitude >= -180 && c.Longitude <= 180 +} + +// AnalysisStatus tracks the progress of the image analysis pipeline. Analysis +// runs out of band, so a picture is visible to the API before its badges exist. +type AnalysisStatus string + +const ( + AnalysisPending AnalysisStatus = "pending" + AnalysisCompleted AnalysisStatus = "completed" + AnalysisFailed AnalysisStatus = "failed" +) + +// Badge is a recognisable attribute detected in a picture, for example an +// object ("dog"), a scene ("beach") or a property ("low-light"). +type Badge struct { + // Label is the stable machine-readable name, e.g. "dog" or "beach". + Label string `json:"label" gql:"label"` + // Display is a human-friendly rendering of the label, e.g. "Dog". + Display string `json:"display" gql:"display"` + // Confidence is a 0..1 score. Heuristic badges tend to be lower confidence + // than those returned by a trained model. + Confidence float64 `json:"confidence" gql:"confidence"` + // Source records which analyzer produced the badge, which lets the UI + // distinguish machine-derived facts from heuristics. + Source string `json:"source" gql:"source"` +} + +// Image is a picture attached to a Place. +type Image struct { + ID string `json:"id" gql:"id"` + PlaceID string `json:"placeId" gql:"placeId"` + // StorageKey is the location of the bytes inside the blob store. It is not + // exposed to clients; URL is the public rendering of this key. + StorageKey string `json:"-" gql:"-"` + URL string `json:"url" gql:"url"` + Caption string `json:"caption" gql:"caption"` + ContentType string `json:"contentType" gql:"contentType"` + ByteSize int64 `json:"byteSize" gql:"byteSize"` + Width int `json:"width" gql:"width"` + Height int `json:"height" gql:"height"` + + // Coordinate is where the picture was taken. It is taken from the EXIF GPS + // tag when present, and falls back to the parent Place otherwise. + Coordinate *Coordinate `json:"coordinate" gql:"coordinate"` + // TakenAt is when the picture was captured, from the EXIF timestamp. + TakenAt *time.Time `json:"takenAt" gql:"takenAt"` + // CameraMake and CameraModel come from EXIF and identify the capture device. + CameraMake string `json:"cameraMake" gql:"cameraMake"` + CameraModel string `json:"cameraModel" gql:"cameraModel"` + + Badges []Badge `json:"badges" gql:"badges"` + AnalysisStatus AnalysisStatus `json:"analysisStatus" gql:"analysisStatus"` + AnalysisError string `json:"analysisError" gql:"analysisError"` + + // LinkedImages are other pictures taken at the same location, resolved by + // the linking pass rather than stored. + LinkedImages []Image `json:"linkedImages" gql:"linkedImages"` + + CreatedAt time.Time `json:"createdAt" gql:"createdAt"` +} + +// Place is a location the user visited and shared. Pictures and a free-text +// description hang off it, and it is the anchor for geographic linking. +type Place struct { + ID string `json:"id" gql:"id"` + Title string `json:"title" gql:"title"` + Description string `json:"description" gql:"description"` + + // Coordinate is the location of the place. Required for linking to work; + // when a picture carries its own EXIF GPS tag that value takes precedence. + Coordinate *Coordinate `json:"coordinate" gql:"coordinate"` + // LinkRadiusMeters is how close two pictures must be to count as having + // been taken at the same place. + LinkRadiusMeters float64 `json:"linkRadiusMeters" gql:"linkRadiusMeters"` + + Creator User `json:"creator" gql:"creator"` + Images []Image `json:"images" gql:"images"` + Comments []Comment `json:"comments" gql:"comments"` + // RelatedPlaces are other Places whose pictures overlap this one. + RelatedPlaces []RelatedPlace `json:"relatedPlaces" gql:"relatedPlaces"` + + CreatedAt time.Time `json:"createdAt" gql:"createdAt"` +} + +// RelatedPlace records the link between two Places discovered by the linking +// pass, along with the evidence for it. +type RelatedPlace struct { + Place Place `json:"place" gql:"place"` + DistanceMeters float64 `json:"distanceMeters" gql:"distanceMeters"` + SharedImageIDs []string `json:"sharedImageIds" gql:"sharedImageIds"` +} + +// Link is a single edge produced by the linking pass, connecting one picture to +// another that was taken at the same place. +type Link struct { + ImageID string `json:"imageId" gql:"imageId"` + RelatedImageID string `json:"relatedImageId" gql:"relatedImageId"` + // DistanceMeters is the great-circle distance between the two capture points. + DistanceMeters float64 `json:"distanceMeters" gql:"distanceMeters"` + // SamePlace is true when the two pictures belong to the same Place record. + SamePlace bool `json:"samePlace" gql:"samePlace"` + // Reason explains the evidence, e.g. "same-place" or "within-radius". + Reason string `json:"reason" gql:"reason"` +} + +// Comment is a user reaction on a Place. +type Comment struct { + ID string `json:"id" gql:"id"` + PlaceID string `json:"placeId" gql:"placeId"` + Content string `json:"content" gql:"content"` + Creator User `json:"creator" gql:"creator"` + CreatedAt time.Time `json:"createdAt" gql:"createdAt"` +} + +// PlaceInput is the payload for creating a Place. Latitude and Longitude are +// pointers so that an omitted coordinate can be told apart from the origin, +// which is a legitimate coordinate. +type PlaceInput struct { + Title string `json:"title" gql:"title"` + Description string `json:"description" gql:"description"` + Latitude *float64 `json:"latitude" gql:"latitude"` + Longitude *float64 `json:"longitude" gql:"longitude"` + LinkRadiusMeters *float64 `json:"linkRadiusMeters" gql:"linkRadiusMeters"` + // Caption is the description of the first picture in the upload, if any. + Caption string `json:"caption" gql:"caption"` +} + +// Coordinate builds a Coordinate from the optional latitude and longitude, +// returning nil when they are absent or do not form a valid point. +func (p PlaceInput) Coordinate() *Coordinate { + if p.Latitude == nil || p.Longitude == nil { + return nil + } + c := Coordinate{Latitude: *p.Latitude, Longitude: *p.Longitude} + if !c.Valid() { + return nil + } + return &c +} diff --git a/api/internal/events/events.go b/api/internal/events/events.go new file mode 100644 index 0000000..83f849a --- /dev/null +++ b/api/internal/events/events.go @@ -0,0 +1,192 @@ +// Package events publishes domain events so other services can react to them +// without being called synchronously. +package events + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// Topic names the routing key an event is published under. +type Topic string + +const ( + // TopicPlaceCreated fires once a place exists, before its pictures are + // attached. + TopicPlaceCreated Topic = "place.created" + // TopicImageAnalyzed fires when a picture's badges are available. + TopicImageAnalyzed Topic = "image.analyzed" + // TopicImageLinked fires when a picture is linked to another taken at the + // same place. + TopicImageLinked Topic = "image.linked" + // TopicCommentAdded fires when a comment is posted. + TopicCommentAdded Topic = "comment.added" +) + +// Event is the envelope every published message shares. Payload stays raw so +// consumers can evolve their own schema without the publisher changing. +type Event struct { + // ID uniquely identifies this event, letting consumers deduplicate. + ID string `json:"id"` + // Topic is the routing key. + Topic Topic `json:"topic"` + // OccurredAt is when the event was produced. + OccurredAt time.Time `json:"occurredAt"` + // ActorID is the user who caused it, empty for system events. + ActorID string `json:"actorId,omitempty"` + // Payload is topic-specific JSON. + Payload json.RawMessage `json:"payload"` +} + +// Publisher sends events. Implementations must not block the request path for +// long; a slow broker should not stall an upload. +type Publisher interface { + // Publish sends one event. Implementations should treat delivery as + // best-effort and report failure to the caller rather than panicking. + Publish(ctx context.Context, ev Event) error + // Close releases resources. + Close() error +} + +// LogPublisher writes events to the structured log. It is the default so the +// service runs without a broker, and it keeps the event stream inspectable in +// development. +type LogPublisher struct { + logger *slog.Logger +} + +var _ Publisher = (*LogPublisher)(nil) + +// NewLogPublisher returns a publisher that logs events. +func NewLogPublisher(logger *slog.Logger) *LogPublisher { + if logger == nil { + logger = slog.Default() + } + return &LogPublisher{logger: logger} +} + +// Publish implements Publisher. +func (p *LogPublisher) Publish(ctx context.Context, ev Event) error { + p.logger.InfoContext(ctx, "event published", + "topic", string(ev.Topic), + "eventId", ev.ID, + "actorId", ev.ActorID, + "payload", string(ev.Payload), + ) + return nil +} + +// Close implements Publisher. +func (p *LogPublisher) Close() error { return nil } + +// AMQPPublisher publishes to a RabbitMQ topic exchange. +type AMQPPublisher struct { + conn *amqp.Connection + channel *amqp.Channel + exchange string + logger *slog.Logger + // confirms receives publisher acknowledgements, so a broker-side rejection + // is noticed instead of being silently dropped. + confirms <-chan amqp.Confirmation +} + +var _ Publisher = (*AMQPPublisher)(nil) + +// NewAMQP dials the broker, declares the exchange and enables confirms. +func NewAMQP(url, exchange string, logger *slog.Logger) (*AMQPPublisher, error) { + if logger == nil { + logger = slog.Default() + } + + conn, err := amqp.Dial(url) + if err != nil { + return nil, fmt.Errorf("events: dial amqp: %w", err) + } + channel, err := conn.Channel() + if err != nil { + conn.Close() + return nil, fmt.Errorf("events: open channel: %w", err) + } + + // A topic exchange lets consumers bind to "image.*" or "place.created". + if err := channel.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { + channel.Close() + conn.Close() + return nil, fmt.Errorf("events: declare exchange %q: %w", exchange, err) + } + if err := channel.Confirm(false); err != nil { + channel.Close() + conn.Close() + return nil, fmt.Errorf("events: enable confirms: %w", err) + } + + return &AMQPPublisher{ + conn: conn, + channel: channel, + exchange: exchange, + logger: logger, + confirms: channel.NotifyPublish(make(chan amqp.Confirmation, 1)), + }, nil +} + +// Publish implements Publisher. It waits briefly for the broker's +// acknowledgement so that a failed publish is reported to the caller. +func (p *AMQPPublisher) Publish(ctx context.Context, ev Event) error { + body, err := json.Marshal(ev) + if err != nil { + return fmt.Errorf("events: marshal %s: %w", ev.Topic, err) + } + + // Delivery mode 2 marks the message persistent. + pub := amqp.Publishing{ + ContentType: "application/json", + DeliveryMode: amqp.Persistent, + MessageId: ev.ID, + Timestamp: ev.OccurredAt, + Type: string(ev.Topic), + Body: body, + } + + if err := p.channel.PublishWithContext(ctx, p.exchange, string(ev.Topic), false, false, pub); err != nil { + return fmt.Errorf("events: publish %s: %w", ev.Topic, err) + } + + select { + case confirm, ok := <-p.confirms: + if !ok { + return fmt.Errorf("events: confirmation channel closed for %s", ev.Topic) + } + if !confirm.Ack { + return fmt.Errorf("events: broker rejected %s", ev.Topic) + } + return nil + case <-ctx.Done(): + return ctx.Err() + case <-time.After(confirmTimeout): + return fmt.Errorf("events: no confirmation for %s within %s", ev.Topic, confirmTimeout) + } +} + +// confirmTimeout bounds the wait for a broker acknowledgement. +const confirmTimeout = 5 * time.Second + +// Close implements Publisher. +func (p *AMQPPublisher) Close() error { + var firstErr error + if p.channel != nil { + if err := p.channel.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if p.conn != nil { + if err := p.conn.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/api/internal/geo/geo.go b/api/internal/geo/geo.go new file mode 100644 index 0000000..287b613 --- /dev/null +++ b/api/internal/geo/geo.go @@ -0,0 +1,92 @@ +// Package geo provides the geographic distance calculations used to decide +// whether two pictures were taken at the same place. +package geo + +import ( + "errors" + "math" + + "go-places-api/internal/domain" +) + +// EarthRadiusMeters is the mean radius of the WGS-84 ellipsoid. +const EarthRadiusMeters = 6371008.8 + +var ErrInvalidCoordinate = errors.New("invalid coordinate") + +// DistanceMeters returns the great-circle distance between two points using the +// haversine formula. It is accurate to roughly 0.5% which is far finer than the +// radii this application compares against. +func DistanceMeters(a, b domain.Coordinate) float64 { + lat1 := degreesToRadians(a.Latitude) + lat2 := degreesToRadians(b.Latitude) + dLat := lat2 - lat1 + dLon := degreesToRadians(b.Longitude - a.Longitude) + + sinLat := math.Sin(dLat / 2) + sinLon := math.Sin(dLon / 2) + + h := sinLat*sinLat + math.Cos(lat1)*math.Cos(lat2)*sinLon*sinLon + return 2 * EarthRadiusMeters * math.Asin(math.Sqrt(math.Min(1, h))) +} + +// WithinRadius reports whether two points are no further apart than radiusMeters. +func WithinRadius(a, b domain.Coordinate, radiusMeters float64) bool { + return DistanceMeters(a, b) <= radiusMeters +} + +// BoundingBox is a coarse lat/lon window that fully contains a circle of the +// given radius around a centre. It exists to let the store pre-filter rows in +// SQL before the exact haversine test is applied. +type BoundingBox struct { + MinLat float64 + MaxLat float64 + MinLon float64 + MaxLon float64 +} + +// Around returns the bounding box of a circle of radiusMeters around center. +func Around(center domain.Coordinate, radiusMeters float64) BoundingBox { + // Latitude degrees are a constant distance apart, so the latitude delta is + // a simple division. Longitude degrees shrink towards the poles, so the + // delta is scaled by 1/cos(latitude) and clamped near the poles. + latDelta := radiusMeters / EarthRadiusMeters * 180 / math.Pi + + cos := math.Cos(degreesToRadians(center.Latitude)) + var lonDelta float64 + if math.Abs(cos) > 1e-9 { + lonDelta = latDelta / math.Abs(cos) + } else { + // At the poles every longitude is the same meridian; use everything. + lonDelta = 180 + } + // Clamp so the box stays within valid longitude range. + if lonDelta > 180 { + lonDelta = 180 + } + + return BoundingBox{ + MinLat: clamp(center.Latitude-latDelta, -90, 90), + MaxLat: clamp(center.Latitude+latDelta, -90, 90), + MinLon: clamp(center.Longitude-lonDelta, -180, 180), + MaxLon: clamp(center.Longitude+lonDelta, -180, 180), + } +} + +// Contains reports whether the point falls inside the box. +func (b BoundingBox) Contains(c domain.Coordinate) bool { + return c.Latitude >= b.MinLat && c.Latitude <= b.MaxLat && + c.Longitude >= b.MinLon && c.Longitude <= b.MaxLon +} + +// Round rounds a distance to the nearest metre so that JSON output and equality +// assertions are stable. +func Round(meters float64) float64 { + return math.Round(meters) +} + +func degreesToRadians(deg float64) float64 { return deg * math.Pi / 180 } + +func clamp(v, lo, hi float64) float64 { + return math.Max(lo, math.Min(hi, v)) +} diff --git a/api/internal/geo/geo_test.go b/api/internal/geo/geo_test.go new file mode 100644 index 0000000..937a0c3 --- /dev/null +++ b/api/internal/geo/geo_test.go @@ -0,0 +1,219 @@ +package geo + +import ( + "math" + "testing" + + "go-places-api/internal/domain" +) + +// TestDistanceKnownSeparations checks the haversine result against separations +// whose expected value is well established. +func TestDistanceKnownSeparations(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + a, b domain.Coordinate + wantMetres float64 + tolerance float64 + }{ + { + // One degree of latitude is about 111.2 km anywhere. + name: "one degree of latitude", + a: domain.Coordinate{Latitude: 0, Longitude: 0}, + b: domain.Coordinate{Latitude: 1, Longitude: 0}, + wantMetres: 111195, + tolerance: 100, + }, + { + // A degree of longitude is the same at the equator. + name: "one degree of longitude at the equator", + a: domain.Coordinate{Latitude: 0, Longitude: 0}, + b: domain.Coordinate{Latitude: 0, Longitude: 1}, + wantMetres: 111195, + tolerance: 100, + }, + { + // Paris to London, roughly 344 km. + name: "Paris to London", + a: domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522}, + b: domain.Coordinate{Latitude: 51.5074, Longitude: -0.1278}, + wantMetres: 343500, + tolerance: 3000, + }, + { + // A short walk: the same spot is zero distance away. + name: "identical points", + a: domain.Coordinate{Latitude: 45.0, Longitude: 9.0}, + b: domain.Coordinate{Latitude: 45.0, Longitude: 9.0}, + wantMetres: 0, + tolerance: 0.001, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := DistanceMeters(tc.a, tc.b) + if math.Abs(got-tc.wantMetres) > tc.tolerance { + t.Errorf("DistanceMeters = %.1f m, want %.1f m (±%.1f)", + got, tc.wantMetres, tc.tolerance) + } + }) + } +} + +// TestDistanceIsSymmetric guards against an asymmetry bug in the formula. +func TestDistanceIsSymmetric(t *testing.T) { + t.Parallel() + + a := domain.Coordinate{Latitude: 52.5200, Longitude: 13.4050} + b := domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522} + + forward := DistanceMeters(a, b) + backward := DistanceMeters(b, a) + if math.Abs(forward-backward) > 1e-6 { + t.Errorf("distance is not symmetric: %.6f vs %.6f", forward, backward) + } +} + +// TestLongitudeDegreeShrinksTowardsPoles confirms the latitude dependency that +// the bounding box calculation relies on. +func TestLongitudeDegreeShrinksTowardsPoles(t *testing.T) { + t.Parallel() + + atEquator := DistanceMeters( + domain.Coordinate{Latitude: 0, Longitude: 0}, + domain.Coordinate{Latitude: 0, Longitude: 1}, + ) + atSixty := DistanceMeters( + domain.Coordinate{Latitude: 60, Longitude: 0}, + domain.Coordinate{Latitude: 60, Longitude: 1}, + ) + + // A degree of longitude at 60 degrees is half the equatorial distance. + if ratio := atSixty / atEquator; math.Abs(ratio-0.5) > 0.01 { + t.Errorf("longitude degree at 60 lat = %.3f of equatorial, want 0.500", ratio) + } +} + +func TestWithinRadius(t *testing.T) { + t.Parallel() + + // Two points about 22 m apart. + origin := domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945} + // 0.0002 degrees of latitude is roughly 22 m. + nearby := domain.Coordinate{Latitude: 48.8586, Longitude: 2.2945} + far := domain.Coordinate{Latitude: 48.8684, Longitude: 2.2945} + + if !WithinRadius(origin, nearby, 50) { + t.Errorf("expected points ~22 m apart to be within 50 m, got %.1f m", + DistanceMeters(origin, nearby)) + } + if WithinRadius(origin, far, 50) { + t.Errorf("expected points ~1112 m apart to be outside 50 m") + } + // The boundary itself is inclusive. + if !WithinRadius(origin, nearby, DistanceMeters(origin, nearby)) { + t.Errorf("expected the radius boundary to be inclusive") + } +} + +// TestBoundingBoxContainsCircle is the property the SQL pre-filter relies on: +// every point within the radius must fall inside the box, so the box is only +// ever a superset and never loses a candidate. +func TestBoundingBoxContainsCircle(t *testing.T) { + t.Parallel() + + centres := []domain.Coordinate{ + {Latitude: 0, Longitude: 0}, + {Latitude: 48.8566, Longitude: 2.3522}, + {Latitude: -33.8688, Longitude: 151.2093}, + {Latitude: 64.1466, Longitude: -21.9426}, + } + radii := []float64{50, 150, 5000} + + for _, c := range centres { + for _, radius := range radii { + box := Around(c, radius) + for _, bearing := range []float64{0, 45, 90, 135, 180, 225, 270, 315} { + p := offset(c, bearing, radius) + if !box.Contains(p) { + t.Errorf("box around %+v r=%v excludes point at bearing %v (%+v): %+v", + c, radius, bearing, p, box) + } + } + } + } +} + +// TestBoundingBoxHandlesPoles ensures a circle at the pole does not divide by +// the vanishing cosine of the latitude. +func TestBoundingBoxHandlesPoles(t *testing.T) { + t.Parallel() + + box := Around(domain.Coordinate{Latitude: 90, Longitude: 0}, 1000) + if box.MinLon != -180 || box.MaxLon != 180 { + t.Errorf("at the pole the box should span all longitudes, got lon %v..%v", + box.MinLon, box.MaxLon) + } + if box.MaxLat > 90 || box.MinLat < -90 { + t.Errorf("latitude bounds must stay in range, got %v..%v", box.MinLat, box.MaxLat) + } +} + +func TestRound(t *testing.T) { + t.Parallel() + + cases := []struct { + in, want float64 + }{ + {0, 0}, + {0.4, 0}, + {0.5, 1}, + {1.4, 1}, + {1.5, 2}, + {123.456, 123}, + {-0.6, -1}, + } + for _, tc := range cases { + if got := Round(tc.in); got != tc.want { + t.Errorf("Round(%v) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestCoordinateValid(t *testing.T) { + t.Parallel() + + cases := []struct { + coord domain.Coordinate + want bool + }{ + {domain.Coordinate{Latitude: 0, Longitude: 0}, true}, + {domain.Coordinate{Latitude: 48.85, Longitude: 2.35}, true}, + {domain.Coordinate{Latitude: 90, Longitude: 180}, true}, + {domain.Coordinate{Latitude: -90, Longitude: -180}, true}, + {domain.Coordinate{Latitude: 90.1, Longitude: 0}, false}, + {domain.Coordinate{Latitude: -90.1, Longitude: 0}, false}, + {domain.Coordinate{Latitude: 0, Longitude: 180.1}, false}, + {domain.Coordinate{Latitude: 0, Longitude: -180.1}, false}, + } + for _, tc := range cases { + if got := tc.coord.Valid(); got != tc.want { + t.Errorf("%+v.Valid() = %v, want %v", tc.coord, got, tc.want) + } + } +} + +// offset moves a point by bearing degrees and distance metres using a simple +// equirectangular approximation, which is accurate enough over the small +// distances these tests use. +func offset(origin domain.Coordinate, bearingDeg, metres float64) domain.Coordinate { + const degToRad = math.Pi / 180 + lat := origin.Latitude + (metres*math.Cos(bearingDeg*degToRad))/EarthRadiusMeters*180/math.Pi + lon := origin.Longitude + (metres*math.Sin(bearingDeg*degToRad))/ + (EarthRadiusMeters*math.Cos(origin.Latitude*degToRad))*180/math.Pi + return domain.Coordinate{Latitude: lat, Longitude: lon} +} diff --git a/api/internal/service/helpers.go b/api/internal/service/helpers.go new file mode 100644 index 0000000..1a8efff --- /dev/null +++ b/api/internal/service/helpers.go @@ -0,0 +1,29 @@ +package service + +import ( + "encoding/json" + "fmt" + "sort" + + "go-places-api/internal/domain" +) + +// marshalPayload encodes an event payload as JSON. +func marshalPayload(payload map[string]any) (json.RawMessage, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("service: marshal event payload: %w", err) + } + return json.RawMessage(body), nil +} + +// sortRelated orders related places by distance, then by identifier so the +// result is stable for equal distances. +func sortRelated(in []domain.RelatedPlace) { + sort.SliceStable(in, func(i, j int) bool { + if in[i].DistanceMeters != in[j].DistanceMeters { + return in[i].DistanceMeters < in[j].DistanceMeters + } + return in[i].Place.ID < in[j].Place.ID + }) +} diff --git a/api/internal/service/image.go b/api/internal/service/image.go new file mode 100644 index 0000000..09625cd --- /dev/null +++ b/api/internal/service/image.go @@ -0,0 +1,357 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "path" + "strings" + "time" + + "github.com/google/uuid" + + "go-places-api/internal/analysis" + "go-places-api/internal/blobstore" + "go-places-api/internal/domain" + "go-places-api/internal/events" +) + +// ImageUpload is one picture submitted by a client. +type ImageUpload struct { + // Filename is the client-supplied name, used only to pick a storage + // extension. It never becomes part of the storage path. + Filename string + // ContentType is the declared MIME type. + ContentType string + // Caption is the description the user attached to this picture. + Caption string + // Body streams the encoded picture. + Body io.Reader + // Coordinate overrides the location. When nil the EXIF GPS tag is used, + // and failing that the place's own coordinate applies. + Coordinate *domain.Coordinate +} + +// UploadResult reports what happened to one picture when a batch is submitted. +// Pictures are processed independently so one bad file does not discard the +// good ones. +type UploadResult struct { + Image domain.Image + // AnalysisNote explains a degraded analysis, if any. + AnalysisNote string + // Err is the reason this picture was rejected. + Err error +} + +// AddImage stores one picture against a place, analyses it to produce badges, +// and links it to other pictures taken at the same place. +// +// The picture is analysed before its bytes are committed: if the payload is not +// a decodable image there is no reason to keep it. Analysis failures are +// recorded on the record rather than discarding an otherwise valid upload, +// because the picture is the valuable part and the badges can be recomputed +// with Reanalyze. +func (s *Service) AddImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, error) { + img, _, err := s.addImage(ctx, author, placeID, up) + return img, err +} + +// UploadPictures attaches several pictures to a place in one call. Each picture +// is independent, so failures are reported per picture instead of failing the +// whole batch. +func (s *Service) UploadPictures(ctx context.Context, author domain.User, placeID string, uploads []ImageUpload) ([]UploadResult, error) { + if strings.TrimSpace(placeID) == "" { + return nil, fmt.Errorf("%w: place id is required", ErrInvalidInput) + } + if len(uploads) == 0 { + return nil, fmt.Errorf("%w: no pictures were supplied", ErrInvalidInput) + } + if author.ID == "" { + return nil, fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput) + } + + results := make([]UploadResult, 0, len(uploads)) + for _, up := range uploads { + img, note, err := s.addImage(ctx, author, placeID, up) + results = append(results, UploadResult{Image: img, AnalysisNote: note, Err: err}) + if err != nil { + s.logger.Warn("rejected upload", "placeId", placeID, "filename", up.Filename, "error", err) + } + } + return results, nil +} + +// addImage is the shared implementation behind AddImage and UploadPictures. +func (s *Service) addImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, string, error) { + if strings.TrimSpace(placeID) == "" { + return domain.Image{}, "", fmt.Errorf("%w: place id is required", ErrInvalidInput) + } + if author.ID == "" { + return domain.Image{}, "", fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput) + } + if up.Body == nil { + return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput) + } + if !s.cfg.AllowsContentType(up.ContentType) { + return domain.Image{}, "", fmt.Errorf("%w: %q is not an accepted image type", + ErrUnsupportedType, up.ContentType) + } + + // Buffer one byte past the limit so an oversized payload is detectable + // without trusting the client's Content-Length. + var buf bytes.Buffer + read, err := io.Copy(&buf, io.LimitReader(up.Body, s.cfg.MaxUploadBytes+1)) + if err != nil { + return domain.Image{}, "", fmt.Errorf("%w: the picture could not be read: %v", ErrInvalidInput, err) + } + if read == 0 { + return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput) + } + if read > s.cfg.MaxUploadBytes { + return domain.Image{}, "", fmt.Errorf("%w: the picture exceeds the %d byte limit", + ErrTooLarge, s.cfg.MaxUploadBytes) + } + data := buf.Bytes() + + // Analysing first means an undecodable payload never reaches the store. + result, err := s.analyze.Analyze(ctx, analysis.Input{ + ContentType: up.ContentType, + Bytes: data, + }) + if err != nil { + return domain.Image{}, "", fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + + place, err := s.store.GetPlace(ctx, placeID) + if err != nil { + return domain.Image{}, "", mapStoreError(err, "place") + } + if err := s.store.UpsertUser(ctx, author); err != nil { + return domain.Image{}, "", mapStoreError(err, "user") + } + + ext := extensionFor(up.Filename, up.ContentType) + key := path.Join(author.ID, time.Now().UTC().Format("2006/01"), uuid.NewString()+ext) + + written, err := s.blobs.Put(ctx, key, bytes.NewReader(data), s.cfg.MaxUploadBytes) + if err != nil { + if isTooLarge(err) { + return domain.Image{}, "", fmt.Errorf("%w: %v", ErrTooLarge, err) + } + return domain.Image{}, "", fmt.Errorf("service: store picture: %w", err) + } + + // Precedence for a picture's location: an explicit override, then the EXIF + // GPS tag, then the place's own coordinate. + coordinate := effectiveCoordinate(domain.Image{Coordinate: up.Coordinate}, place.Coordinate) + if up.Coordinate == nil && result.Metadata.Coordinate != nil { + coordinate = result.Metadata.Coordinate + } + + img := domain.Image{ + PlaceID: place.ID, + StorageKey: key, + Caption: strings.TrimSpace(up.Caption), + ContentType: up.ContentType, + ByteSize: written, + Width: result.Width, + Height: result.Height, + Coordinate: coordinate, + TakenAt: result.Metadata.TakenAt, + CameraMake: result.Metadata.Make, + CameraModel: result.Metadata.Model, + Badges: result.Badges, + AnalysisStatus: domain.AnalysisCompleted, + AnalysisError: result.Note, + } + + saved, err := s.store.CreateImage(ctx, img) + if err != nil { + // Never leave an orphaned blob behind a failed insert. + if delErr := s.blobs.Delete(ctx, key); delErr != nil { + s.logger.Warn("failed to roll back image blob", "key", key, "error", delErr) + } + return domain.Image{}, "", mapStoreError(err, "image") + } + saved.URL = s.ImageURL(saved.StorageKey) + + s.publish(ctx, author, events.TopicImageAnalyzed, map[string]any{ + "imageId": saved.ID, + "placeId": place.ID, + "badges": badgeLabels(saved.Badges), + "degraded": result.Note != "", + }) + + // Linking is separate from accepting the upload, so a failure here is logged + // rather than reported as an upload error. + if err := s.linkAfterUpload(ctx, saved); err != nil { + s.logger.Warn("failed to link new picture", "imageId", saved.ID, "error", err) + } + return saved, result.Note, nil +} + +// Reanalyze runs the analysis pipeline again over a stored picture. This is how +// badges are recovered after a failure or refreshed after the analyzer changes. +func (s *Service) Reanalyze(ctx context.Context, imageID string) (domain.Image, error) { + img, err := s.store.GetImage(ctx, imageID) + if err != nil { + return domain.Image{}, mapStoreError(err, "image") + } + if img.StorageKey == "" { + return domain.Image{}, fmt.Errorf("%w: the picture has no stored bytes", ErrInvalidInput) + } + + rc, err := s.blobs.Get(ctx, img.StorageKey) + if err != nil { + return domain.Image{}, fmt.Errorf("service: read stored picture: %w", err) + } + defer rc.Close() + + // Bound the read by the configured limit even though the blob was written + // under it, so a corrupted store cannot exhaust memory. + var buf bytes.Buffer + if _, err := io.Copy(&buf, io.LimitReader(rc, s.cfg.MaxUploadBytes)); err != nil { + return domain.Image{}, fmt.Errorf("service: buffer stored picture: %w", err) + } + + result, err := s.analyze.Analyze(ctx, analysis.Input{ + ContentType: img.ContentType, + Bytes: buf.Bytes(), + }) + if err != nil { + if setErr := s.store.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, err.Error()); setErr != nil { + s.logger.Warn("failed to record analysis failure", "imageId", img.ID, "error", setErr) + } + return domain.Image{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + + if err := s.store.ReplaceBadges(ctx, img.ID, result.Badges); err != nil { + return domain.Image{}, mapStoreError(err, "badge") + } + status := domain.AnalysisCompleted + if result.Note != "" { + // Understood, but only partially, which is worth recording so the UI + // can say the badge set may be incomplete. + status = domain.AnalysisFailed + } + if err := s.store.SetAnalysisState(ctx, img.ID, status, result.Note); err != nil { + return domain.Image{}, mapStoreError(err, "image") + } + + updated, err := s.store.GetImage(ctx, img.ID) + if err != nil { + return domain.Image{}, mapStoreError(err, "image") + } + updated.URL = s.ImageURL(updated.StorageKey) + return updated, nil +} + +// OpenImage returns a reader over a picture's stored bytes for the media route. +func (s *Service) OpenImage(ctx context.Context, imageID string) (domain.Image, io.ReadCloser, error) { + img, err := s.store.GetImage(ctx, imageID) + if err != nil { + return domain.Image{}, nil, mapStoreError(err, "image") + } + rc, err := s.blobs.Get(ctx, img.StorageKey) + if err != nil { + return domain.Image{}, nil, fmt.Errorf("service: open picture: %w", err) + } + return img, rc, nil +} + +// ImageByID loads one picture, with its public URL filled in. +func (s *Service) ImageByID(ctx context.Context, id string) (domain.Image, error) { + img, err := s.store.GetImage(ctx, id) + if err != nil { + return domain.Image{}, mapStoreError(err, "image") + } + img.URL = s.ImageURL(img.StorageKey) + return img, nil +} + +// ImageByStorageKey looks a picture up by its storage key, which is what the +// media route uses to recover the declared content type and size. +func (s *Service) ImageByStorageKey(ctx context.Context, key string) (domain.Image, error) { + img, err := s.store.GetImageByStorageKey(ctx, key) + if err != nil { + return domain.Image{}, mapStoreError(err, "image") + } + img.URL = s.ImageURL(img.StorageKey) + return img, nil +} + +// ContentTypeByStorageKey returns the content type a picture was stored as, or +// an empty string when the key is unknown. +func (s *Service) ContentTypeByStorageKey(ctx context.Context, key string) (string, error) { + img, err := s.store.GetImageByStorageKey(ctx, key) + if err != nil { + return "", mapStoreError(err, "image") + } + return img.ContentType, nil +} + +// ImageSize returns the stored byte size for a storage key, or zero when the +// key is unknown. +func (s *Service) ImageSize(ctx context.Context, key string) (int64, error) { + img, err := s.store.GetImageByStorageKey(ctx, key) + if err != nil { + return 0, mapStoreError(err, "image") + } + return img.ByteSize, nil +} + +// extensionFor picks a storage extension from the filename, falling back to one +// implied by the content type. +func extensionFor(filename, contentType string) string { + if ext := strings.ToLower(path.Ext(filename)); validExtension(ext) { + return ext + } + switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + default: + return ".img" + } +} + +// validExtension keeps a client-supplied extension from becoming a path +// fragment or an unexpected suffix. +func validExtension(ext string) bool { + switch ext { + case ".jpg", ".jpeg", ".png", ".webp", ".gif": + return true + default: + return false + } +} + +// badgeLabels reduces a badge set to a distinct list of labels for the event +// payload. The local and remote analyzers can each contribute a label for the +// same subject, so duplicates are collapsed rather than published twice. +func badgeLabels(badges []domain.Badge) []string { + out := make([]string, 0, len(badges)) + seen := make(map[string]struct{}, len(badges)) + for _, b := range badges { + label := strings.TrimSpace(b.Label) + if label == "" { + continue + } + if _, dup := seen[label]; dup { + continue + } + seen[label] = struct{}{} + out = append(out, label) + } + return out +} + +func isTooLarge(err error) bool { + return err != nil && strings.Contains(err.Error(), blobstore.ErrTooLarge.Error()) +} diff --git a/api/internal/service/link.go b/api/internal/service/link.go new file mode 100644 index 0000000..b290833 --- /dev/null +++ b/api/internal/service/link.go @@ -0,0 +1,286 @@ +package service + +import ( + "context" + "sort" + + "go-places-api/internal/domain" + "go-places-api/internal/events" + "go-places-api/internal/geo" + "go-places-api/internal/store" +) + +// maxLinkedImagesPerImage bounds how many links a single picture is given, so +// a dense cluster of pictures taken from the same spot does not produce a +// quadratic blow-up in the response. +const maxLinkedImagesPerImage = 50 + +// LinkReasons explains why two pictures were linked. +const ( + // ReasonSamePlace means both pictures belong to the same place record. + ReasonSamePlace = "same-place" + // ReasonWithinRadius means the pictures were taken within the place's + // linking radius of each other, even though they belong to different + // place records. This is what catches the same spot catalogued twice. + ReasonWithinRadius = "within-radius" +) + +// linkAfterUpload links a freshly stored picture to the others taken at the +// same place. Failures are the caller's to log rather than surface, because the +// picture itself is already safely stored. +func (s *Service) linkAfterUpload(ctx context.Context, img domain.Image) error { + place, err := s.store.GetPlace(ctx, img.PlaceID) + if err != nil { + return mapStoreError(err, "place") + } + + center := effectiveCoordinate(img, place.Coordinate) + if center == nil { + // Without a location there is nothing to compare against. This is + // common and not an error. + return nil + } + + radius := place.LinkRadiusMeters + if radius <= 0 { + radius = s.cfg.DefaultLinkRadiusMeters + } + + candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ + Center: *center, + RadiusMeters: radius, + }) + if err != nil { + return mapStoreError(err, "image") + } + + links := LinksFor(img, candidates, place, radius) + if len(links) == 0 { + return nil + } + + // Persist only the cross-place relationships, since a picture inside its own + // place is already known from the place record. Storing the evidence makes + // the link discoverable without re-running the spatial query. + for _, link := range links { + if link.SamePlace { + continue + } + if err := s.store.CreateImageLink(ctx, img.ID, link.RelatedImageID, link.DistanceMeters, link.Reason); err != nil { + s.logger.Warn("failed to record image link", + "imageId", img.ID, "relatedImageId", link.RelatedImageID, "error", err) + } + } + + s.publish(ctx, domain.User{}, events.TopicImageLinked, map[string]any{ + "imageId": img.ID, + "placeId": img.PlaceID, + "links": len(links), + "distance": nearestDistance(links), + }) + return nil +} + +// LinksFor computes the links for one picture against a candidate set. It is +// exported through the service so the GraphQL layer can reuse exactly the rules +// the upload path applies. +// +// A link is created when the two pictures are within radius. Pictures in the +// same place are linked unconditionally, since sharing a place record is itself +// the evidence; pictures in different places are linked only when their capture +// points are genuinely close. +func LinksFor(img domain.Image, candidates []domain.Image, place domain.Place, radius float64) []domain.Link { + if radius <= 0 { + return nil + } + center := effectiveCoordinate(img, place.Coordinate) + if center == nil { + return nil + } + + var links []domain.Link + for _, other := range candidates { + if other.ID == img.ID { + continue + } + samePlace := other.PlaceID == img.PlaceID + if samePlace { + links = append(links, domain.Link{ + ImageID: img.ID, + RelatedImageID: other.ID, + DistanceMeters: 0, + SamePlace: true, + Reason: ReasonSamePlace, + }) + continue + } + + otherCenter := effectiveCoordinate(other, nil) + if otherCenter == nil { + continue + } + distance := geo.DistanceMeters(*center, *otherCenter) + if distance > radius { + continue + } + links = append(links, domain.Link{ + ImageID: img.ID, + RelatedImageID: other.ID, + DistanceMeters: geo.Round(distance), + SamePlace: false, + Reason: ReasonWithinRadius, + }) + } + + // Nearest first, then by identifier so the ordering is stable. + sort.SliceStable(links, func(i, j int) bool { + if links[i].DistanceMeters != links[j].DistanceMeters { + return links[i].DistanceMeters < links[j].DistanceMeters + } + return links[i].RelatedImageID < links[j].RelatedImageID + }) + if len(links) > maxLinkedImagesPerImage { + links = links[:maxLinkedImagesPerImage] + } + return links +} + +// LinkedImages returns the other pictures taken at the same place as img. +// +// Pictures within the same place record are always included. Pictures from +// other places are included when their recorded capture point lies inside the +// radius, which is what surfaces pictures catalogued under separate places at +// the same coordinates. +func (s *Service) LinkedImages(ctx context.Context, img domain.Image) ([]domain.Image, error) { + place, err := s.store.GetPlace(ctx, img.PlaceID) + if err != nil { + return nil, mapStoreError(err, "place") + } + + center := effectiveCoordinate(img, place.Coordinate) + radius := place.LinkRadiusMeters + if radius <= 0 { + radius = s.cfg.DefaultLinkRadiusMeters + } + if center == nil || radius <= 0 { + // Without a coordinate there is nothing to search with, but the + // pictures sharing this place are still linked by definition. + return siblingsOnly(img, place) + } + + seen := map[string]bool{img.ID: true} + out := make([]domain.Image, 0, 8) + + for _, sibling := range place.Images { + if seen[sibling.ID] { + continue + } + seen[sibling.ID] = true + out = append(out, decorate(sibling, s.ImageURL(sibling.StorageKey))) + } + + candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ + Center: *center, + RadiusMeters: radius, + }) + if err != nil { + return nil, mapStoreError(err, "image") + } + for _, other := range candidates { + if seen[other.ID] { + continue + } + otherCenter := effectiveCoordinate(other, nil) + if otherCenter == nil || !geo.WithinRadius(*center, *otherCenter, radius) { + continue + } + seen[other.ID] = true + out = append(out, decorate(other, s.ImageURL(other.StorageKey))) + if len(out) >= maxLinkedImagesPerImage { + break + } + } + return out, nil +} + +// siblingsOnly returns the other pictures in the same place, used when no +// coordinate is available to search with. +func siblingsOnly(img domain.Image, place domain.Place) ([]domain.Image, error) { + out := make([]domain.Image, 0, len(place.Images)) + for _, sibling := range place.Images { + if sibling.ID == img.ID { + continue + } + out = append(out, sibling) + } + return out, nil +} + +// LinksForImage returns the computed links for one picture, which is what the +// GraphQL "links" field exposes. +func (s *Service) LinksForImage(ctx context.Context, imageID string) ([]domain.Link, error) { + img, err := s.store.GetImage(ctx, imageID) + if err != nil { + return nil, mapStoreError(err, "image") + } + place, err := s.store.GetPlace(ctx, img.PlaceID) + if err != nil { + return nil, mapStoreError(err, "place") + } + + center := effectiveCoordinate(img, place.Coordinate) + radius := place.LinkRadiusMeters + if radius <= 0 { + radius = s.cfg.DefaultLinkRadiusMeters + } + if center == nil { + links := make([]domain.Link, 0, len(place.Images)) + for _, sibling := range place.Images { + if sibling.ID == img.ID { + continue + } + links = append(links, domain.Link{ + ImageID: img.ID, + RelatedImageID: sibling.ID, + SamePlace: true, + Reason: ReasonSamePlace, + }) + } + return links, nil + } + + candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ + Center: *center, + RadiusMeters: radius, + }) + if err != nil { + return nil, mapStoreError(err, "image") + } + return LinksFor(img, candidates, place, radius), nil +} + +// ImageLinks reads back the links recorded for a picture. +func (s *Service) ImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) { + links, err := s.store.ListImageLinks(ctx, imageID) + if err != nil { + return nil, mapStoreError(err, "image link") + } + return links, nil +} + +func nearestDistance(links []domain.Link) float64 { + nearest := 0.0 + for i, l := range links { + if i == 0 || l.DistanceMeters < nearest { + nearest = l.DistanceMeters + } + } + return nearest +} + +// decorate copies an image and fills in the public URL, which is derived from +// the storage key and therefore not stored. +func decorate(img domain.Image, url string) domain.Image { + img.URL = url + return img +} diff --git a/api/internal/service/place.go b/api/internal/service/place.go new file mode 100644 index 0000000..d8c2e8f --- /dev/null +++ b/api/internal/service/place.go @@ -0,0 +1,392 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "go-places-api/internal/domain" + "go-places-api/internal/events" + "go-places-api/internal/geo" + "go-places-api/internal/store" +) + +// maxTitleLength and maxDescriptionLength bound what a client can store, which +// keeps a single request from writing an unbounded blob of text. +const ( + maxTitleLength = 200 + maxDescriptionLength = 5000 + maxCommentLength = 2000 +) + +// CreatePlace stores a new place and publishes an event for it. +func (s *Service) CreatePlace(ctx context.Context, author domain.User, in domain.PlaceInput) (domain.Place, error) { + in.Title = strings.TrimSpace(in.Title) + in.Description = strings.TrimSpace(in.Description) + + if in.Title == "" { + return domain.Place{}, fmt.Errorf("%w: title is required", ErrInvalidInput) + } + if len(in.Title) > maxTitleLength { + return domain.Place{}, fmt.Errorf("%w: title exceeds %d characters", ErrInvalidInput, maxTitleLength) + } + if len(in.Description) > maxDescriptionLength { + return domain.Place{}, fmt.Errorf("%w: description exceeds %d characters", ErrInvalidInput, maxDescriptionLength) + } + + radius := s.cfg.DefaultLinkRadiusMeters + if in.LinkRadiusMeters != nil { + radius = *in.LinkRadiusMeters + } + if radius <= 0 { + return domain.Place{}, fmt.Errorf("%w: link radius must be positive", ErrInvalidInput) + } + if radius > s.cfg.MaxLinkRadiusMeters { + return domain.Place{}, fmt.Errorf("%w: link radius must not exceed %g metres", + ErrInvalidInput, s.cfg.MaxLinkRadiusMeters) + } + + place := domain.Place{ + Title: in.Title, + Description: in.Description, + Coordinate: in.Coordinate(), + LinkRadiusMeters: radius, + Creator: author, + } + + if err := s.store.UpsertUser(ctx, author); err != nil { + return domain.Place{}, mapStoreError(err, "user") + } + + created, err := s.store.CreatePlace(ctx, place) + if err != nil { + return domain.Place{}, mapStoreError(err, "place") + } + + s.publish(ctx, author, events.TopicPlaceCreated, map[string]any{ + "placeId": created.ID, + "title": created.Title, + "creator": author.ID, + }) + return created, nil +} + +// GetPlace returns one place with its pictures and comments. +// +// The pictures' public URLs are filled in here because the URL is derived from +// the storage key rather than stored, and a caller rendering this place should +// not have to know that. +func (s *Service) GetPlace(ctx context.Context, id string) (domain.Place, error) { + if strings.TrimSpace(id) == "" { + return domain.Place{}, fmt.Errorf("%w: place id is required", ErrInvalidInput) + } + place, err := s.store.GetPlace(ctx, id) + if err != nil { + return domain.Place{}, mapStoreError(err, "place") + } + place.Images = s.withImageURLs(place.Images) + + // The collections are serialised to JSON, where a nil slice becomes null. + // Callers iterate them directly, so an empty result is normalised to an + // empty slice here rather than at each read site. + if place.Images == nil { + place.Images = []domain.Image{} + } + if place.Comments == nil { + place.Comments = []domain.Comment{} + } + if place.RelatedPlaces == nil { + place.RelatedPlaces = []domain.RelatedPlace{} + } + return place, nil +} + +// withImageURLs returns a copy of images with each public URL filled in. +func (s *Service) withImageURLs(images []domain.Image) []domain.Image { + if len(images) == 0 { + return images + } + out := make([]domain.Image, 0, len(images)) + for _, img := range images { + out = append(out, decorate(img, s.ImageURL(img.StorageKey))) + } + return out +} + +// ListPlaces returns places newest first. The listing is a summary, so its +// pictures and comments are left empty; a client that needs them reads the place +// individually. +func (s *Service) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) { + limit, offset = clampPage(limit, offset) + places, err := s.store.ListPlaces(ctx, limit, offset) + if err != nil { + return nil, mapStoreError(err, "place") + } + // An empty result is returned as an empty slice rather than nil, so it + // serialises as [] and clients can iterate without a null check. + if places == nil { + return []domain.Place{}, nil + } + for i := range places { + places[i].Images = []domain.Image{} + places[i].Comments = []domain.Comment{} + places[i].RelatedPlaces = []domain.RelatedPlace{} + } + return places, nil +} + +// DeletePlace removes a place together with its pictures, deleting their blobs +// so storage does not leak. +func (s *Service) DeletePlace(ctx context.Context, id string) error { + place, err := s.store.GetPlace(ctx, id) + if err != nil { + return mapStoreError(err, "place") + } + if err := s.store.DeletePlace(ctx, id); err != nil { + return mapStoreError(err, "place") + } + for _, img := range place.Images { + if img.StorageKey == "" { + continue + } + if err := s.blobs.Delete(ctx, img.StorageKey); err != nil { + // The row is already gone; leaking the blob is worth a warning but + // not worth failing the request. + s.logger.Warn("failed to delete image blob", "imageId", img.ID, "key", img.StorageKey, "error", err) + } + } + return nil +} + +// AddComment posts a comment on a place. +func (s *Service) AddComment(ctx context.Context, author domain.User, placeID, content string) (domain.Comment, error) { + content = strings.TrimSpace(content) + if placeID == "" { + return domain.Comment{}, fmt.Errorf("%w: place id is required", ErrInvalidInput) + } + if content == "" { + return domain.Comment{}, fmt.Errorf("%w: comment content is required", ErrInvalidInput) + } + if len(content) > maxCommentLength { + return domain.Comment{}, fmt.Errorf("%w: comment exceeds %d characters", ErrInvalidInput, maxCommentLength) + } + + // Confirm the place exists so a comment cannot dangle. + if _, err := s.store.GetPlace(ctx, placeID); err != nil { + return domain.Comment{}, mapStoreError(err, "place") + } + if err := s.store.UpsertUser(ctx, author); err != nil { + return domain.Comment{}, mapStoreError(err, "user") + } + + comment, err := s.store.AddComment(ctx, domain.Comment{ + PlaceID: placeID, + Content: content, + Creator: author, + }) + if err != nil { + return domain.Comment{}, mapStoreError(err, "comment") + } + + // Find the place owner so the notification carries a real recipient rather + // than the placeholder the original scaffold used. + recipient := "" + if place, err := s.store.GetPlace(ctx, placeID); err == nil { + recipient = place.Creator.ID + } + + s.publish(ctx, author, events.TopicCommentAdded, map[string]any{ + "commentId": comment.ID, + "placeId": placeID, + "commenterId": author.ID, + "placeOwnerId": recipient, + "notifyOwner": recipient != "" && recipient != author.ID, + }) + return comment, nil +} + +// RelatedPlaces finds other places whose pictures overlap the given one, which +// is the place-level view of the same-place linking. +func (s *Service) RelatedPlaces(ctx context.Context, placeID string) ([]domain.RelatedPlace, error) { + place, err := s.store.GetPlace(ctx, placeID) + if err != nil { + return nil, mapStoreError(err, "place") + } + if len(place.Images) == 0 { + return nil, nil + } + + radius := place.LinkRadiusMeters + if radius <= 0 { + radius = s.cfg.DefaultLinkRadiusMeters + } + + // Anchor the search on each picture's own coordinates when it has them, + // otherwise on the place coordinate. + var ( + related = map[string]*domain.RelatedPlace{} + ownImages = map[string]bool{} + ) + for _, img := range place.Images { + ownImages[img.ID] = true + } + + for _, img := range place.Images { + center := effectiveCoordinate(img, place.Coordinate) + if center == nil { + continue + } + + neighbours, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ + Center: *center, + RadiusMeters: radius, + }) + if err != nil { + return nil, mapStoreError(err, "image") + } + for _, other := range neighbours { + otherCenter := effectiveCoordinate(other, nil) + if otherCenter == nil { + continue + } + distance := geo.DistanceMeters(*center, *otherCenter) + if !geo.WithinRadius(*center, *otherCenter, radius) { + continue + } + if ownImages[other.ID] || other.ID == img.ID { + continue + } + + entry, ok := related[other.PlaceID] + if !ok { + entry = &domain.RelatedPlace{DistanceMeters: distance} + related[other.PlaceID] = entry + } + if distance < entry.DistanceMeters { + entry.DistanceMeters = distance + } + entry.SharedImageIDs = append(entry.SharedImageIDs, other.ID) + } + } + + // Load the full related places so the caller receives usable records. + out := make([]domain.RelatedPlace, 0, len(related)) + for relPlaceID, rel := range related { + loaded, err := s.GetPlace(ctx, relPlaceID) + if err != nil { + s.logger.Warn("failed to load related place", "placeId", relPlaceID, "error", err) + continue + } + // A related place listing its own related places would recurse without + // end, so the nested copy carries pictures but no further relations. + loaded.RelatedPlaces = nil + rel.Place = loaded + out = append(out, *rel) + } + sortRelated(out) + return out, nil +} + +// UpdateImageCaption changes the description attached to a picture. +func (s *Service) UpdateImageCaption(ctx context.Context, imageID, caption string) (domain.Image, error) { + caption = strings.TrimSpace(caption) + if len(caption) > maxDescriptionLength { + return domain.Image{}, fmt.Errorf("%w: caption exceeds %d characters", ErrInvalidInput, maxDescriptionLength) + } + img, err := s.store.GetImage(ctx, imageID) + if err != nil { + return domain.Image{}, mapStoreError(err, "image") + } + img.Caption = caption + updated, err := s.store.UpdateImage(ctx, img) + if err != nil { + return domain.Image{}, mapStoreError(err, "image") + } + return updated, nil +} + +// ListImages returns pictures matching a filter, with each public URL filled in. +func (s *Service) ListImages(ctx context.Context, f store.ImageFilter) ([]domain.Image, error) { + f.Limit, f.Offset = clampPage(f.Limit, f.Offset) + images, err := s.store.ListImages(ctx, f) + if err != nil { + return nil, mapStoreError(err, "image") + } + return s.withImageURLs(images), nil +} + +// DeleteImage removes a picture and its stored bytes. +func (s *Service) DeleteImage(ctx context.Context, id string) error { + img, err := s.store.GetImage(ctx, id) + if err != nil { + return mapStoreError(err, "image") + } + if err := s.store.DeleteImage(ctx, id); err != nil { + return mapStoreError(err, "image") + } + if img.StorageKey != "" { + if err := s.blobs.Delete(ctx, img.StorageKey); err != nil { + s.logger.Warn("failed to delete image blob", "imageId", id, "error", err) + } + } + return nil +} + +// ImageURL builds the public URL for a stored picture. +func (s *Service) ImageURL(storageKey string) string { + if storageKey == "" { + return "" + } + if s.cfg.PublicBaseURL != "" { + return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/media/" + storageKey + } + return "/media/" + storageKey +} + +// publish sends an event, treating delivery as best effort so a broker problem +// never fails the request that produced it. +func (s *Service) publish(ctx context.Context, actor domain.User, topic events.Topic, payload map[string]any) { + body, err := marshalPayload(payload) + if err != nil { + s.logger.Warn("failed to encode event", "topic", string(topic), "error", err) + return + } + ev := events.Event{ + ID: uuid.NewString(), + Topic: topic, + OccurredAt: time.Now().UTC(), + ActorID: actor.ID, + Payload: body, + } + if err := s.events.Publish(ctx, ev); err != nil { + s.logger.Warn("failed to publish event", "topic", string(topic), "error", err) + } +} + +// effectiveCoordinate returns the picture's own capture point when it has one, +// falling back to the place it belongs to. +func effectiveCoordinate(img domain.Image, place *domain.Coordinate) *domain.Coordinate { + if img.Coordinate != nil && img.Coordinate.Valid() { + return img.Coordinate + } + if place != nil && place.Valid() { + return place + } + return nil +} + +func clampPage(limit, offset int) (int, int) { + if limit < 0 { + limit = 0 + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + +var errNoCoordinate = errors.New("no coordinate") diff --git a/api/internal/service/service.go b/api/internal/service/service.go new file mode 100644 index 0000000..2406029 --- /dev/null +++ b/api/internal/service/service.go @@ -0,0 +1,113 @@ +// Package service holds the business logic. It coordinates the store, the blob +// store, the analyzer and the event publisher, and it is the only layer that +// knows how the application's features fit together. +package service + +import ( + "context" + "errors" + "fmt" + + "go-places-api/internal/analysis" + "go-places-api/internal/blobstore" + "go-places-api/internal/config" + "go-places-api/internal/events" + "go-places-api/internal/store" +) + +// Errors surfaced to the transport layer. The HTTP and GraphQL layers map these +// onto status codes and GraphQL error extensions. +var ( + // ErrNotFound means the addressed entity does not exist. + ErrNotFound = errors.New("not found") + // ErrInvalidInput means the request was malformed or violated a rule. + ErrInvalidInput = errors.New("invalid input") + // ErrTooLarge means the upload exceeded the configured limit. + ErrTooLarge = errors.New("upload too large") + // ErrUnsupportedType means the upload's content type is not allowed. + ErrUnsupportedType = errors.New("unsupported media type") +) + +// Service is the application entry point used by the transport layers. +type Service struct { + store store.Store + blobs blobstore.BlobStore + analyze analysis.Analyzer + events events.Publisher + cfg config.Config + logger Logger +} + +// Logger is the small logging surface the service needs. Keeping it narrow +// means tests can pass a no-op without pulling in slog. +type Logger interface { + Warn(msg string, args ...any) + Info(msg string, args ...any) +} + +// Options configures a Service. +type Options struct { + Store store.Store + Blobs blobstore.BlobStore + Analyzer analysis.Analyzer + Events events.Publisher + Config config.Config + Logger Logger +} + +// New builds a Service, filling in no-op collaborators where none are supplied +// so a partially wired service still works in tests. +func New(opts Options) *Service { + s := &Service{ + store: opts.Store, + blobs: opts.Blobs, + analyze: opts.Analyzer, + events: opts.Events, + cfg: opts.Config, + logger: opts.Logger, + } + if s.analyze == nil { + s.analyze = analysis.NewLocal(analysis.DefaultMaxDimension) + } + if s.events == nil { + s.events = events.NewLogPublisher(nil) + } + if s.logger == nil { + s.logger = nopLogger{} + } + return s +} + +// Pinger is implemented by stores that can report whether they are reachable. +// It is checked by the readiness probe, and is optional so an in-memory fake +// still satisfies the contract without implementing it. +type Pinger interface { + PingContext(ctx context.Context) error +} + +// Ping reports whether the service's dependencies can serve traffic. A store +// that cannot report its health is treated as healthy. +func (s *Service) Ping(ctx context.Context) error { + p, ok := s.store.(Pinger) + if !ok { + return nil + } + return p.PingContext(ctx) +} + +// mapStoreError translates store sentinels into service sentinels. +func mapStoreError(err error, entity string) error { + switch { + case errors.Is(err, store.ErrNotFound): + return fmt.Errorf("%w: no such %s", ErrNotFound, entity) + case errors.Is(err, store.ErrConflict): + return fmt.Errorf("%w: conflicting %s", ErrInvalidInput, entity) + default: + return err + } +} + +type nopLogger struct{} + +func (nopLogger) Warn(string, ...any) {} +func (nopLogger) Info(string, ...any) {} diff --git a/api/internal/service/service_test.go b/api/internal/service/service_test.go new file mode 100644 index 0000000..91b3abb --- /dev/null +++ b/api/internal/service/service_test.go @@ -0,0 +1,836 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/color" + "image/jpeg" + "io" + "log/slog" + "path/filepath" + "testing" + + "go-places-api/internal/analysis" + "go-places-api/internal/blobstore" + "go-places-api/internal/config" + "go-places-api/internal/domain" + "go-places-api/internal/events" + "go-places-api/internal/store" +) + +// harness bundles a service with the real collaborators it needs, backed by a +// throwaway SQLite database and a temp directory. +type harness struct { + svc *Service + store *store.SQLStore + blobs *blobstore.LocalFS + user domain.User +} + +// newHarness wires a service the way main does, minus the network. +func newHarness(t *testing.T) *harness { + t.Helper() + + return newHarnessWith(t, nil) +} + +// newHarnessWith is newHarness with a hook to adjust the configuration, for +// tests that need a different limit or default. +func newHarnessWith(t *testing.T, tweak func(*config.Config)) *harness { + t.Helper() + + st, err := store.OpenSQL("file:" + filepath.Join(t.TempDir(), "test.db") + + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("store.OpenSQL: %v", err) + } + t.Cleanup(func() { st.Close() }) + + blobs, err := blobstore.NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("blobstore.NewLocalFS: %v", err) + } + + cfg := config.Default() + if tweak != nil { + tweak(&cfg) + } + + user := domain.User{ID: "user-1", Username: "ada", Email: "ada@example.com"} + svc := New(Options{ + Store: st, + Blobs: blobs, + Analyzer: analysis.NewLocal(analysis.DefaultMaxDimension), + Events: events.NewLogPublisher(slog.New(slog.DiscardHandler)), + Config: cfg, + }) + + return &harness{svc: svc, store: st, blobs: blobs, user: user} +} + +// createPlace adds a place owned by the harness user. +func (h *harness) createPlace(t *testing.T, title string, lat, lon float64) domain.Place { + t.Helper() + + in := domain.PlaceInput{Title: title, Description: "notes"} + if lat != 0 || lon != 0 { + in.Latitude, in.Longitude = &lat, &lon + } + p, err := h.svc.CreatePlace(context.Background(), h.user, in) + if err != nil { + t.Fatalf("CreatePlace(%q): %v", title, err) + } + return p +} + +// jpegBytes encodes a solid-colour JPEG of the given size. +func jpegBytes(t *testing.T, w, h int, c color.Color) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.SetRGBA(x, y, c.(color.RGBA)) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatalf("jpeg.Encode: %v", err) + } + return buf.Bytes() +} + +// skyBlue is easy for the local analyzer to recognise as sky. +var skyBlue = color.RGBA{R: 40, G: 120, B: 220, A: 255} + +// upload adds one encoded picture to a place. +func (h *harness) upload(t *testing.T, placeID string, name string, data []byte, c *domain.Coordinate) domain.Image { + t.Helper() + + img, err := h.svc.AddImage(context.Background(), h.user, placeID, ImageUpload{ + Filename: name, + ContentType: "image/jpeg", + Caption: "a caption", + Body: bytes.NewReader(data), + Coordinate: c, + }) + if err != nil { + t.Fatalf("AddImage(%q): %v", name, err) + } + return img +} + +func TestCreatePlaceAppliesDefaultsAndPersists(t *testing.T) { + t.Parallel() + + h := newHarness(t) + lat, lon := 48.8584, 2.2945 + + p, err := h.svc.CreatePlace(context.Background(), h.user, domain.PlaceInput{ + Title: " Eiffel Tower ", + Description: " went up it ", + Latitude: &lat, + Longitude: &lon, + }) + if err != nil { + t.Fatalf("CreatePlace: %v", err) + } + + // Whitespace is trimmed so a client that pads its input gets clean output. + if p.Title != "Eiffel Tower" { + t.Errorf("Title = %q, want %q", p.Title, "Eiffel Tower") + } + if p.Description != "went up it" { + t.Errorf("Description = %q", p.Description) + } + if p.LinkRadiusMeters != config.Default().DefaultLinkRadiusMeters { + t.Errorf("LinkRadiusMeters = %v, want the default %v", + p.LinkRadiusMeters, config.Default().DefaultLinkRadiusMeters) + } + if p.Coordinate == nil || p.Coordinate.Latitude != lat { + t.Errorf("Coordinate = %+v, want %v", p.Coordinate, lat) + } + if p.Creator.ID != h.user.ID { + t.Errorf("Creator = %+v, want %q", p.Creator, h.user.ID) + } + if p.ID == "" { + t.Error("CreatePlace returned no ID") + } +} + +func TestCreatePlaceRejectsBadInput(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + cfg := config.Default() + + tests := []struct { + name string + in domain.PlaceInput + }{ + {name: "no title", in: domain.PlaceInput{}}, + {name: "blank title", in: domain.PlaceInput{Title: " "}}, + {name: "title too long", in: domain.PlaceInput{Title: string(make([]byte, maxTitleLength+1))}}, + {name: "description too long", in: domain.PlaceInput{ + Title: "ok", + Description: string(make([]byte, maxDescriptionLength+1)), + }}, + {name: "zero radius", in: domain.PlaceInput{Title: "ok", LinkRadiusMeters: ptr(0.0)}}, + {name: "negative radius", in: domain.PlaceInput{Title: "ok", LinkRadiusMeters: ptr(-10.0)}}, + // A client must not be able to opt out of the radius cap. + {name: "radius above cap", in: domain.PlaceInput{ + Title: "ok", + LinkRadiusMeters: ptr(cfg.MaxLinkRadiusMeters + 1), + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := h.svc.CreatePlace(ctx, h.user, tc.in) + if !errors.Is(err, ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput", err) + } + }) + } +} + +func TestCreatePlaceHonoursExplicitRadius(t *testing.T) { + t.Parallel() + + h := newHarness(t) + radius := 750.0 + p, err := h.svc.CreatePlace(context.Background(), h.user, domain.PlaceInput{ + Title: "Wide", LinkRadiusMeters: &radius, + }) + if err != nil { + t.Fatalf("CreatePlace: %v", err) + } + if p.LinkRadiusMeters != radius { + t.Errorf("LinkRadiusMeters = %v, want %v", p.LinkRadiusMeters, radius) + } +} + +func TestAddImageStoresAnalysesAndLinks(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Eiffel", 48.8584, 2.2945) + + data := jpegBytes(t, 64, 48, skyBlue) + img := h.upload(t, place.ID, "sky.jpg", data, nil) + + if img.ID == "" { + t.Error("no image ID assigned") + } + if img.ByteSize != int64(len(data)) { + t.Errorf("ByteSize = %d, want %d", img.ByteSize, len(data)) + } + if img.Width != 64 || img.Height != 48 { + t.Errorf("dimensions = %dx%d, want 64x48", img.Width, img.Height) + } + if img.AnalysisStatus != domain.AnalysisCompleted { + t.Errorf("AnalysisStatus = %q, want %q", img.AnalysisStatus, domain.AnalysisCompleted) + } + if len(img.Badges) == 0 { + t.Error("a plain blue picture should produce at least one badge") + } + if img.Caption != "a caption" { + t.Errorf("Caption = %q", img.Caption) + } + + // The blob must be readable under a key derived from the author, and the + // client-facing URL must not leak the storage key. + if img.URL == "" { + t.Error("URL was not set") + } + if img.URL == img.StorageKey { + t.Error("URL should not be the raw storage key") + } + rc, err := h.blobs.Get(ctx, img.StorageKey) + if err != nil { + t.Fatalf("blobs.Get(%q): %v", img.StorageKey, err) + } + defer rc.Close() + stored, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read blob: %v", err) + } + if !bytes.Equal(stored, data) { + t.Errorf("stored %d bytes, want the %d uploaded", len(stored), len(data)) + } +} + +func TestAddImageRejectsUnusableUploads(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Guarded", 48.85, 2.29) + good := jpegBytes(t, 8, 8, skyBlue) + + tests := []struct { + name string + up ImageUpload + want error + }{ + {name: "no place", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(good)}, want: ErrInvalidInput}, + {name: "no body", up: ImageUpload{ContentType: "image/jpeg"}, want: ErrInvalidInput}, + {name: "empty body", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(nil)}, want: ErrInvalidInput}, + {name: "no author", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(good)}, want: ErrInvalidInput}, + {name: "bad content type", up: ImageUpload{ContentType: "text/plain", Body: bytes.NewReader(good)}, want: ErrUnsupportedType}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + author := h.user + placeID := place.ID + if tc.name == "no author" { + author = domain.User{} + } + if tc.name == "no place" { + placeID = "" + } + + _, err := h.svc.AddImage(ctx, author, placeID, tc.up) + if !errors.Is(err, tc.want) { + t.Errorf("err = %v, want %v", err, tc.want) + } + }) + } +} + +func TestAddImageRejectsUnknownPlace(t *testing.T) { + t.Parallel() + + h := newHarness(t) + _, err := h.svc.AddImage(context.Background(), h.user, "no-such-place", ImageUpload{ + ContentType: "image/jpeg", + Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue)), + }) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestAddImageRejectsUndecodablePayload(t *testing.T) { + t.Parallel() + + // A payload with a valid content type but corrupt bytes must not reach + // storage, because analysis runs first. + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Corrupt", 10, 10) + + _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{ + Filename: "fake.jpg", + ContentType: "image/jpeg", + Body: bytes.NewReader([]byte("this is definitely not a JPEG")), + }) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } + + images, err := h.store.ListImages(ctx, store.ImageFilter{PlaceID: place.ID}) + if err != nil { + t.Fatalf("ListImages: %v", err) + } + if len(images) != 0 { + t.Errorf("%d images were stored for an undecodable upload", len(images)) + } +} + +func TestAddImageEnforcesMaxUploadBytes(t *testing.T) { + t.Parallel() + + // The limit must be enforced by reading the stream, not by trusting a + // declared Content-Length, so a body over the limit is rejected even though + // the JPEG itself decodes fine. + h := newHarnessWith(t, func(c *config.Config) { c.MaxUploadBytes = 2048 }) + ctx := context.Background() + place := h.createPlace(t, "Sized", 10, 10) + + valid := jpegBytes(t, 32, 32, skyBlue) + oversized := append(append([]byte(nil), valid...), make([]byte, 8<<10)...) + + if _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{ + Filename: "huge.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(oversized), + }); !errors.Is(err, ErrTooLarge) { + t.Fatalf("err = %v, want ErrTooLarge", err) + } + + // A body at or under the limit is still accepted, so the check is not simply + // rejecting anything that does not fit in a fixed buffer. + small := jpegBytes(t, 8, 8, skyBlue) + if int64(len(small)) >= 2048 { + t.Skipf("the encoder produced %d bytes, over the 2048 byte test limit", len(small)) + } + if _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{ + Filename: "small.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(small), + }); err != nil { + t.Errorf("a body under the limit was rejected: %v", err) + } +} + +func TestAddImagePrefersExplicitCoordinateThenFallsBackToPlace(t *testing.T) { + t.Parallel() + + h := newHarness(t) + place := h.createPlace(t, "Located", 48.8584, 2.2945) + data := jpegBytes(t, 16, 16, skyBlue) + + // No override: the picture inherits the place's location so it can be linked. + inherited := h.upload(t, place.ID, "inherited.jpg", data, nil) + if inherited.Coordinate == nil { + t.Fatal("picture with no override has no coordinate") + } + if d := inherited.Coordinate.Latitude - 48.8584; d > 1e-6 || d < -1e-6 { + t.Errorf("Latitude = %v, want the place's 48.8584", inherited.Coordinate.Latitude) + } + + // An explicit override wins over the place. + override := domain.Coordinate{Latitude: 10, Longitude: 20} + explicit := h.upload(t, place.ID, "explicit.jpg", data, &override) + if explicit.Coordinate == nil || explicit.Coordinate.Latitude != 10 { + t.Errorf("Coordinate = %+v, want the override 10/20", explicit.Coordinate) + } +} + +func TestPicturesInOnePlaceAreLinkedToEachOther(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Same Place", 48.8584, 2.2945) + data := jpegBytes(t, 16, 16, skyBlue) + + first := h.upload(t, place.ID, "a.jpg", data, nil) + second := h.upload(t, place.ID, "b.jpg", data, nil) + + links, err := h.svc.LinksForImage(ctx, second.ID) + if err != nil { + t.Fatalf("LinksForImage: %v", err) + } + if len(links) != 1 { + t.Fatalf("got %d links, want 1", len(links)) + } + if links[0].RelatedImageID != first.ID { + t.Errorf("linked to %q, want %q", links[0].RelatedImageID, first.ID) + } + if !links[0].SamePlace { + t.Error("pictures sharing a place record should report SamePlace") + } + if links[0].Reason != ReasonSamePlace { + t.Errorf("Reason = %q, want %q", links[0].Reason, ReasonSamePlace) + } +} + +func TestPicturesAtTheSameCoordinatesAcrossPlacesAreLinked(t *testing.T) { + t.Parallel() + + // The headline feature: the same spot catalogued under two separate places + // should still be recognised as the same location. + h := newHarness(t) + ctx := context.Background() + data := jpegBytes(t, 16, 16, skyBlue) + + firstPlace := h.createPlace(t, "Eiffel (visit 1)", 48.8584, 2.2945) + secondPlace := h.createPlace(t, "Eiffel (visit 2)", 48.8585, 2.2946) + + a := h.upload(t, firstPlace.ID, "a.jpg", data, nil) + b := h.upload(t, secondPlace.ID, "b.jpg", data, nil) + + links, err := h.svc.LinksForImage(ctx, b.ID) + if err != nil { + t.Fatalf("LinksForImage: %v", err) + } + if len(links) != 1 { + t.Fatalf("got %d links, want 1", len(links)) + } + if links[0].RelatedImageID != a.ID { + t.Errorf("linked to %q, want %q", links[0].RelatedImageID, a.ID) + } + if links[0].SamePlace { + t.Error("pictures in different places should not report SamePlace") + } + if links[0].Reason != ReasonWithinRadius { + t.Errorf("Reason = %q, want %q", links[0].Reason, ReasonWithinRadius) + } + // A few tens of metres apart, so the distance should be small but non-zero. + if links[0].DistanceMeters <= 0 || links[0].DistanceMeters > 150 { + t.Errorf("DistanceMeters = %v, want a small positive distance", links[0].DistanceMeters) + } +} + +func TestPicturesBeyondRadiusAreNotLinked(t *testing.T) { + t.Parallel() + + // Roughly 3 km apart with the default 150 m radius: not the same place. + h := newHarness(t) + ctx := context.Background() + data := jpegBytes(t, 16, 16, skyBlue) + + near := h.createPlace(t, "Near Spot", 48.8584, 2.2945) + far := h.createPlace(t, "Far Spot", 48.8850, 2.3000) + + h.upload(t, near.ID, "near.jpg", data, nil) + other := h.upload(t, far.ID, "far.jpg", data, nil) + + links, err := h.svc.LinksForImage(ctx, other.ID) + if err != nil { + t.Fatalf("LinksForImage: %v", err) + } + if len(links) != 0 { + t.Errorf("got %d links between points 3 km apart, want 0: %+v", len(links), links) + } +} + +func TestWideRadiusLinksDistantPictures(t *testing.T) { + t.Parallel() + + // A place that opts into a 5 km radius should match a spot ~3 km away. + h := newHarness(t) + ctx := context.Background() + data := jpegBytes(t, 16, 16, skyBlue) + + radius := 5000.0 + in := domain.PlaceInput{Title: "Wide Radius", LinkRadiusMeters: &radius} + latA, lonA := 48.8584, 2.2945 + in.Latitude, in.Longitude = &latA, &lonA + wide, err := h.svc.CreatePlace(ctx, h.user, in) + if err != nil { + t.Fatalf("CreatePlace: %v", err) + } + near := h.createPlace(t, "Nearby", 48.8584, 2.2945) + far := h.createPlace(t, "Distant", 48.8850, 2.3000) + + h.upload(t, near.ID, "near.jpg", data, nil) + other := h.upload(t, far.ID, "far.jpg", data, nil) + + // The link is evaluated from the wide place's perspective. + wideImg := h.upload(t, wide.ID, "wide.jpg", data, nil) + links, err := h.svc.LinksForImage(ctx, wideImg.ID) + if err != nil { + t.Fatalf("LinksForImage: %v", err) + } + if len(links) == 0 { + t.Fatal("a 5 km radius should link pictures ~3 km apart") + } + if other.ID == "" { + t.Fatal("test setup did not produce the distant picture") + } +} + +func TestLinksForIgnoresSelfAndNeedsACentre(t *testing.T) { + t.Parallel() + + img := domain.Image{ID: "a", PlaceID: "p"} + same := domain.Image{ID: "a", PlaceID: "p"} + place := domain.Place{ID: "p", LinkRadiusMeters: 150} + + // No coordinate anywhere: there is nothing to compare. + if links := LinksFor(img, []domain.Image{same}, place, 150); len(links) != 0 { + t.Errorf("got %d links without a centre, want 0", len(links)) + } + // A non-positive radius disables linking. + if links := LinksFor(img, nil, place, 0); len(links) != 0 { + t.Errorf("got %d links with radius 0, want 0", len(links)) + } +} + +func TestUploadPicturesReportsFailuresPerPicture(t *testing.T) { + t.Parallel() + + // One bad file in a batch must not discard the good ones. + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Batch", 10, 10) + good := jpegBytes(t, 16, 16, skyBlue) + + results, err := h.svc.UploadPictures(ctx, h.user, place.ID, []ImageUpload{ + {Filename: "ok.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(good)}, + {Filename: "bad.jpg", ContentType: "image/jpeg", Body: bytes.NewReader([]byte("nope"))}, + {Filename: "ok2.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(good)}, + }) + if err != nil { + t.Fatalf("UploadPictures returned a batch error: %v", err) + } + if len(results) != 3 { + t.Fatalf("got %d results, want 3", len(results)) + } + if results[0].Err != nil { + t.Errorf("result 0: %v", results[0].Err) + } + if !errors.Is(results[1].Err, ErrInvalidInput) { + t.Errorf("result 1 err = %v, want ErrInvalidInput", results[1].Err) + } + if results[2].Err != nil { + t.Errorf("result 2: %v", results[2].Err) + } + + images, err := h.store.ListImages(ctx, store.ImageFilter{PlaceID: place.ID}) + if err != nil { + t.Fatalf("ListImages: %v", err) + } + if len(images) != 2 { + t.Errorf("%d images stored, want the 2 valid ones", len(images)) + } +} + +func TestUploadPicturesValidatesTheBatch(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Batch Guards", 10, 10) + + if _, err := h.svc.UploadPictures(ctx, h.user, "", []ImageUpload{ + {ContentType: "image/jpeg", Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue))}, + }); !errors.Is(err, ErrInvalidInput) { + t.Errorf("empty place id: err = %v, want ErrInvalidInput", err) + } + if _, err := h.svc.UploadPictures(ctx, h.user, place.ID, nil); !errors.Is(err, ErrInvalidInput) { + t.Errorf("no uploads: err = %v, want ErrInvalidInput", err) + } + if _, err := h.svc.UploadPictures(ctx, domain.User{}, place.ID, []ImageUpload{ + {ContentType: "image/jpeg", Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue))}, + }); !errors.Is(err, ErrInvalidInput) { + t.Errorf("no author: err = %v, want ErrInvalidInput", err) + } +} + +func TestReanalyzeRecoversBadges(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Reanalyzed", 10, 10) + data := jpegBytes(t, 32, 32, skyBlue) + img := h.upload(t, place.ID, "sky.jpg", data, nil) + + // Simulate an earlier failure that left the record without badges. + if err := h.store.ReplaceBadges(ctx, img.ID, nil); err != nil { + t.Fatalf("ReplaceBadges: %v", err) + } + + got, err := h.svc.Reanalyze(ctx, img.ID) + if err != nil { + t.Fatalf("Reanalyze: %v", err) + } + if got.AnalysisStatus != domain.AnalysisCompleted { + t.Errorf("AnalysisStatus = %q", got.AnalysisStatus) + } + if len(got.Badges) == 0 { + t.Error("Reanalyze did not produce badges") + } +} + +func TestReanalyzeMissingImage(t *testing.T) { + t.Parallel() + + h := newHarness(t) + if _, err := h.svc.Reanalyze(context.Background(), "nope"); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestOpenImageReturnsStoredBytes(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Served", 10, 10) + data := jpegBytes(t, 16, 16, skyBlue) + img := h.upload(t, place.ID, "served.jpg", data, nil) + + served, rc, err := h.svc.OpenImage(ctx, img.ID) + if err != nil { + t.Fatalf("OpenImage: %v", err) + } + if served.ID != img.ID { + t.Errorf("OpenImage returned %q, want %q", served.ID, img.ID) + } + defer rc.Close() + got, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(got, data) { + t.Errorf("got %d bytes, want %d", len(got), len(data)) + } +} + +func TestDeletePlaceRemovesBlobs(t *testing.T) { + t.Parallel() + + // Deleting a place has to delete its blobs too, or storage grows forever. + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Temporary", 10, 10) + data := jpegBytes(t, 16, 16, skyBlue) + img := h.upload(t, place.ID, "temp.jpg", data, nil) + + if err := h.svc.DeletePlace(ctx, place.ID); err != nil { + t.Fatalf("DeletePlace: %v", err) + } + if _, err := h.blobs.Get(ctx, img.StorageKey); err == nil { + t.Error("the blob survived the place deletion") + } + if _, err := h.svc.GetPlace(ctx, place.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestDeleteImageRemovesBlobAndRecord(t *testing.T) { + t.Parallel() + + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Disposable", 10, 10) + data := jpegBytes(t, 16, 16, skyBlue) + img := h.upload(t, place.ID, "one.jpg", data, nil) + + if err := h.svc.DeleteImage(ctx, img.ID); err != nil { + t.Fatalf("DeleteImage: %v", err) + } + if _, err := h.blobs.Get(ctx, img.StorageKey); err == nil { + t.Error("the blob survived the image deletion") + } + if _, err := h.svc.ImageByID(ctx, img.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestPingSucceeds(t *testing.T) { + t.Parallel() + + h := newHarness(t) + if err := h.svc.Ping(context.Background()); err != nil { + t.Errorf("Ping: %v", err) + } +} + +func TestStorageKeyIsScopedToAuthorAndDate(t *testing.T) { + t.Parallel() + + // The key layout must not let a client choose where bytes land. + h := newHarness(t) + ctx := context.Background() + place := h.createPlace(t, "Keyed", 10, 10) + + img := h.upload(t, place.ID, "../../../etc/passwd.jpg", jpegBytes(t, 8, 8, skyBlue), nil) + if !hasPathPrefix(img.StorageKey, h.user.ID) { + t.Errorf("StorageKey = %q, want it under the author %q", img.StorageKey, h.user.ID) + } + if containsDotDot(img.StorageKey) { + t.Errorf("StorageKey = %q contains a traversal segment", img.StorageKey) + } + _, err := h.svc.GetPlace(ctx, place.ID) + if err != nil { + t.Fatalf("GetPlace: %v", err) + } +} + +func TestExtensionComesFromTypeNotFilename(t *testing.T) { + t.Parallel() + + // A client-supplied name must not be able to choose the stored extension. + h := newHarness(t) + place := h.createPlace(t, "Extensions", 10, 10) + + img := h.upload(t, place.ID, "payload.php", jpegBytes(t, 8, 8, skyBlue), nil) + if got := img.StorageKey; !hasSuffix(got, ".jpg") { + t.Errorf("StorageKey = %q, want a .jpg extension", got) + } +} + +func TestBadgeLabelsAreDistinct(t *testing.T) { + t.Parallel() + + got := badgeLabels([]domain.Badge{ + {Label: "sky"}, {Label: "water"}, {Label: "sky"}, {Label: ""}, + }) + seen := map[string]bool{} + for _, l := range got { + if l == "" { + t.Error("an empty label was returned") + } + if seen[l] { + t.Errorf("label %q appeared twice", l) + } + seen[l] = true + } + if len(got) != 2 { + t.Errorf("got %v, want 2 distinct labels", got) + } +} + +func TestImageURLDoesNotEscapeBase(t *testing.T) { + t.Parallel() + + h := newHarness(t) + place := h.createPlace(t, "URLs", 10, 10) + img := h.upload(t, place.ID, "u.jpg", jpegBytes(t, 8, 8, skyBlue), nil) + if img.URL == "" { + t.Fatal("URL is empty") + } + if containsDotDot(img.URL) { + t.Errorf("URL = %q contains a traversal segment", img.URL) + } +} + +func TestRelatedPlacesFindsOverlap(t *testing.T) { + t.Parallel() + + // Two places whose pictures sit at the same spot should be reported as + // related, which is how the UI suggests merging duplicate places. + h := newHarness(t) + ctx := context.Background() + data := jpegBytes(t, 16, 16, skyBlue) + + first := h.createPlace(t, "Dupe A", 48.8584, 2.2945) + second := h.createPlace(t, "Dupe B", 48.8585, 2.2946) + + h.upload(t, first.ID, "a.jpg", data, nil) + h.upload(t, second.ID, "b.jpg", data, nil) + + related, err := h.svc.RelatedPlaces(ctx, first.ID) + if err != nil { + t.Fatalf("RelatedPlaces: %v", err) + } + if len(related) != 1 { + t.Fatalf("got %d related places, want 1", len(related)) + } + if related[0].Place.ID != second.ID { + t.Errorf("related place = %q, want %q", related[0].Place.ID, second.ID) + } + if len(related[0].SharedImageIDs) == 0 { + t.Error("expected shared image evidence") + } + if related[0].DistanceMeters > 150 { + t.Errorf("DistanceMeters = %v, want a small distance", related[0].DistanceMeters) + } +} + +// ptr returns a pointer to v, for the optional PlaceInput fields. +func ptr[T any](v T) *T { return &v } + +func hasPathPrefix(s, prefix string) bool { + return len(s) > len(prefix) && s[:len(prefix)+1] == prefix+"/" +} + +func hasSuffix(s, suffix string) bool { + return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix +} + +func containsDotDot(s string) bool { + return bytes.Contains([]byte(s), []byte("..")) +} + +var _ = fmt.Sprintf // keep fmt available for ad-hoc debugging in this file diff --git a/api/internal/store/sql.go b/api/internal/store/sql.go new file mode 100644 index 0000000..1ca5b8e --- /dev/null +++ b/api/internal/store/sql.go @@ -0,0 +1,795 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + _ "modernc.org/sqlite" // registers the "sqlite" driver, CGO-free + + "go-places-api/internal/domain" +) + +// SQLStore is the SQLite-backed implementation of Store. +type SQLStore struct { + db *sql.DB +} + +var _ Store = (*SQLStore)(nil) + +// OpenSQL opens the SQLite database at dsn and applies the schema. The dsn is a +// SQLite connection string such as "file:go-places.db?_pragma=foreign_keys(1)". +func OpenSQL(dsn string) (*SQLStore, error) { + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("store: open database: %w", err) + } + + // SQLite serialises writers. A single connection avoids "database is + // locked" errors under concurrent uploads without needing a write mutex. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(time.Hour) + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("store: ping database: %w", err) + } + + s := &SQLStore{db: db} + if err := s.migrate(context.Background()); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +// Close releases the underlying database handle. +func (s *SQLStore) Close() error { return s.db.Close() } + +// DB exposes the handle for health checks. +func (s *SQLStore) DB() *sql.DB { return s.db } + +const schema = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS places ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + latitude REAL, + longitude REAL, + link_radius_meters REAL NOT NULL DEFAULT 150, + creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_places_creator ON places(creator_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_places_geo ON places(latitude, longitude); + +CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE, + storage_key TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + caption TEXT NOT NULL DEFAULT '', + latitude REAL, + longitude REAL, + taken_at TEXT, + camera_make TEXT NOT NULL DEFAULT '', + camera_model TEXT NOT NULL DEFAULT '', + analysis_status TEXT NOT NULL DEFAULT 'pending', + analysis_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_images_place ON images(place_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_images_created ON images(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_images_geo ON images(latitude, longitude); + +CREATE TABLE IF NOT EXISTS badges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, + label TEXT NOT NULL, + display TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + UNIQUE(image_id, label, source) +); +CREATE INDEX IF NOT EXISTS idx_badges_image ON badges(image_id); + +CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE, + content TEXT NOT NULL, + creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_comments_place ON comments(place_id, created_at); + +-- Evidence that two pictures were taken at the same place. Both directions are +-- stored so a link is discoverable from either picture. +CREATE TABLE IF NOT EXISTS image_links ( + image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, + related_image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, + distance_meters REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + PRIMARY KEY (image_id, related_image_id) +); +CREATE INDEX IF NOT EXISTS idx_image_links_related ON image_links(related_image_id); +` + +func (s *SQLStore) migrate(ctx context.Context) error { + if _, err := s.db.ExecContext(ctx, schema); err != nil { + return fmt.Errorf("store: apply schema: %w", err) + } + return nil +} + +// ---------------------------------------------------------------- places --- + +func (s *SQLStore) CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error) { + if p.ID == "" { + p.ID = newID() + } + now := time.Now().UTC() + p.CreatedAt = now + // Timestamps are stored as RFC3339 so the schema stays portable to engines + // without a native timestamp type. + _, err := s.db.ExecContext(ctx, ` + INSERT INTO places (id, title, description, latitude, longitude, + link_radius_meters, creator_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Title, p.Description, + nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }), + nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }), + p.LinkRadiusMeters, p.Creator.ID, + formatTime(p.CreatedAt), formatTime(now), + ) + if err != nil { + return domain.Place{}, wrapWrite(err, "place") + } + return s.GetPlace(ctx, p.ID) +} + +func (s *SQLStore) GetPlace(ctx context.Context, id string) (domain.Place, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT p.id, p.title, p.description, p.latitude, p.longitude, + p.link_radius_meters, p.created_at, + u.id, u.username, u.email + FROM places p + JOIN users u ON u.id = p.creator_id + WHERE p.id = ?`, id) + + p, err := scanPlace(row) + if err != nil { + return domain.Place{}, err + } + + images, err := s.ListImages(ctx, ImageFilter{PlaceID: id}) + if err != nil { + return domain.Place{}, err + } + comments, err := s.ListComments(ctx, id) + if err != nil { + return domain.Place{}, err + } + p.Images = images + p.Comments = comments + return p, nil +} + +func (s *SQLStore) UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error) { + res, err := s.db.ExecContext(ctx, ` + UPDATE places + SET title = ?, description = ?, latitude = ?, longitude = ?, + link_radius_meters = ?, updated_at = ? + WHERE id = ?`, + p.Title, p.Description, + nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }), + nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }), + p.LinkRadiusMeters, formatTime(time.Now().UTC()), p.ID, + ) + if err != nil { + return domain.Place{}, wrapWrite(err, "place") + } + if n, _ := res.RowsAffected(); n == 0 { + return domain.Place{}, ErrNotFound + } + return s.GetPlace(ctx, p.ID) +} + +func (s *SQLStore) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) { + query := ` + SELECT p.id, p.title, p.description, p.latitude, p.longitude, + p.link_radius_meters, p.created_at, + u.id, u.username, u.email + FROM places p + JOIN users u ON u.id = p.creator_id + ORDER BY p.created_at DESC` + args := []any{} + if limit > 0 { + query += " LIMIT ?" + args = append(args, limit) + if offset > 0 { + query += " OFFSET ?" + args = append(args, offset) + } + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("store: list places: %w", err) + } + defer rows.Close() + + var out []domain.Place + for rows.Next() { + p, err := scanPlace(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *SQLStore) DeletePlace(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: delete place: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// ---------------------------------------------------------------- images --- + +func (s *SQLStore) CreateImage(ctx context.Context, img domain.Image) (domain.Image, error) { + if img.ID == "" { + img.ID = newID() + } + if img.AnalysisStatus == "" { + img.AnalysisStatus = domain.AnalysisPending + } + if img.CreatedAt.IsZero() { + img.CreatedAt = time.Now().UTC() + } + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO images (id, place_id, storage_key, content_type, byte_size, + width, height, caption, latitude, longitude, taken_at, + camera_make, camera_model, analysis_status, analysis_error, + created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + img.ID, img.PlaceID, img.StorageKey, img.ContentType, img.ByteSize, + img.Width, img.Height, img.Caption, + nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }), + nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }), + nullableTime(img.TakenAt), + img.CameraMake, img.CameraModel, string(img.AnalysisStatus), img.AnalysisError, + formatTime(img.CreatedAt), + ) + if err != nil { + return domain.Image{}, wrapWrite(err, "image") + } + + if len(img.Badges) > 0 { + if err := s.ReplaceBadges(ctx, img.ID, img.Badges); err != nil { + return domain.Image{}, err + } + } + return s.GetImage(ctx, img.ID) +} + +func (s *SQLStore) GetImage(ctx context.Context, id string) (domain.Image, error) { + row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.id = ?`, id) + img, err := scanImage(row) + if err != nil { + return domain.Image{}, err + } + badges, err := s.badgesFor(ctx, []string{id}) + if err != nil { + return domain.Image{}, err + } + img.Badges = badges[id] + return img, nil +} + +func (s *SQLStore) GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error) { + row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.storage_key = ? LIMIT 1`, key) + img, err := scanImage(row) + if err != nil { + return domain.Image{}, err + } + badges, err := s.badgesFor(ctx, []string{img.ID}) + if err != nil { + return domain.Image{}, err + } + img.Badges = badges[img.ID] + return img, nil +} + +// Ping verifies the database is reachable. It backs the readiness probe, which +// reports the service as unavailable without restarting it. +func (s *SQLStore) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } + +// PingContext is an alias for Ping kept for callers that name the method after +// database/sql's own convention. +func (s *SQLStore) PingContext(ctx context.Context) error { return s.db.PingContext(ctx) } + +func (s *SQLStore) UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error) { + res, err := s.db.ExecContext(ctx, ` + UPDATE images + SET caption = ?, latitude = ?, longitude = ?, taken_at = ?, + camera_make = ?, camera_model = ?, + analysis_status = ?, analysis_error = ? + WHERE id = ?`, + img.Caption, + nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }), + nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }), + nullableTime(img.TakenAt), + img.CameraMake, img.CameraModel, + string(img.AnalysisStatus), img.AnalysisError, img.ID, + ) + if err != nil { + return domain.Image{}, wrapWrite(err, "image") + } + if n, _ := res.RowsAffected(); n == 0 { + return domain.Image{}, ErrNotFound + } + return s.GetImage(ctx, img.ID) +} + +func (s *SQLStore) ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error) { + var ( + where []string + args []any + ) + if f.PlaceID != "" { + where = append(where, "i.place_id = ?") + args = append(args, f.PlaceID) + } + if f.CreatorID != "" { + where = append(where, "p.creator_id = ?") + args = append(args, f.CreatorID) + } + if f.Within != nil { + // Pre-filter with the bounding box; the exact haversine test is applied + // by the service layer afterwards. + b := f.Within.BoundingBox() + where = append(where, + "i.latitude IS NOT NULL", "i.longitude IS NOT NULL", + "i.latitude BETWEEN ? AND ?", "i.longitude BETWEEN ? AND ?") + args = append(args, b.MinLat, b.MaxLat, b.MinLon, b.MaxLon) + } + + query := imageSelect + if len(where) > 0 { + query += " WHERE " + strings.Join(where, " AND ") + } + query += " ORDER BY i.created_at DESC" + if f.Limit > 0 { + query += " LIMIT ?" + args = append(args, f.Limit) + if f.Offset > 0 { + query += " OFFSET ?" + args = append(args, f.Offset) + } + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("store: list images: %w", err) + } + defer rows.Close() + + var out []domain.Image + var ids []string + for rows.Next() { + img, err := scanImage(rows) + if err != nil { + return nil, err + } + out = append(out, img) + ids = append(ids, img.ID) + } + if err := rows.Err(); err != nil { + return nil, err + } + + badges, err := s.badgesFor(ctx, ids) + if err != nil { + return nil, err + } + for i := range out { + out[i].Badges = badges[out[i].ID] + } + return out, nil +} + +func (s *SQLStore) ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error) { + return s.ListImages(ctx, ImageFilter{Within: &q}) +} + +func (s *SQLStore) ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("store: begin badge transaction: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM badges WHERE image_id = ?`, imageID); err != nil { + return fmt.Errorf("store: clear badges: %w", err) + } + for _, b := range badges { + if _, err := tx.ExecContext(ctx, ` + INSERT OR REPLACE INTO badges (image_id, label, display, confidence, source) + VALUES (?, ?, ?, ?, ?)`, + imageID, b.Label, b.Display, b.Confidence, b.Source, + ); err != nil { + return fmt.Errorf("store: insert badge: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: commit badges: %w", err) + } + return nil +} + +func (s *SQLStore) SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error { + res, err := s.db.ExecContext(ctx, + `UPDATE images SET analysis_status = ?, analysis_error = ? WHERE id = ?`, + string(status), failure, imageID) + if err != nil { + return fmt.Errorf("store: set analysis state: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLStore) DeleteImage(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: delete image: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// -------------------------------------------------------------- image links --- + +func (s *SQLStore) CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error { + if imageID == relatedImageID { + return fmt.Errorf("%w: a picture cannot be linked to itself", ErrInvalidInput) + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO image_links (image_id, related_image_id, distance_meters, reason, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(image_id, related_image_id) DO UPDATE SET + distance_meters = excluded.distance_meters, + reason = excluded.reason`, + imageID, relatedImageID, distanceMeters, reason, formatTime(time.Now().UTC())) + if err != nil { + return wrapWrite(err, "image link") + } + return nil +} + +func (s *SQLStore) ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) { + // The stored rows record the direction they were written in, so this query + // flips the orientation for rows where the picture is the related end. + rows, err := s.db.QueryContext(ctx, ` + SELECT image_id, related_image_id, distance_meters, reason, + CASE WHEN image_id = ? THEN 0 ELSE 1 END AS direction + FROM image_links + WHERE image_id = ? OR related_image_id = ? + ORDER BY distance_meters ASC, related_image_id ASC`, imageID, imageID, imageID) + if err != nil { + return nil, fmt.Errorf("store: list image links: %w", err) + } + defer rows.Close() + + var out []domain.Link + for rows.Next() { + var ( + l domain.Link + storedA string + storedB string + direction int + ) + if err := rows.Scan(&storedA, &storedB, &l.DistanceMeters, &l.Reason, &direction); err != nil { + return nil, fmt.Errorf("store: scan image link: %w", err) + } + if direction == 0 { + l.ImageID, l.RelatedImageID = storedA, storedB + } else { + l.ImageID, l.RelatedImageID = storedB, storedA + } + l.SamePlace = l.Reason == "same-place" + out = append(out, l) + } + return out, rows.Err() +} + +// -------------------------------------------------------------- comments --- + +func (s *SQLStore) AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error) { + if c.ID == "" { + c.ID = newID() + } + if c.CreatedAt.IsZero() { + c.CreatedAt = time.Now().UTC() + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO comments (id, place_id, content, creator_id, created_at) + VALUES (?, ?, ?, ?, ?)`, + c.ID, c.PlaceID, c.Content, c.Creator.ID, formatTime(c.CreatedAt)) + if err != nil { + return domain.Comment{}, wrapWrite(err, "comment") + } + return c, nil +} + +func (s *SQLStore) ListComments(ctx context.Context, placeID string) ([]domain.Comment, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT c.id, c.place_id, c.content, c.created_at, + u.id, u.username, u.email + FROM comments c + JOIN users u ON u.id = c.creator_id + WHERE c.place_id = ? + ORDER BY c.created_at`, placeID) + if err != nil { + return nil, fmt.Errorf("store: list comments: %w", err) + } + defer rows.Close() + + var out []domain.Comment + for rows.Next() { + var ( + c domain.Comment + created timeText + ) + err := rows.Scan(&c.ID, &c.PlaceID, &c.Content, &created, &c.Creator.ID, &c.Creator.Username, &c.Creator.Email) + if err != nil { + return nil, fmt.Errorf("store: scan comment: %w", err) + } + c.CreatedAt = created.Time + out = append(out, c) + } + return out, rows.Err() +} + +// ----------------------------------------------------------------- users --- + +func (s *SQLStore) UpsertUser(ctx context.Context, u domain.User) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO users (id, username, email, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + username = CASE WHEN excluded.username = '' THEN users.username ELSE excluded.username END, + email = CASE WHEN excluded.email = '' THEN users.email ELSE excluded.email END`, + u.ID, u.Username, u.Email, formatTime(time.Now().UTC())) + if err != nil { + return wrapWrite(err, "user") + } + return nil +} + +func (s *SQLStore) GetUser(ctx context.Context, id string) (domain.User, error) { + var u domain.User + err := s.db.QueryRowContext(ctx, + `SELECT id, username, email FROM users WHERE id = ?`, id). + Scan(&u.ID, &u.Username, &u.Email) + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, ErrNotFound + } + if err != nil { + return domain.User{}, fmt.Errorf("store: get user: %w", err) + } + return u, nil +} + +// --------------------------------------------------------------- helpers --- + +const imageSelect = ` + SELECT i.id, i.place_id, i.storage_key, i.content_type, i.byte_size, + i.width, i.height, i.caption, i.latitude, i.longitude, i.taken_at, + i.camera_make, i.camera_model, i.analysis_status, i.analysis_error, + i.created_at, p.creator_id + FROM images i + JOIN places p ON p.id = i.place_id` + +// scanner is satisfied by both *sql.Row and *sql.Rows. +type scanner interface{ Scan(dest ...any) error } + +func scanPlace(sc scanner) (domain.Place, error) { + var ( + p domain.Place + lat sql.NullFloat64 + lon sql.NullFloat64 + created timeText + ) + err := sc.Scan(&p.ID, &p.Title, &p.Description, &lat, &lon, + &p.LinkRadiusMeters, &created, + &p.Creator.ID, &p.Creator.Username, &p.Creator.Email) + if errors.Is(err, sql.ErrNoRows) { + return domain.Place{}, ErrNotFound + } + if err != nil { + return domain.Place{}, fmt.Errorf("store: scan place: %w", err) + } + if lat.Valid && lon.Valid { + p.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64} + } + p.CreatedAt = created.Time + return p, nil +} + +func scanImage(sc scanner) (domain.Image, error) { + var ( + img domain.Image + lat sql.NullFloat64 + lon sql.NullFloat64 + takenAt nullTimeText + createdAt timeText + status string + creatorID string + ) + err := sc.Scan(&img.ID, &img.PlaceID, &img.StorageKey, &img.ContentType, &img.ByteSize, + &img.Width, &img.Height, &img.Caption, &lat, &lon, &takenAt, + &img.CameraMake, &img.CameraModel, &status, &img.AnalysisError, &createdAt, &creatorID) + if errors.Is(err, sql.ErrNoRows) { + return domain.Image{}, ErrNotFound + } + if err != nil { + return domain.Image{}, fmt.Errorf("store: scan image: %w", err) + } + if lat.Valid && lon.Valid { + img.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64} + } + img.TakenAt = takenAt.Time + img.CreatedAt = createdAt.Time + img.AnalysisStatus = domain.AnalysisStatus(status) + return img, nil +} + +// badgesFor loads badges for many pictures in one round trip. +func (s *SQLStore) badgesFor(ctx context.Context, imageIDs []string) (map[string][]domain.Badge, error) { + out := map[string][]domain.Badge{} + if len(imageIDs) == 0 { + return out, nil + } + + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(imageIDs)), ",") + args := make([]any, len(imageIDs)) + for i, id := range imageIDs { + args[i] = id + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT image_id, label, display, confidence, source + FROM badges + WHERE image_id IN (`+placeholders+`) + ORDER BY confidence DESC, label ASC`, args...) + if err != nil { + return nil, fmt.Errorf("store: load badges: %w", err) + } + defer rows.Close() + + for rows.Next() { + var ( + imageID string + b domain.Badge + ) + if err := rows.Scan(&imageID, &b.Label, &b.Display, &b.Confidence, &b.Source); err != nil { + return nil, fmt.Errorf("store: scan badge: %w", err) + } + out[imageID] = append(out[imageID], b) + } + return out, rows.Err() +} + +func newID() string { return uuid.NewString() } + +func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +func nullableFloat(c *domain.Coordinate, get func(domain.Coordinate) float64) any { + if c == nil { + return nil + } + return get(*c) +} + +func nullableTime(t *time.Time) any { + if t == nil { + return nil + } + return formatTime(*t) +} + +// timeText scans an RFC3339 timestamp column into a time.Time. +type timeText struct{ Time time.Time } + +// Scan implements sql.Scanner. +func (t *timeText) Scan(src any) error { + var s string + switch v := src.(type) { + case string: + s = v + case []byte: + s = string(v) + case time.Time: + t.Time = v.UTC() + return nil + case nil: + return errors.New("store: unexpected NULL timestamp") + default: + return fmt.Errorf("store: cannot scan %T into time", src) + } + // Try RFC3339Nano first, then date-only format YYYY-MM-DD + parsedTime := time.Time{} + if parsed, err := time.Parse(time.RFC3339Nano, s); err == nil { + parsedTime = parsed + } else if parsed, err := time.Parse("2006-01-02", s); err == nil { + parsedTime = parsed + } else { + return fmt.Errorf("store: parse timestamp %q: %w", s, nil) + } + t.Time = parsedTime.UTC() + return nil +} + +// nullTimeText scans a nullable RFC3339 timestamp column. +type nullTimeText struct{ Time *time.Time } + +// Scan implements sql.Scanner. +func (t *nullTimeText) Scan(src any) error { + if src == nil { + t.Time = nil + return nil + } + var inner timeText + if err := inner.Scan(src); err != nil { + return err + } + v := inner.Time + t.Time = &v + return nil +} + +// wrapWrite converts driver constraint failures into the package sentinel +// errors so callers can branch on them with errors.Is. +func wrapWrite(err error, entity string) error { + if err == nil { + return nil + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "unique constraint failed"), + strings.Contains(msg, "constraint failed"): + return fmt.Errorf("%w: duplicate %s: %v", ErrConflict, entity, err) + case errors.Is(err, sql.ErrNoRows): + return ErrNotFound + default: + return fmt.Errorf("store: write %s: %w", entity, err) + } +} diff --git a/api/internal/store/store.go b/api/internal/store/store.go new file mode 100644 index 0000000..44d4f92 --- /dev/null +++ b/api/internal/store/store.go @@ -0,0 +1,116 @@ +// Package store defines persistence for the domain entities. The interface +// exists so that the query implementation can be swapped (a different SQL +// dialect, or an in-memory fake in tests) without touching the service layer. +package store + +import ( + "context" + "errors" + + "go-places-api/internal/domain" + "go-places-api/internal/geo" +) + +var ( + // ErrNotFound is returned when a lookup matches no row. + ErrNotFound = errors.New("not found") + // ErrConflict is returned when a unique constraint would be violated. + ErrConflict = errors.New("conflict") +) + +// ImageFilter narrows a picture search. +type ImageFilter struct { + // PlaceID restricts results to one place when non-empty. + PlaceID string + // CreatorID restricts results to one author when non-empty. + CreatorID string + // Within restricts results to pictures inside this box and radius. A nil + // Coordinate disables the geographic restriction. + Within *NearbyQuery + // Limit caps the number of rows. Zero means unlimited. + Limit int + // Offset skips rows for pagination. + Offset int +} + +// NearbyQuery selects entities within a radius of a point. +type NearbyQuery struct { + Center domain.Coordinate + RadiusMeters float64 +} + +// Store is the persistence contract required by the service layer. +type Store interface { + // CreatePlace inserts a place and returns it as persisted. + CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error) + // GetPlace loads a place, its pictures, comments and author. + GetPlace(ctx context.Context, id string) (domain.Place, error) + // UpdatePlace replaces the mutable fields of a place. + UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error) + // ListPlaces returns places newest first. + ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) + // DeletePlace removes a place and everything attached to it. + DeletePlace(ctx context.Context, id string) error + + // CreateImage inserts a picture record. + CreateImage(ctx context.Context, img domain.Image) (domain.Image, error) + // GetImage loads one picture. + GetImage(ctx context.Context, id string) (domain.Image, error) + // GetImageByStorageKey loads the picture a blob key belongs to. The media + // route needs it to learn the declared content type and size of a stored + // object without trusting the file's own bytes. + GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error) + // UpdateImage replaces the mutable fields of a picture. + UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error) + // ListImages returns pictures matching the filter, newest first. + ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error) + // ImagesWithin returns pictures whose capture point lies inside the radius. + ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error) + // ReplaceBadges swaps the badge set for a picture. + ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error + // CreateImageLink records that two pictures were taken at the same place. + // Re-recording the same pair updates the measured distance. + CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error + // ListImageLinks returns the recorded links for a picture in both + // directions, so a link is visible from either end. + ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) + // SetAnalysisState records the outcome of the analysis pipeline. + SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error + // DeleteImage removes a picture and its badges. + DeleteImage(ctx context.Context, id string) error + + // AddComment inserts a comment and returns it as persisted. + AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error) + // ListComments returns the comments on a place, oldest first. + ListComments(ctx context.Context, placeID string) ([]domain.Comment, error) + + // UpsertUser stores the identity asserted by the auth layer. + UpsertUser(ctx context.Context, u domain.User) error + // GetUser loads a user by identifier. + GetUser(ctx context.Context, id string) (domain.User, error) + + // Close releases resources held by the store. + Close() error +} + +// BoundingBox converts a nearby query into the coarse window used to pre-filter +// rows in SQL. Exposed here so implementations and tests agree on the filter. +func (q NearbyQuery) BoundingBox() geo.BoundingBox { + return geo.Around(q.Center, q.RadiusMeters) +} + +// ErrInvalidInput is returned when a write would violate a domain rule, such as +// linking a picture to itself. +var ErrInvalidInput = errors.New("invalid input") + +// ImageGetter is the optional extra surface the service uses for storage-key +// lookups. It is separate from Store so an implementation that cannot map a key +// back to a record simply does not satisfy it. +type ImageGetter interface { + GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error) +} + +// Pinger is the optional extra surface used by the readiness probe. +type Pinger interface { + Ping(ctx context.Context) error +} diff --git a/api/internal/store/store_test.go b/api/internal/store/store_test.go new file mode 100644 index 0000000..e96f087 --- /dev/null +++ b/api/internal/store/store_test.go @@ -0,0 +1,667 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "go-places-api/internal/domain" +) + +// newTestStore opens a throwaway SQLite database. The foreign_keys pragma is +// requested explicitly because SQLite ignores declared constraints unless it is +// enabled per connection. +func newTestStore(t *testing.T) *SQLStore { + t.Helper() + + dsn := "file:" + filepath.Join(t.TempDir(), "go-places.db") + + "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)" + s, err := OpenSQL(dsn) + if err != nil { + t.Fatalf("OpenSQL: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +// seedUser inserts the author every place and comment must reference. +func seedUser(t *testing.T, s *SQLStore, id string) domain.User { + t.Helper() + + u := domain.User{ID: id, Username: "ada-" + id, Email: id + "@example.com"} + if err := s.UpsertUser(context.Background(), u); err != nil { + t.Fatalf("UpsertUser: %v", err) + } + return u +} + +// seedPlace creates a place at an optional coordinate. +func seedPlace(t *testing.T, s *SQLStore, creator, title string, coord *domain.Coordinate) domain.Place { + t.Helper() + + p, err := s.CreatePlace(context.Background(), domain.Place{ + Title: title, + Description: "notes for " + title, + Coordinate: coord, + LinkRadiusMeters: 150, + Creator: domain.User{ID: creator}, + }) + if err != nil { + t.Fatalf("CreatePlace(%q): %v", title, err) + } + return p +} + +// seedImage creates a picture belonging to a place. +func seedImage(t *testing.T, s *SQLStore, placeID, key string, coord *domain.Coordinate) domain.Image { + t.Helper() + + img, err := s.CreateImage(context.Background(), domain.Image{ + PlaceID: placeID, + StorageKey: key, + ContentType: "image/jpeg", + ByteSize: 2048, + Coordinate: coord, + AnalysisStatus: domain.AnalysisPending, + }) + if err != nil { + t.Fatalf("CreateImage(%q): %v", key, err) + } + return img +} + +func coord(lat, lon float64) *domain.Coordinate { + return &domain.Coordinate{Latitude: lat, Longitude: lon} +} + +func TestPlaceRoundTrip(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-1") + + created := seedPlace(t, s, user.ID, "Montmartre", coord(48.8867, 2.3431)) + if created.ID == "" { + t.Error("CreatePlace did not assign an ID") + } + if created.CreatedAt.IsZero() { + t.Error("CreatedAt was not set") + } + + got, err := s.GetPlace(ctx, created.ID) + if err != nil { + t.Fatalf("GetPlace: %v", err) + } + if got.Title != "Montmartre" || got.Description != "notes for Montmartre" { + t.Errorf("round trip lost fields: %+v", got) + } + if got.Coordinate == nil { + t.Fatal("Coordinate was not persisted") + } + if d := got.Coordinate.Latitude - 48.8867; d > 1e-9 || d < -1e-9 { + t.Errorf("Latitude = %v, want 48.8867", got.Coordinate.Latitude) + } + if got.LinkRadiusMeters != 150 { + t.Errorf("LinkRadiusMeters = %v, want 150", got.LinkRadiusMeters) + } + if got.Creator.ID != user.ID { + t.Errorf("Creator.ID = %q, want %q", got.Creator.ID, user.ID) + } +} + +func TestPlaceWithoutCoordinateStaysNil(t *testing.T) { + t.Parallel() + + // (0,0) is a legitimate coordinate, so "unset" must be represented by nil + // rather than by zero values. Check the round trip does not invent a point. + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-nocoord") + + created := seedPlace(t, s, user.ID, "Unknown Spot", nil) + got, err := s.GetPlace(ctx, created.ID) + if err != nil { + t.Fatalf("GetPlace: %v", err) + } + if got.Coordinate != nil { + t.Errorf("Coordinate = %+v, want nil", got.Coordinate) + } +} + +func TestGetPlaceMissingReturnsErrNotFound(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + if _, err := s.GetPlace(context.Background(), "does-not-exist"); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestUpdatePlaceReplacesMutableFields(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-2") + created := seedPlace(t, s, user.ID, "Old Title", coord(10, 10)) + + created.Title = "New Title" + created.Description = "rewritten" + created.LinkRadiusMeters = 42 + updated, err := s.UpdatePlace(ctx, created) + if err != nil { + t.Fatalf("UpdatePlace: %v", err) + } + if updated.Title != "New Title" || updated.LinkRadiusMeters != 42 { + t.Errorf("UpdatePlace returned %+v", updated) + } + + got, err := s.GetPlace(ctx, created.ID) + if err != nil { + t.Fatalf("GetPlace: %v", err) + } + if got.Title != "New Title" || got.Description != "rewritten" || got.LinkRadiusMeters != 42 { + t.Errorf("stored value did not change: %+v", got) + } +} + +func TestListPlacesIsNewestFirstAndPaginates(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-3") + + // CreatedAt is stamped by the store, so insert in sequence and rely on the + // sub-millisecond difference the store writes. + const n = 3 + for i := range n { + seedPlace(t, s, user.ID, "place-"+string(rune('a'+i)), nil) + time.Sleep(2 * time.Millisecond) + } + + all, err := s.ListPlaces(ctx, 0, 0) + if err != nil { + t.Fatalf("ListPlaces: %v", err) + } + if len(all) != n { + t.Fatalf("ListPlaces returned %d places, want %d", len(all), n) + } + for i := 1; i < len(all); i++ { + if all[i-1].CreatedAt.Before(all[i].CreatedAt) { + t.Errorf("results are not newest first: %v then %v", all[i-1].CreatedAt, all[i].CreatedAt) + } + } + + page, err := s.ListPlaces(ctx, 1, 0) + if err != nil { + t.Fatalf("ListPlaces(limit=1): %v", err) + } + if len(page) != 1 { + t.Errorf("limit=1 returned %d places", len(page)) + } + if page[0].ID != all[0].ID { + t.Errorf("first page = %q, want the newest %q", page[0].ID, all[0].ID) + } + + second, err := s.ListPlaces(ctx, 1, 1) + if err != nil { + t.Fatalf("ListPlaces(limit=1,offset=1): %v", err) + } + if len(second) != 1 || second[0].ID != all[1].ID { + t.Errorf("offset did not skip one row: %+v", second) + } +} + +func TestDeletePlaceCascadesToImagesCommentsAndLinks(t *testing.T) { + t.Parallel() + + // The schema relies on ON DELETE CASCADE. If foreign keys are not enabled the + // rows survive, which would leak every attachment of a deleted place. + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-4") + place := seedPlace(t, s, user.ID, "Doomed", coord(1, 1)) + other := seedPlace(t, s, user.ID, "Survivor", coord(1, 1)) + + a := seedImage(t, s, place.ID, "a.jpg", coord(1, 1)) + b := seedImage(t, s, other.ID, "b.jpg", coord(1, 1)) + + if _, err := s.AddComment(ctx, domain.Comment{ + PlaceID: place.ID, Content: "nice", Creator: domain.User{ID: user.ID}, + }); err != nil { + t.Fatalf("AddComment: %v", err) + } + if err := s.CreateImageLink(ctx, a.ID, b.ID, 3.5, "same-place"); err != nil { + t.Fatalf("CreateImageLink: %v", err) + } + + if err := s.DeletePlace(ctx, place.ID); err != nil { + t.Fatalf("DeletePlace: %v", err) + } + + if _, err := s.GetPlace(ctx, place.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("place still readable: err = %v", err) + } + if _, err := s.GetImage(ctx, a.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("image survived the cascade: err = %v", err) + } + comments, err := s.ListComments(ctx, place.ID) + if err != nil { + t.Fatalf("ListComments: %v", err) + } + if len(comments) != 0 { + t.Errorf("%d comments survived the cascade", len(comments)) + } + links, err := s.ListImageLinks(ctx, b.ID) + if err != nil { + t.Fatalf("ListImageLinks: %v", err) + } + if len(links) != 0 { + t.Errorf("%d links survived the cascade", len(links)) + } + + // The unrelated picture must be untouched. + if _, err := s.GetImage(ctx, b.ID); err != nil { + t.Errorf("unrelated image was removed: %v", err) + } +} + +func TestImageCarriesMetadataAndBadges(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-5") + place := seedPlace(t, s, user.ID, "With Photo", coord(48, 2)) + + taken := time.Date(2026, 6, 1, 14, 23, 45, 0, time.UTC) + img, err := s.CreateImage(ctx, domain.Image{ + PlaceID: place.ID, + StorageKey: "photos/eiffel.jpg", + ContentType: "image/jpeg", + ByteSize: 4096, + Width: 1920, + Height: 1080, + Caption: "the view", + Coordinate: coord(48.8584, 2.2945), + TakenAt: &taken, + CameraMake: "Canon", + CameraModel: "EOS R6", + }) + if err != nil { + t.Fatalf("CreateImage: %v", err) + } + if img.AnalysisStatus != domain.AnalysisPending { + t.Errorf("AnalysisStatus = %q, want %q", img.AnalysisStatus, domain.AnalysisPending) + } + + if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{ + {Label: "sky", Display: "Sky", Confidence: 0.91, Source: "local"}, + {Label: "water", Display: "Water", Confidence: 0.42, Source: "local"}, + }); err != nil { + t.Fatalf("ReplaceBadges: %v", err) + } + + got, err := s.GetImage(ctx, img.ID) + if err != nil { + t.Fatalf("GetImage: %v", err) + } + if got.Caption != "the view" || got.Width != 1920 || got.Height != 1080 { + t.Errorf("image fields lost: %+v", got) + } + if got.TakenAt == nil || !got.TakenAt.Equal(taken) { + t.Errorf("TakenAt = %v, want %v", got.TakenAt, taken) + } + if got.CameraMake != "Canon" || got.CameraModel != "EOS R6" { + t.Errorf("camera = %q/%q, want Canon/EOS R6", got.CameraMake, got.CameraModel) + } + if len(got.Badges) != 2 { + t.Fatalf("got %d badges, want 2", len(got.Badges)) + } + // Badges come back keyed by label so callers can look one up. + byLabel := map[string]domain.Badge{} + for _, b := range got.Badges { + byLabel[b.Label] = b + } + if sky := byLabel["sky"]; sky.Display != "Sky" || sky.Source != "local" { + t.Errorf("sky badge = %+v", sky) + } +} + +func TestReplaceBadgesIsIdempotent(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-6") + place := seedPlace(t, s, user.ID, "Repeated Analysis", nil) + img := seedImage(t, s, place.ID, "r.jpg", nil) + + for range 2 { + if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{ + {Label: "sky", Display: "Sky", Confidence: 0.5, Source: "local"}, + }); err != nil { + t.Fatalf("ReplaceBadges: %v", err) + } + } + + got, err := s.GetImage(ctx, img.ID) + if err != nil { + t.Fatalf("GetImage: %v", err) + } + if len(got.Badges) != 1 { + t.Errorf("ReplaceBadges duplicated rows: got %d badges", len(got.Badges)) + } +} + +func TestSetAnalysisStateRecordsFailure(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-7") + place := seedPlace(t, s, user.ID, "Failing", nil) + img := seedImage(t, s, place.ID, "f.jpg", nil) + + if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, "decode failed"); err != nil { + t.Fatalf("SetAnalysisState: %v", err) + } + got, err := s.GetImage(ctx, img.ID) + if err != nil { + t.Fatalf("GetImage: %v", err) + } + if got.AnalysisStatus != domain.AnalysisFailed { + t.Errorf("AnalysisStatus = %q, want %q", got.AnalysisStatus, domain.AnalysisFailed) + } + if got.AnalysisError != "decode failed" { + t.Errorf("AnalysisError = %q", got.AnalysisError) + } + + // A later successful run must clear the stale error, otherwise a recovered + // picture keeps reporting why it failed. + if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisCompleted, ""); err != nil { + t.Fatalf("SetAnalysisState(completed): %v", err) + } + got, err = s.GetImage(ctx, img.ID) + if err != nil { + t.Fatalf("GetImage: %v", err) + } + if got.AnalysisStatus != domain.AnalysisCompleted || got.AnalysisError != "" { + t.Errorf("state = %q/%q, want completed with no error", got.AnalysisStatus, got.AnalysisError) + } +} + +func TestGetImageByStorageKey(t *testing.T) { + t.Parallel() + + // The media route uses this to serve a blob with the recorded content type + // and size instead of trusting the bytes on disk. + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-8") + place := seedPlace(t, s, user.ID, "Keyed", nil) + want := seedImage(t, s, place.ID, "media/2026/photo.webp", nil) + + got, err := s.GetImageByStorageKey(ctx, "media/2026/photo.webp") + if err != nil { + t.Fatalf("GetImageByStorageKey: %v", err) + } + if got.ID != want.ID { + t.Errorf("got image %q, want %q", got.ID, want.ID) + } + if got.ContentType != "image/webp" && got.ContentType != "image/jpeg" { + t.Errorf("ContentType = %q", got.ContentType) + } + if _, err := s.GetImageByStorageKey(ctx, "media/nope.jpg"); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestImageLinkIsVisibleFromBothEnds(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-9") + place := seedPlace(t, s, user.ID, "Linked", coord(48.85, 2.29)) + a := seedImage(t, s, place.ID, "a.jpg", coord(48.8566, 2.2943)) + b := seedImage(t, s, place.ID, "b.jpg", coord(48.8570, 2.2950)) + + if err := s.CreateImageLink(ctx, a.ID, b.ID, 63.2, "same-place"); err != nil { + t.Fatalf("CreateImageLink: %v", err) + } + + fromA, err := s.ListImageLinks(ctx, a.ID) + if err != nil { + t.Fatalf("ListImageLinks(a): %v", err) + } + if len(fromA) != 1 { + t.Fatalf("from a: got %d links, want 1", len(fromA)) + } + if fromA[0].RelatedImageID != b.ID { + t.Errorf("from a: related = %q, want %q", fromA[0].RelatedImageID, b.ID) + } + if fromA[0].DistanceMeters != 63.2 { + t.Errorf("DistanceMeters = %v, want 63.2", fromA[0].DistanceMeters) + } + if fromA[0].Reason != "same-place" { + t.Errorf("Reason = %q, want same-place", fromA[0].Reason) + } + + fromB, err := s.ListImageLinks(ctx, b.ID) + if err != nil { + t.Fatalf("ListImageLinks(b): %v", err) + } + if len(fromB) != 1 || fromB[0].RelatedImageID != a.ID { + t.Errorf("the link is not visible from b: %+v", fromB) + } +} + +func TestCreateImageLinkUpdatesDistanceAndRejectsSelfLink(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-10") + place := seedPlace(t, s, user.ID, "Relinked", nil) + a := seedImage(t, s, place.ID, "a.jpg", nil) + b := seedImage(t, s, place.ID, "b.jpg", nil) + + if err := s.CreateImageLink(ctx, a.ID, b.ID, 10, "within-radius"); err != nil { + t.Fatalf("CreateImageLink: %v", err) + } + // Re-running the linking pass must not create a duplicate edge. + if err := s.CreateImageLink(ctx, a.ID, b.ID, 12.5, "within-radius"); err != nil { + t.Fatalf("CreateImageLink again: %v", err) + } + links, err := s.ListImageLinks(ctx, a.ID) + if err != nil { + t.Fatalf("ListImageLinks: %v", err) + } + if len(links) != 1 { + t.Fatalf("got %d links, want 1", len(links)) + } + if links[0].DistanceMeters != 12.5 { + t.Errorf("DistanceMeters = %v, want the updated 12.5", links[0].DistanceMeters) + } + + if err := s.CreateImageLink(ctx, a.ID, a.ID, 0, "same-place"); !errors.Is(err, ErrInvalidInput) { + t.Errorf("self link err = %v, want ErrInvalidInput", err) + } +} + +func TestCommentsAreOldestFirstWithAuthor(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-11") + place := seedPlace(t, s, user.ID, "Talked About", nil) + + for i, content := range []string{"first", "second", "third"} { + if _, err := s.AddComment(ctx, domain.Comment{ + PlaceID: place.ID, Content: content, Creator: domain.User{ID: user.ID}, + }); err != nil { + t.Fatalf("AddComment(%d): %v", i, err) + } + } + + got, err := s.ListComments(ctx, place.ID) + if err != nil { + t.Fatalf("ListComments: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d comments, want 3", len(got)) + } + want := []string{"first", "second", "third"} + for i, c := range got { + if c.Content != want[i] { + t.Errorf("comment %d = %q, want %q", i, c.Content, want[i]) + } + if c.ID == "" { + t.Errorf("comment %d has no ID", i) + } + if c.Creator.ID != user.ID { + t.Errorf("comment %d creator = %q, want %q", i, c.Creator.ID, user.ID) + } + } +} + +func TestAddCommentRejectsUnknownAuthor(t *testing.T) { + t.Parallel() + + // A comment must not be able to invent an author that does not exist. + s := newTestStore(t) + ctx := context.Background() + user := seedUser(t, s, "user-12") + place := seedPlace(t, s, user.ID, "Guarded", nil) + + _, err := s.AddComment(ctx, domain.Comment{ + PlaceID: place.ID, Content: "ghost", Creator: domain.User{ID: "nobody"}, + }) + if err == nil { + t.Fatal("AddComment accepted an unknown author") + } +} + +func TestListImagesFiltersByPlaceCreatorAndBox(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + ada := seedUser(t, s, "ada") + bo := seedUser(t, s, "bo") + + eiffel := seedPlace(t, s, ada.ID, "Eiffel", coord(48.8584, 2.2945)) + louvre := seedPlace(t, s, bo.ID, "Louvre", coord(48.8606, 2.3376)) + + near := seedImage(t, s, eiffel.ID, "near.jpg", coord(48.8585, 2.2946)) + // Roughly 3 km away: outside a 150 m box but inside a 5 km one. + far := seedImage(t, s, louvre.ID, "far.jpg", coord(48.8730, 2.3320)) + // No coordinate at all: geographic filters must skip it rather than fail. + noCoord := seedImage(t, s, eiffel.ID, "nolat.jpg", nil) + + byPlace, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID}) + if err != nil { + t.Fatalf("ListImages(place): %v", err) + } + if len(byPlace) != 2 { + t.Errorf("place filter returned %d images, want 2", len(byPlace)) + } + + byCreator, err := s.ListImages(ctx, ImageFilter{CreatorID: ada.ID}) + if err != nil { + t.Fatalf("ListImages(creator): %v", err) + } + if len(byCreator) != 2 { + t.Errorf("creator filter returned %d images, want 2", len(byCreator)) + } + + // Creator and place filters combine. + both, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID, CreatorID: bo.ID}) + if err != nil { + t.Fatalf("ListImages(place+creator): %v", err) + } + if len(both) != 0 { + t.Errorf("combined filter returned %d images, want 0", len(both)) + } + + tight := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 150} + withinTight, err := s.ListImages(ctx, ImageFilter{Within: &tight}) + if err != nil { + t.Fatalf("ListImages(within 150m): %v", err) + } + if len(withinTight) != 1 || withinTight[0].ID != near.ID { + t.Errorf("150 m box returned %d images, want just the nearby one", len(withinTight)) + } + for _, got := range withinTight { + if got.ID == far.ID { + t.Error("the 3 km image was inside the 150 m box") + } + } + + wide := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 5000} + withinWide, err := s.ListImages(ctx, ImageFilter{Within: &wide}) + if err != nil { + t.Fatalf("ListImages(within 5km): %v", err) + } + if len(withinWide) != 2 { + t.Errorf("5 km box returned %d images, want 2", len(withinWide)) + } + ids := map[string]bool{} + for _, got := range withinWide { + ids[got.ID] = true + } + if !ids[near.ID] || !ids[far.ID] { + t.Error("5 km box should contain both the near and the far image") + } + if ids[noCoord.ID] { + t.Error("a picture without a coordinate must never match a geographic filter") + } +} + +func TestUpsertUserIsIdempotentAndGetUserReadsBack(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + ctx := context.Background() + + first := domain.User{ID: "u1", Username: "ada", Email: "ada@example.com"} + if err := s.UpsertUser(ctx, first); err != nil { + t.Fatalf("UpsertUser: %v", err) + } + // The auth layer re-asserts identity on every request, so a second call must + // update rather than fail. + second := domain.User{ID: "u1", Username: "ada-lovelace", Email: "ada@example.com"} + if err := s.UpsertUser(ctx, second); err != nil { + t.Fatalf("UpsertUser again: %v", err) + } + + got, err := s.GetUser(ctx, "u1") + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if got.Username != "ada-lovelace" { + t.Errorf("Username = %q, want the updated value", got.Username) + } + + if _, err := s.GetUser(ctx, "missing"); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestPingSucceedsOnOpenStore(t *testing.T) { + t.Parallel() + + s := newTestStore(t) + if err := s.Ping(context.Background()); err != nil { + t.Errorf("Ping: %v", err) + } + if err := s.PingContext(context.Background()); err != nil { + t.Errorf("PingContext: %v", err) + } +} diff --git a/api/internal/transport/playground.go b/api/internal/transport/playground.go new file mode 100644 index 0000000..7634832 --- /dev/null +++ b/api/internal/transport/playground.go @@ -0,0 +1,66 @@ +package transport + +// playgroundHTML is a minimal query console served at GET /graphql when the +// request carries no query. It is deliberately dependency-free: the API has no +// front-end assets to ship, and this is enough to exercise the endpoint from a +// browser during development. +const playgroundHTML = ` + + + +go-places GraphQL + + + +

go-places GraphQL

+
+ + + AUTH_MODE=dev accepts valid_token +
+

+ +

+
+
+
+`
diff --git a/api/internal/transport/rest_test.go b/api/internal/transport/rest_test.go
new file mode 100644
index 0000000..84646b6
--- /dev/null
+++ b/api/internal/transport/rest_test.go
@@ -0,0 +1,388 @@
+package transport
+
+import (
+	"bytes"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"net/textproto"
+	"strings"
+	"testing"
+
+	"go-places-api/internal/config"
+)
+
+// The tests in this file cover the REST surface behind ENABLE_REST, which the
+// React client uses. The GraphQL surface has its own tests in transport_test.go.
+//
+// REST was added without the auth middleware at first, which left every place
+// and picture readable by an anonymous caller; the first test here pins that
+// closed.
+
+// restServer returns a server with the REST routes enabled.
+func restServer(t *testing.T) *Server {
+	t.Helper()
+	return newTestServer(t, func(c *config.Config) { c.EnableREST = true })
+}
+
+// apiRequest performs an authenticated REST call and returns the status and the
+// decoded body. The body is decoded into any because the endpoints answer with
+// both objects and arrays.
+func apiRequest(t *testing.T, srv *Server, method, path string, body []byte, token string) (int, any) {
+	t.Helper()
+
+	req := httptest.NewRequest(method, path, bytes.NewReader(body))
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	rec := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, req)
+
+	var out any
+	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+		// A non-JSON body (an error page, say) is still worth asserting on, so
+		// the text is returned as a plain string rather than failing the decode.
+		out = rec.Body.String()
+	}
+	return rec.Code, out
+}
+
+// object asserts that a decoded body is a JSON object and returns it.
+func object(t *testing.T, body any) map[string]any {
+	t.Helper()
+	m, ok := body.(map[string]any)
+	if !ok {
+		t.Fatalf("body is %T, want a JSON object: %v", body, body)
+	}
+	return m
+}
+
+// array asserts that a decoded body is a JSON array and returns it.
+func array(t *testing.T, body any) []any {
+	t.Helper()
+	a, ok := body.([]any)
+	if !ok {
+		t.Fatalf("body is %T, want a JSON array: %v", body, body)
+	}
+	return a
+}
+
+// restPlace creates a place over REST and returns its id.
+func restPlace(t *testing.T, srv *Server, title string, lat, lon float64) string {
+	t.Helper()
+
+	body, err := json.Marshal(map[string]any{
+		"title": title, "description": "notes", "latitude": lat, "longitude": lon,
+	})
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", body, "valid_token")
+	if code != http.StatusCreated {
+		t.Fatalf("create place status = %d, want 201: %v", code, resp)
+	}
+	id, _ := object(t, resp)["id"].(string)
+	if id == "" {
+		t.Fatalf("create place returned no id: %v", resp)
+	}
+	return id
+}
+
+// restUpload posts a PNG to a place and returns the status and image object.
+func restUpload(t *testing.T, srv *Server, placeID string) (int, map[string]any) {
+	t.Helper()
+
+	img := image.NewRGBA(image.Rect(0, 0, 8, 4))
+	for y := 0; y < 4; y++ {
+		for x := 0; x < 8; x++ {
+			img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255})
+		}
+	}
+	var pngBuf bytes.Buffer
+	if err := png.Encode(&pngBuf, img); err != nil {
+		t.Fatalf("encode png: %v", err)
+	}
+
+	var body bytes.Buffer
+	w := multipart.NewWriter(&body)
+	h := make(textproto.MIMEHeader)
+	h.Set("Content-Disposition", `form-data; name="image"; filename="pic.png"`)
+	h.Set("Content-Type", "image/png")
+	part, err := w.CreatePart(h)
+	if err != nil {
+		t.Fatalf("create part: %v", err)
+	}
+	if _, err := part.Write(pngBuf.Bytes()); err != nil {
+		t.Fatalf("write part: %v", err)
+	}
+	if err := w.Close(); err != nil {
+		t.Fatalf("close writer: %v", err)
+	}
+
+	req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", bytes.NewReader(body.Bytes()))
+	req.Header.Set("Content-Type", w.FormDataContentType())
+	req.Header.Set("Authorization", "Bearer valid_token")
+	rec := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, req)
+
+	var out map[string]any
+	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+		t.Fatalf("decode upload response %q: %v", rec.Body.String(), err)
+	}
+	inner, _ := out["image"].(map[string]any)
+	return rec.Code, inner
+}
+
+func TestRESTRoutesRequireAuthentication(t *testing.T) {
+	srv := restServer(t)
+	placeID := restPlace(t, srv, "Guarded", 48.85, 2.29)
+
+	// Every route that reads or writes user data has to reject an anonymous
+	// caller. Without the token these endpoints returned a full listing.
+	tests := []struct {
+		name   string
+		method string
+		path   string
+	}{
+		{"list places", http.MethodGet, "/api/places"},
+		{"get place", http.MethodGet, "/api/places/" + placeID},
+		{"list place images", http.MethodGet, "/api/places/" + placeID + "/images"},
+		{"create place", http.MethodPost, "/api/places"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte(`{"title":"x"}`)))
+			if tc.method == http.MethodPost {
+				req.Header.Set("Content-Type", "application/json")
+			}
+			rec := httptest.NewRecorder()
+			srv.Handler().ServeHTTP(rec, req)
+
+			if rec.Code != http.StatusUnauthorized {
+				t.Errorf("status = %d, want 401", rec.Code)
+			}
+		})
+	}
+
+	// And a wrong token is rejected just as firmly.
+	code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "not-a-real-token")
+	if code != http.StatusUnauthorized {
+		t.Errorf("bad token status = %d, want 401", code)
+	}
+}
+
+func TestRESTListsPlacesAndServesOne(t *testing.T) {
+	srv := restServer(t)
+	id := restPlace(t, srv, "Listed", 48.8584, 2.2945)
+
+	code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("list status = %d: %v", code, list)
+	}
+	if got := array(t, list); len(got) != 1 {
+		t.Errorf("list returned %d places, want 1", len(got))
+	}
+
+	code, rawOne := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("get status = %d: %v", code, rawOne)
+	}
+	one := object(t, rawOne)
+	if one["title"] != "Listed" {
+		t.Errorf("title = %v, want Listed", one["title"])
+	}
+	if one["id"] != id {
+		t.Errorf("id = %v, want %q", one["id"], id)
+	}
+
+	code, _ = apiRequest(t, srv, http.MethodGet, "/api/places/nope", nil, "valid_token")
+	if code != http.StatusNotFound {
+		t.Errorf("unknown place status = %d, want 404", code)
+	}
+}
+
+func TestRESTPlaceCarriesImageURLs(t *testing.T) {
+	// The stored record has no URL column, it is derived from the storage key.
+	// When the REST handler skipped that step the client received pictures it
+	// could not render.
+	srv := restServer(t)
+	id := restPlace(t, srv, "With Photo", 48.8584, 2.2945)
+
+	code, img := restUpload(t, srv, id)
+	if code != http.StatusCreated {
+		t.Fatalf("upload status = %d, want 201", code)
+	}
+	wantURL, _ := img["url"].(string)
+	if wantURL == "" {
+		t.Fatalf("upload response has no url: %v", img)
+	}
+
+	code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("get status = %d: %v", code, rawPlace)
+	}
+	place := object(t, rawPlace)
+	images, _ := place["images"].([]any)
+	if len(images) != 1 {
+		t.Fatalf("place carries %d images, want 1", len(images))
+	}
+	if got, _ := images[0].(map[string]any)["url"].(string); got != wantURL {
+		t.Errorf("picture url = %q, want %q", got, wantURL)
+	}
+
+	// The image listing has to carry the URL too.
+	code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/images", nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("images status = %d: %v", code, rawListed)
+	}
+	listed := array(t, rawListed)
+	if got, _ := listed[0].(map[string]any)["url"].(string); got != wantURL {
+		t.Errorf("listed picture url = %q, want %q", got, wantURL)
+	}
+}
+
+func TestRESTListsEmptyArraysRatherThanNull(t *testing.T) {
+	// Go serialises a nil slice as JSON null. The client iterates these fields
+	// directly, so an empty result has to be [] rather than null.
+	srv := restServer(t)
+
+	code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("list status = %d: %v", code, list)
+	}
+	if got := array(t, list); len(got) != 0 {
+		t.Errorf("an empty list returned %d places", len(got))
+	}
+
+	id := restPlace(t, srv, "Bare", 10, 10)
+	code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("get status = %d: %v", code, rawPlace)
+	}
+	place := object(t, rawPlace)
+	for _, field := range []string{"images", "comments", "relatedPlaces"} {
+		if place[field] == nil {
+			t.Errorf("%q is null on a place with nothing attached", field)
+		}
+	}
+}
+
+func TestRESTPlaceReportsRelatedPlaces(t *testing.T) {
+	// The same spot catalogued twice is the case the feature exists for, so the
+	// REST response has to surface the relationship.
+	srv := restServer(t)
+	first := restPlace(t, srv, "Eiffel (visit 1)", 48.8584, 2.2945)
+	second := restPlace(t, srv, "Eiffel (visit 2)", 48.8585, 2.2946)
+
+	if code, img := restUpload(t, srv, first); code != http.StatusCreated {
+		t.Fatalf("first upload status = %d", code)
+	} else if img["id"] == "" {
+		t.Fatal("first upload returned no id")
+	}
+	if code, _ := restUpload(t, srv, second); code != http.StatusCreated {
+		t.Fatalf("second upload status = %d", code)
+	}
+
+	code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+second, nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("get status = %d: %v", code, rawPlace)
+	}
+	place := object(t, rawPlace)
+	related, _ := place["relatedPlaces"].([]any)
+	if len(related) != 1 {
+		t.Fatalf("got %d related places, want 1", len(related))
+	}
+	entry := related[0].(map[string]any)
+	if got, _ := entry["place"].(map[string]any)["id"].(string); got != first {
+		t.Errorf("related place id = %q, want %q", got, first)
+	}
+	if _, ok := entry["distanceMeters"].(float64); !ok {
+		t.Errorf("distanceMeters missing: %v", entry)
+	}
+
+	// The relationship must not nest into itself.
+	nested, _ := entry["place"].(map[string]any)["relatedPlaces"].([]any)
+	if len(nested) != 0 {
+		t.Errorf("the nested place carries %d related places, want 0", len(nested))
+	}
+}
+
+func TestRESTPictureReportsLinkedImages(t *testing.T) {
+	// Pictures in different place records at the same coordinates are linked, and
+	// the link has to be visible from the picture listing.
+	srv := restServer(t)
+	first := restPlace(t, srv, "Spot A", 48.8584, 2.2945)
+	second := restPlace(t, srv, "Spot B", 48.8585, 2.2946)
+	restUpload(t, srv, first)
+
+	code, secondImg := restUpload(t, srv, second)
+	if code != http.StatusCreated {
+		t.Fatalf("upload status = %d", code)
+	}
+	linked, _ := secondImg["linkedImages"].([]any)
+	if len(linked) != 1 {
+		t.Fatalf("got %d linked images, want 1: %v", len(linked), secondImg)
+	}
+
+	code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+second+"/images", nil, "valid_token")
+	if code != http.StatusOK {
+		t.Fatalf("images status = %d: %v", code, rawListed)
+	}
+	listed := array(t, rawListed)
+	listedLinks, _ := listed[0].(map[string]any)["linkedImages"].([]any)
+	if len(listedLinks) != 1 {
+		t.Errorf("the listing reports %d linked images, want 1", len(listedLinks))
+	}
+}
+
+func TestRESTRejectsInvalidInputWithJSON(t *testing.T) {
+	// The error shape has to match the success shape, otherwise a client has to
+	// parse two formats.
+	srv := restServer(t)
+
+	code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`{"title":""}`), "valid_token")
+	if code != http.StatusBadRequest {
+		t.Errorf("empty title status = %d, want 400: %v", code, resp)
+	}
+	if msg, _ := object(t, resp)["error"].(string); msg == "" {
+		t.Errorf("error body has no message: %v", resp)
+	}
+
+	code, resp = apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`not json`), "valid_token")
+	if code != http.StatusBadRequest {
+		t.Errorf("malformed body status = %d, want 400: %v", code, resp)
+	}
+}
+
+func TestRESTUnknownSubresourceIsNotAPlaceLookup(t *testing.T) {
+	// GET /api/places/ is a prefix match, so a deeper path arrives at the same
+	// handler. IDs are UUIDs, so a path with a slash in it is an unknown route
+	// rather than a place that happens not to exist.
+	srv := restServer(t)
+	id := restPlace(t, srv, "Spot", 48.8584, 2.2945)
+
+	code, body := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/comments", nil, "valid_token")
+	if code != http.StatusNotFound {
+		t.Errorf("status = %d, want 404 for an unknown subresource: %v", code, body)
+	}
+	if msg, _ := object(t, body)["error"].(string); !strings.Contains(msg, "resource") {
+		t.Errorf("error = %q, want it to name the missing resource", msg)
+	}
+}
+
+func TestRESTDisabledByDefault(t *testing.T) {
+	// ENABLE_REST defaults to off, so a deployment that did not opt in must not
+	// expose the routes at all.
+	srv := newTestServer(t)
+	code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
+	if code != http.StatusNotFound {
+		t.Errorf("status = %d, want 404 when REST is disabled", code)
+	}
+}
diff --git a/api/internal/transport/schema.go b/api/internal/transport/schema.go
new file mode 100644
index 0000000..47530d8
--- /dev/null
+++ b/api/internal/transport/schema.go
@@ -0,0 +1,513 @@
+// Package transport exposes the service over HTTP: a GraphQL endpoint for the
+// data, a multipart endpoint for picture uploads, read-only serving of stored
+// media, and the health probes the Kubernetes charts poll.
+package transport
+
+import (
+	"context"
+	"errors"
+
+	"github.com/graphql-go/graphql"
+
+	"go-places-api/internal/auth"
+	"go-places-api/internal/domain"
+	"go-places-api/internal/service"
+	"go-places-api/internal/store"
+)
+
+// The GraphQL type set mirrors the domain structs. Field names line up with the
+// `json` tags on the domain models, so graphql-go's default resolver already
+// populates most fields; explicit resolvers are only needed where a value has to
+// be computed (image URLs, linked pictures, related places) or where the raw
+// domain type needs converting (AnalysisStatus, LinkRadiusMeters).
+//
+// Image, Place and RelatedPlace refer to each other, so their fields are built
+// by thunks that graphql-go evaluates lazily. Declaring them eagerly would mean
+// a type had to exist before it was defined.
+
+var (
+	badgeType = graphql.NewObject(graphql.ObjectConfig{
+		Name:        "Badge",
+		Description: "A recognisable attribute detected in a picture.",
+		Fields: graphql.Fields{
+			"label":      &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
+			"display":    &graphql.Field{Type: graphql.String},
+			"confidence": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
+			"source":     &graphql.Field{Type: graphql.String},
+		},
+	})
+
+	coordinateType = graphql.NewObject(graphql.ObjectConfig{
+		Name:        "Coordinate",
+		Description: "A WGS-84 geographic point.",
+		Fields: graphql.Fields{
+			"latitude":  &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
+			"longitude": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
+		},
+	})
+
+	userType = graphql.NewObject(graphql.ObjectConfig{
+		Name:        "User",
+		Description: "A logged-in user, as asserted by the go-loose SSO service.",
+		Fields: graphql.Fields{
+			"id":       &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
+			"username": &graphql.Field{Type: graphql.String},
+			"email":    &graphql.Field{Type: graphql.String},
+		},
+	})
+
+	commentType = graphql.NewObject(graphql.ObjectConfig{
+		Name:        "Comment",
+		Description: "A user reaction on a place.",
+		Fields: graphql.Fields{
+			"id":        &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
+			"placeId":   &graphql.Field{Type: graphql.String},
+			"content":   &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
+			"creator":   &graphql.Field{Type: graphql.NewNonNull(userType)},
+			"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
+		},
+	})
+
+	// PlaceInput is the createPlace argument. Latitude and longitude are
+	// optional so an omitted coordinate can be told apart from the origin, which
+	// is a legitimate point.
+	placeInput = graphql.NewInputObject(graphql.InputObjectConfig{
+		Name: "PlaceInput",
+		Fields: graphql.InputObjectConfigFieldMap{
+			"title":            &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)},
+			"description":      &graphql.InputObjectFieldConfig{Type: graphql.String},
+			"latitude":         &graphql.InputObjectFieldConfig{Type: graphql.Float},
+			"longitude":        &graphql.InputObjectFieldConfig{Type: graphql.Float},
+			"linkRadiusMeters": &graphql.InputObjectFieldConfig{Type: graphql.Float},
+			"caption":          &graphql.InputObjectFieldConfig{Type: graphql.String},
+		},
+	})
+)
+
+// Image, Place and RelatedPlace refer to each other, so each type is created
+// once and cached on the builder before any field thunk runs. Building the type
+// objects as thunks keeps the references valid: graphql-go evaluates a thunk the
+// first time it needs the type's field map, which happens while NewSchema walks
+// the type graph, by which point all three instances already exist.
+//
+// The builder is used exactly once, during startup, before the server serves any
+// request, so the lazy caching needs no further synchronisation.
+type builder struct {
+	svc *service.Service
+
+	image   *graphql.Object
+	place   *graphql.Object
+	related *graphql.Object
+}
+
+// imageType returns the shared Image type.
+func (b *builder) imageType() *graphql.Object {
+	if b.image == nil {
+		b.image = graphql.NewObject(graphql.ObjectConfig{
+			Name:        "Image",
+			Description: "A picture attached to a place.",
+			Fields: graphql.FieldsThunk(func() graphql.Fields {
+				return graphql.Fields{
+					"id":          &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
+					"placeId":     &graphql.Field{Type: graphql.String},
+					"url":         &graphql.Field{Type: graphql.String, Resolve: nullOnError(resolveImageURL(b.svc))},
+					"caption":     &graphql.Field{Type: graphql.String},
+					"contentType": &graphql.Field{Type: graphql.String},
+					"byteSize":    &graphql.Field{Type: graphql.NewNonNull(graphql.Int)},
+					"width":       &graphql.Field{Type: graphql.Int},
+					"height":      &graphql.Field{Type: graphql.Int},
+					"coordinate":  &graphql.Field{Type: coordinateType},
+					"takenAt":     &graphql.Field{Type: graphql.DateTime},
+					"cameraMake":  &graphql.Field{Type: graphql.String},
+					"cameraModel": &graphql.Field{Type: graphql.String},
+					"badges":      &graphql.Field{Type: graphql.NewList(badgeType)},
+					"analysisStatus": &graphql.Field{
+						Type: graphql.String,
+						Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+							img, ok := p.Source.(domain.Image)
+							if !ok {
+								return nil, nil
+							}
+							return string(img.AnalysisStatus), nil
+						},
+					},
+					"analysisError": &graphql.Field{Type: graphql.String},
+					// LinkedImages is derived on read, so it is only ever computed
+					// when a client actually selects it.
+					"linkedImages": &graphql.Field{
+						Type:    graphql.NewList(b.imageType()),
+						Resolve: nullOnError(resolveLinkedImages(b.svc)),
+					},
+					"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
+				}
+			}),
+		})
+	}
+	return b.image
+}
+
+// placeType returns the shared Place type.
+func (b *builder) placeType() *graphql.Object {
+	if b.place == nil {
+		b.place = graphql.NewObject(graphql.ObjectConfig{
+			Name:        "Place",
+			Description: "A location the user visited and shared.",
+			Fields: graphql.FieldsThunk(func() graphql.Fields {
+				return graphql.Fields{
+					"id":          &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
+					"title":       &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
+					"description": &graphql.Field{Type: graphql.String},
+					"coordinate":  &graphql.Field{Type: coordinateType},
+					"linkRadiusMeters": &graphql.Field{
+						Type: graphql.Float,
+						Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+							pl, ok := p.Source.(domain.Place)
+							if !ok {
+								return nil, nil
+							}
+							return pl.LinkRadiusMeters, nil
+						},
+					},
+					"creator":  &graphql.Field{Type: graphql.NewNonNull(userType)},
+					"images":   &graphql.Field{Type: graphql.NewList(b.imageType())},
+					"comments": &graphql.Field{Type: graphql.NewList(commentType)},
+					"relatedPlaces": &graphql.Field{
+						Type:    graphql.NewList(b.relatedPlaceType()),
+						Resolve: nullOnError(resolveRelatedPlaces(b.svc)),
+					},
+					"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
+				}
+			}),
+		})
+	}
+	return b.place
+}
+
+// relatedPlaceType returns the shared RelatedPlace type.
+func (b *builder) relatedPlaceType() *graphql.Object {
+	if b.related == nil {
+		b.related = graphql.NewObject(graphql.ObjectConfig{
+			Name:        "RelatedPlace",
+			Description: "A place whose pictures overlap the queried one.",
+			Fields: graphql.FieldsThunk(func() graphql.Fields {
+				return graphql.Fields{
+					"place":          &graphql.Field{Type: graphql.NewNonNull(b.placeType())},
+					"distanceMeters": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
+					"sharedImageIds": &graphql.Field{Type: graphql.NewList(graphql.String)},
+				}
+			}),
+		})
+	}
+	return b.related
+}
+
+// newSchema assembles the executable schema.
+func newSchema(svc *service.Service) (graphql.Schema, error) {
+	b := &builder{svc: svc}
+	// Instantiated up front so the thunks above observe cached instances rather
+	// than building a second copy of each type.
+	imageType, placeType, relatedType := b.imageType(), b.placeType(), b.relatedPlaceType()
+
+	query := graphql.NewObject(graphql.ObjectConfig{
+		Name:        "Query",
+		Description: "Read access to the travel log.",
+		Fields: graphql.FieldsThunk(func() graphql.Fields {
+			return graphql.Fields{
+				"place": &graphql.Field{
+					Type:        placeType,
+					Description: "One place by identifier.",
+					Args: graphql.FieldConfigArgument{
+						"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+					},
+					Resolve: nullOnError(resolvePlace(b.svc)),
+				},
+				"places": &graphql.Field{
+					Type:        graphql.NewList(placeType),
+					Description: "Places, newest first.",
+					Args: graphql.FieldConfigArgument{
+						"limit":  &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
+						"offset": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
+					},
+					Resolve: nullOnError(resolvePlaces(b.svc)),
+				},
+				"images": &graphql.Field{
+					Type:        graphql.NewList(imageType),
+					Description: "Pictures, newest first.",
+					Args: graphql.FieldConfigArgument{
+						"placeId":   &graphql.ArgumentConfig{Type: graphql.String},
+						"creatorId": &graphql.ArgumentConfig{Type: graphql.String},
+						"limit":     &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
+						"offset":    &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
+					},
+					Resolve: nullOnError(resolveImages(b.svc)),
+				},
+				"relatedPlaces": &graphql.Field{
+					Type:        graphql.NewList(relatedType),
+					Description: "Places whose pictures overlap the given one.",
+					Args: graphql.FieldConfigArgument{
+						"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+					},
+					Resolve: nullOnError(resolveRelatedByID(b.svc)),
+				},
+			}
+		}),
+	})
+
+	mutation := graphql.NewObject(graphql.ObjectConfig{
+		Name:        "Mutation",
+		Description: "Write access to the travel log.",
+		Fields: graphql.FieldsThunk(func() graphql.Fields {
+			return graphql.Fields{
+				"createPlace": &graphql.Field{
+					Type: placeType,
+					Args: graphql.FieldConfigArgument{
+						"input": &graphql.ArgumentConfig{Type: graphql.NewNonNull(placeInput)},
+					},
+					Resolve: nullOnError(resolveCreatePlace(b.svc)),
+				},
+				"addComment": &graphql.Field{
+					Type: commentType,
+					Args: graphql.FieldConfigArgument{
+						"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+						"content": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+					},
+					Resolve: nullOnError(resolveAddComment(b.svc)),
+				},
+				"updateImageCaption": &graphql.Field{
+					Type: imageType,
+					Args: graphql.FieldConfigArgument{
+						"imageId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+						"caption": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+					},
+					Resolve: nullOnError(resolveUpdateImageCaption(b.svc)),
+				},
+				"deletePlace": &graphql.Field{
+					Type:        graphql.Boolean,
+					Description: "Remove a place and everything attached to it.",
+					Args: graphql.FieldConfigArgument{
+						"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+					},
+					Resolve: nullOnError(resolveDeletePlace(b.svc)),
+				},
+				"deleteImage": &graphql.Field{
+					Type:        graphql.Boolean,
+					Description: "Remove a picture and its stored bytes.",
+					Args: graphql.FieldConfigArgument{
+						"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
+					},
+					Resolve: nullOnError(resolveDeleteImage(b.svc)),
+				},
+			}
+		}),
+	})
+
+	return graphql.NewSchema(graphql.SchemaConfig{
+		Query:    query,
+		Mutation: mutation,
+	})
+}
+
+// ---------------------------------------------------------------- resolvers ---
+//
+// Each resolver is written as a typed function and wrapped by nullOnError, so a
+// failure nulls the field instead of emitting the zero value of the domain type.
+// graphql-go uses whatever a resolver returns, error or not, so returning the
+// zero struct alongside an error would hand clients a plausible-looking empty
+// record alongside the error.
+
+// nullOnError adapts a typed resolver to a GraphQL field resolver, reporting a
+// failure as a null field.
+func nullOnError[T any](fn func(graphql.ResolveParams) (T, error)) graphql.FieldResolveFn {
+	return func(p graphql.ResolveParams) (interface{}, error) {
+		v, err := fn(p)
+		if err != nil {
+			return nil, err
+		}
+		return v, nil
+	}
+}
+
+func resolvePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
+	return func(p graphql.ResolveParams) (domain.Place, error) {
+		return svc.GetPlace(p.Context, p.Args["id"].(string))
+	}
+}
+
+func resolvePlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.Place, error) {
+	return func(p graphql.ResolveParams) ([]domain.Place, error) {
+		limit, offset := pageArgs(p.Args)
+		return svc.ListPlaces(p.Context, limit, offset)
+	}
+}
+
+func resolveImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
+	return func(p graphql.ResolveParams) ([]domain.Image, error) {
+		limit, offset := pageArgs(p.Args)
+		f := store.ImageFilter{Limit: limit, Offset: offset}
+		if v, ok := p.Args["placeId"].(string); ok {
+			f.PlaceID = v
+		}
+		if v, ok := p.Args["creatorId"].(string); ok {
+			f.CreatorID = v
+		}
+		return svc.ListImages(p.Context, f)
+	}
+}
+
+func resolveRelatedByID(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
+	return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
+		return svc.RelatedPlaces(p.Context, p.Args["placeId"].(string))
+	}
+}
+
+func resolveCreatePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
+	return func(p graphql.ResolveParams) (domain.Place, error) {
+		author, err := requireUser(p.Context)
+		if err != nil {
+			return domain.Place{}, err
+		}
+		raw, _ := p.Args["input"].(map[string]interface{})
+		return svc.CreatePlace(p.Context, author, decodePlaceInput(raw))
+	}
+}
+
+func resolveAddComment(svc *service.Service) func(graphql.ResolveParams) (domain.Comment, error) {
+	return func(p graphql.ResolveParams) (domain.Comment, error) {
+		author, err := requireUser(p.Context)
+		if err != nil {
+			return domain.Comment{}, err
+		}
+		return svc.AddComment(p.Context, author, p.Args["placeId"].(string), p.Args["content"].(string))
+	}
+}
+
+func resolveUpdateImageCaption(svc *service.Service) func(graphql.ResolveParams) (domain.Image, error) {
+	return func(p graphql.ResolveParams) (domain.Image, error) {
+		return svc.UpdateImageCaption(p.Context, p.Args["imageId"].(string), p.Args["caption"].(string))
+	}
+}
+
+func resolveDeletePlace(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
+	return func(p graphql.ResolveParams) (bool, error) {
+		if err := svc.DeletePlace(p.Context, p.Args["id"].(string)); err != nil {
+			return false, err
+		}
+		return true, nil
+	}
+}
+
+func resolveDeleteImage(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
+	return func(p graphql.ResolveParams) (bool, error) {
+		if err := svc.DeleteImage(p.Context, p.Args["id"].(string)); err != nil {
+			return false, err
+		}
+		return true, nil
+	}
+}
+
+// resolveImageURL turns the private storage key into the public URL. The key
+// itself is never exposed.
+func resolveImageURL(svc *service.Service) func(graphql.ResolveParams) (string, error) {
+	return func(p graphql.ResolveParams) (string, error) {
+		img, ok := p.Source.(domain.Image)
+		if !ok {
+			return "", nil
+		}
+		return svc.ImageURL(img.StorageKey), nil
+	}
+}
+
+// resolveLinkedImages hydrates the neighbours discovered by the linking pass. The
+// links are already ordered by distance, so the order is preserved.
+func resolveLinkedImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
+	return func(p graphql.ResolveParams) ([]domain.Image, error) {
+		img, ok := p.Source.(domain.Image)
+		if !ok {
+			return nil, nil
+		}
+		links, err := svc.ImageLinks(p.Context, img.ID)
+		if err != nil {
+			return nil, err
+		}
+		out := make([]domain.Image, 0, len(links))
+		for _, link := range links {
+			// Each neighbour is re-read so the nested selection sees its badges
+			// and URL just like a top-level picture.
+			related, err := svc.ImageByID(p.Context, link.RelatedImageID)
+			if err != nil {
+				// A neighbour that vanished between the two reads is skipped
+				// rather than failing the whole selection.
+				continue
+			}
+			out = append(out, related)
+		}
+		return out, nil
+	}
+}
+
+func resolveRelatedPlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
+	return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
+		place, ok := p.Source.(domain.Place)
+		if !ok {
+			return nil, nil
+		}
+		related, err := svc.RelatedPlaces(p.Context, place.ID)
+		if err != nil {
+			return nil, err
+		}
+		if related == nil {
+			// An empty list rather than null, so clients can iterate safely.
+			return []domain.RelatedPlace{}, nil
+		}
+		return related, nil
+	}
+}
+
+// ------------------------------------------------------------------ helpers ---
+
+// requireUser pulls the verified identity that the auth middleware placed on the
+// context. Reaching this point without one means a route was mounted without the
+// middleware, which is a wiring bug rather than a client error.
+func requireUser(ctx context.Context) (domain.User, error) {
+	u, ok := auth.FromContext(ctx)
+	if !ok {
+		return domain.User{}, errors.New("transport: request is not authenticated")
+	}
+	return u.User, nil
+}
+
+// pageArgs reads the shared limit/offset arguments, tolerating their absence.
+func pageArgs(args map[string]interface{}) (limit, offset int) {
+	if v, ok := args["limit"].(int); ok {
+		limit = v
+	}
+	if v, ok := args["offset"].(int); ok {
+		offset = v
+	}
+	return limit, offset
+}
+
+// decodePlaceInput converts the GraphQL input object into the service payload,
+// turning absent numeric fields into nil pointers.
+func decodePlaceInput(raw map[string]interface{}) domain.PlaceInput {
+	var in domain.PlaceInput
+	if v, ok := raw["title"].(string); ok {
+		in.Title = v
+	}
+	if v, ok := raw["description"].(string); ok {
+		in.Description = v
+	}
+	if v, ok := raw["caption"].(string); ok {
+		in.Caption = v
+	}
+	if v, ok := raw["latitude"].(float64); ok {
+		in.Latitude = &v
+	}
+	if v, ok := raw["longitude"].(float64); ok {
+		in.Longitude = &v
+	}
+	if v, ok := raw["linkRadiusMeters"].(float64); ok {
+		in.LinkRadiusMeters = &v
+	}
+	return in
+}
diff --git a/api/internal/transport/server.go b/api/internal/transport/server.go
new file mode 100644
index 0000000..7a4a06c
--- /dev/null
+++ b/api/internal/transport/server.go
@@ -0,0 +1,604 @@
+package transport
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/graphql-go/graphql"
+
+	"go-places-api/internal/auth"
+	"go-places-api/internal/blobstore"
+	"go-places-api/internal/config"
+	"go-places-api/internal/domain"
+	"go-places-api/internal/service"
+	"go-places-api/internal/store"
+)
+
+// mediaPrefix is the path segment under which stored pictures are served. It
+// matches the URLs that Service.ImageURL builds.
+const mediaPrefix = "/media/"
+
+// maxGraphQLBody bounds a GraphQL request document. A legitimate document is
+// small, so this keeps a hostile client from filling memory.
+const maxGraphQLBody = 1 << 20
+
+// sniffLen is how much of an upload is examined to guess its content type when
+// the client did not declare one.
+const sniffLen = 512
+
+// Options configures a Server.
+type Options struct {
+	// Service holds the business logic the handlers delegate to.
+	Service *service.Service
+	// Config is the resolved runtime configuration.
+	Config config.Config
+	// Auth resolves bearer tokens on the protected routes.
+	Auth *auth.Middleware
+	// Blobs serves stored picture bytes.
+	Blobs blobstore.BlobStore
+	// Logger receives request and failure logs.
+	Logger *slog.Logger
+	// EnableREST if true, adds REST endpoints alongside GraphQL.
+	EnableREST bool
+}
+
+// Server holds the HTTP routes.
+type Server struct {
+	svc    *service.Service
+	cfg    config.Config
+	auth   *auth.Middleware
+	blobs  blobstore.BlobStore
+	logger *slog.Logger
+	schema graphql.Schema
+}
+
+// NewServer builds the Server and compiles the GraphQL schema. A schema that
+// does not compile is a programming error, so it is reported to the caller
+// rather than deferred to the first request.
+func NewServer(opts Options) (*Server, error) {
+	logger := opts.Logger
+	if logger == nil {
+		logger = slog.Default()
+	}
+
+	schema, err := newSchema(opts.Service)
+	if err != nil {
+		return nil, fmt.Errorf("transport: build graphql schema: %w", err)
+	}
+
+	return &Server{
+		svc:    opts.Service,
+		cfg:    opts.Config,
+		auth:   opts.Auth,
+		blobs:  opts.Blobs,
+		logger: logger,
+		schema: schema,
+	}, nil
+}
+
+// Handler returns the fully routed handler.
+//
+// The probes and the media route are deliberately unauthenticated: a kubelet
+// health probe cannot present a bearer token, and picture URLs have to be
+// renderable where a token cannot be attached, such as an  tag. Every
+// route that reads or writes user data sits behind the auth middleware.
+func (s *Server) Handler() http.Handler {
+	mux := http.NewServeMux()
+
+	mux.HandleFunc("GET /healthz", s.handleHealthz)
+	mux.HandleFunc("GET /readyz", s.handleReadyz)
+	mux.HandleFunc("GET "+mediaPrefix, s.handleMedia)
+
+	upload := http.HandlerFunc(s.handleUpload)
+	if s.auth != nil {
+		upload = s.auth.WrapFunc(s.handleUpload)
+	}
+	// The place may come from the path or from a form field, so both shapes are
+	// accepted.
+	mux.HandleFunc("POST /upload", upload)
+	mux.HandleFunc("POST /places/{placeId}/images", upload)
+
+	// REST API routes. These read and write user data, so they sit behind the
+	// same auth middleware as the upload and GraphQL routes. Leaving them open
+	// would expose every place, picture and comment to an unauthenticated
+	// caller.
+	if s.cfg.EnableREST {
+		// When no middleware is configured there is nothing to verify against,
+		// so the handlers are mounted unwrapped; they will reject the request
+		// themselves because no identity reaches the context.
+		protect := func(h http.HandlerFunc) http.HandlerFunc {
+			if s.auth == nil {
+				return h
+			}
+			return s.auth.WrapFunc(h)
+		}
+
+		mux.HandleFunc("GET /api/places", protect(s.handleListPlaces))
+		mux.HandleFunc("GET /api/places/", protect(s.handleGetPlace))
+		mux.HandleFunc("POST /api/places", protect(s.handleCreatePlace))
+		mux.HandleFunc("GET /api/places/{placeId}/images", protect(s.handleListPlaceImages))
+	}
+
+	var graphqlHandler http.Handler = http.HandlerFunc(s.handleGraphQL)
+	if s.auth != nil {
+		graphqlHandler = s.auth.Wrap(http.HandlerFunc(s.handleGraphQL))
+	}
+	mux.Handle("POST /graphql", graphqlHandler)
+	// GET only ever serves the static query console. Executing a query over GET
+	// would bypass the auth middleware above, so a caller could read the whole
+	// travel log from a link in an email client.
+	mux.HandleFunc("GET /graphql", s.handleGraphQLConsole)
+
+	return s.withLogging(s.recoverPanics(mux))
+}
+
+// ------------------------------------------------------------------ probes ---
+
+func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// handleReadyz reports whether the service can serve traffic. It is separate
+// from healthz so a failing dependency does not trigger a restart loop.
+func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
+	if err := s.svc.Ping(r.Context()); err != nil {
+		s.logger.Warn("readiness check failed", "error", err)
+		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
+			"status": "unavailable",
+			"error":  err.Error(),
+		})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
+}
+
+// ------------------------------------------------------------------- media ---
+
+// handleMedia streams a stored picture. The key is the path after the prefix
+// and is resolved by the blob store, which rejects anything escaping its root.
+func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
+	key := strings.TrimPrefix(r.URL.Path, mediaPrefix)
+	if key == "" {
+		http.Error(w, "a media key is required", http.StatusBadRequest)
+		return
+	}
+	if s.blobs == nil {
+		http.Error(w, "media storage is not configured", http.StatusNotFound)
+		return
+	}
+
+	// The declared type and size come from the record rather than from the file,
+	// so a mislabelled upload is still served as what it was stored as.
+	if ct, err := s.svc.ContentTypeByStorageKey(r.Context(), key); err == nil && ct != "" {
+		w.Header().Set("Content-Type", ct)
+	}
+	if n, err := s.svc.ImageSize(r.Context(), key); err == nil && n > 0 {
+		w.Header().Set("Content-Length", strconv.FormatInt(n, 10))
+	}
+
+	body, err := s.blobs.Get(r.Context(), key)
+	if err != nil {
+		if errors.Is(err, blobstore.ErrNotFound) {
+			http.Error(w, "no such media", http.StatusNotFound)
+			return
+		}
+		s.logger.Warn("failed to read media", "key", key, "error", err)
+		http.Error(w, "media could not be read", http.StatusInternalServerError)
+		return
+	}
+	defer body.Close()
+
+	// Media is immutable once written, so it can be cached hard.
+	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+	if _, err := io.Copy(w, body); err != nil {
+		s.logger.Warn("failed to stream media", "key", key, "error", err)
+	}
+}
+
+// ------------------------------------------------------------------ upload ---
+
+// handleUpload accepts a multipart picture and attaches it to a place.
+func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
+	user, ok := auth.FromContext(r.Context())
+	if !ok {
+		// Only reachable if the middleware was not applied, which is a wiring
+		// bug rather than a client error.
+		http.Error(w, "unauthorized: a bearer token is required", http.StatusUnauthorized)
+		return
+	}
+
+	if err := r.ParseMultipartForm(8 << 20); err != nil {
+		http.Error(w, "a multipart form body is required", http.StatusBadRequest)
+		return
+	}
+
+	placeID := r.PathValue("placeId")
+	if placeID == "" {
+		placeID = r.FormValue("placeId")
+	}
+	if placeID == "" {
+		http.Error(w, "a placeId is required", http.StatusBadRequest)
+		return
+	}
+
+	file, header, err := r.FormFile("image")
+	if err != nil {
+		http.Error(w, `a multipart field named "image" is required`, http.StatusBadRequest)
+		return
+	}
+	defer file.Close()
+
+	// The declared type is authoritative, but a client that omits it gets the
+	// type sniffed from the payload instead of a confusing rejection.
+	contentType := header.Header.Get("Content-Type")
+	var body io.Reader = file
+	if contentType == "" {
+		prefix := make([]byte, sniffLen)
+		n, err := io.ReadFull(file, prefix)
+		if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
+			http.Error(w, "the uploaded image could not be read", http.StatusBadRequest)
+			return
+		}
+		prefix = prefix[:n]
+		contentType = http.DetectContentType(prefix)
+		body = io.MultiReader(bytes.NewReader(prefix), file)
+	}
+
+	img, err := s.svc.AddImage(r.Context(), user.User, placeID, service.ImageUpload{
+		ContentType: contentType,
+		Caption:     r.FormValue("caption"),
+		Body:        body,
+	})
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+
+	// The client needs the URLs of the pictures this upload linked to, and the
+	// link evidence lives in the store rather than on the record, so they are
+	// read back here.
+	linked, err := s.svc.LinkedImages(r.Context(), img)
+	if err != nil {
+		// The picture itself was stored, so this is reported rather than failing
+		// a request that already succeeded.
+		s.logger.Warn("failed to load linked pictures", "imageId", img.ID, "error", err)
+	} else {
+		img.LinkedImages = linked
+	}
+
+	s.logger.Info("image stored",
+		"imageId", img.ID,
+		"placeId", img.PlaceID,
+		"bytes", img.ByteSize,
+		"analysisStatus", string(img.AnalysisStatus),
+	)
+	writeJSON(w, http.StatusCreated, map[string]interface{}{"image": img})
+}
+
+// ----------------------------------------------------------------- graphql ---
+
+// graphQLRequest is the standard GraphQL-over-HTTP payload.
+type graphQLRequest struct {
+	Query         string                 `json:"query"`
+	OperationName string                 `json:"operationName"`
+	Variables     map[string]interface{} `json:"variables"`
+}
+
+// handleGraphQLConsole serves the static query console. It never executes a
+// query: this route is unauthenticated, so any data it returned would leak to
+// whoever opened the URL.
+func (s *Server) handleGraphQLConsole(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Query().Get("query") != "" {
+		writeGraphQLRequestError(w, "send queries as POST /graphql with an Authorization header")
+		return
+	}
+	s.servePlayground(w, r)
+}
+
+func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
+	var req graphQLRequest
+
+	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxGraphQLBody))
+	if err := dec.Decode(&req); err != nil {
+		writeGraphQLRequestError(w, "the request body must be a GraphQL JSON object")
+		return
+	}
+
+	if strings.TrimSpace(req.Query) == "" {
+		writeGraphQLRequestError(w, "a query is required")
+		return
+	}
+
+	result := graphql.Do(graphql.Params{
+		Schema:         s.schema,
+		RequestString:  req.Query,
+		VariableValues: req.Variables,
+		OperationName:  req.OperationName,
+		Context:        r.Context(),
+	})
+
+	if len(result.Errors) > 0 {
+		s.logger.Warn("graphql reported errors",
+			"operation", req.OperationName,
+			"errors", len(result.Errors),
+			"detail", firstErrorMessage(result),
+		)
+	}
+
+	// A GraphQL response carries its own errors in the body, so the transport
+	// status stays 200 even when a resolver failed. Only a malformed request is
+	// a 4xx.
+	writeJSON(w, http.StatusOK, result)
+}
+
+// handleListPlaces lists all places, optionally filtered by limit/offset.
+func (s *Server) handleListPlaces(w http.ResponseWriter, r *http.Request) {
+	limit := 20
+	offset := 0
+	if v := r.URL.Query().Get("limit"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n > 0 {
+			limit = n
+		}
+	}
+	if v := r.URL.Query().Get("offset"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
+			offset = n
+		}
+	}
+	places, err := s.svc.ListPlaces(r.Context(), limit, offset)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, places)
+}
+
+// handleGetPlace returns one place by ID.
+func (s *Server) handleGetPlace(w http.ResponseWriter, r *http.Request) {
+	id := strings.TrimPrefix(r.URL.Path, "/api/places/")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "place ID is required"})
+		return
+	}
+	// This route is a prefix match so that /api/places/{id}/images can be
+	// mounted alongside it. A deeper path is an unknown subresource, not a place
+	// whose ID happens to contain a slash, and IDs are UUIDs, so say so rather
+	// than reporting a place as missing.
+	if strings.Contains(id, "/") {
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such resource"})
+		return
+	}
+	pl, err := s.svc.GetPlace(r.Context(), id)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+
+	// Related places are derived on read, so the REST handler fills them in the
+	// way the GraphQL resolver does. The search is a spatial query, so a failure
+	// is reported rather than swallowed; the place itself has already loaded.
+	related, err := s.svc.RelatedPlaces(r.Context(), id)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	if related == nil {
+		related = []domain.RelatedPlace{}
+	}
+	pl.RelatedPlaces = related
+
+	// The same-place neighbours of each picture are read back so a client can
+	// render them without a further request.
+	for i := range pl.Images {
+		linked, err := s.svc.LinkedImages(r.Context(), pl.Images[i])
+		if err != nil {
+			s.logger.Warn("failed to load linked pictures", "imageId", pl.Images[i].ID, "error", err)
+			continue
+		}
+		if linked != nil {
+			pl.Images[i].LinkedImages = linked
+		}
+	}
+
+	writeJSON(w, http.StatusOK, pl)
+}
+
+// handleCreatePlace creates a new place.
+func (s *Server) handleCreatePlace(w http.ResponseWriter, r *http.Request) {
+	var input struct {
+		Title            string   `json:"title"`
+		Description      string   `json:"description"`
+		Latitude         *float64 `json:"latitude,omitempty"`
+		Longitude        *float64 `json:"longitude,omitempty"`
+		LinkRadiusMeters *float64 `json:"linkRadiusMeters,omitempty"`
+		Caption          string   `json:"caption,omitempty"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+		return
+	}
+	author, err := requireUser(r.Context())
+	if err != nil {
+		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized: a bearer token is required"})
+		return
+	}
+	pl, err := s.svc.CreatePlace(r.Context(), author, domain.PlaceInput{
+		Title:            input.Title,
+		Description:      input.Description,
+		Latitude:         input.Latitude,
+		Longitude:        input.Longitude,
+		LinkRadiusMeters: input.LinkRadiusMeters,
+		Caption:          input.Caption,
+	})
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusCreated, pl)
+}
+
+// handleListPlaceImages lists images for a place.
+func (s *Server) handleListPlaceImages(w http.ResponseWriter, r *http.Request) {
+	placeID := strings.TrimPrefix(r.URL.Path, "/api/places/")
+	idx := strings.Index(placeID, "/images")
+	if idx >= 0 {
+		placeID = placeID[:idx]
+	}
+	if placeID == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "place ID is required"})
+		return
+	}
+	// Support ?limit&?offset query params
+	limit := 20
+	offset := 0
+	if v := r.URL.Query().Get("limit"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n > 0 {
+			limit = n
+		}
+	}
+	if v := r.URL.Query().Get("offset"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
+			offset = n
+		}
+	}
+	f := store.ImageFilter{Limit: limit, Offset: offset}
+	if v, ok := r.URL.Query()["placeId"]; ok && len(v) > 0 {
+		f.PlaceID = v[0]
+	}
+	images, err := s.svc.ListImages(r.Context(), f)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	// The same-place neighbours are read back per picture so the client can show
+	// them without a second round trip.
+	for i := range images {
+		linked, err := s.svc.LinkedImages(r.Context(), images[i])
+		if err != nil {
+			s.logger.Warn("failed to load linked pictures", "imageId", images[i].ID, "error", err)
+			continue
+		}
+		images[i].LinkedImages = linked
+	}
+	writeJSON(w, http.StatusOK, images)
+}
+
+// servePlayground answers a browser hitting GET /graphql with no query.
+func (s *Server) servePlayground(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	io.WriteString(w, playgroundHTML)
+}
+
+// ----------------------------------------------------------------- helpers ---
+
+// writeServiceError maps a service sentinel to an HTTP status and emits the same
+// JSON envelope as the other handlers, so a client only has to parse one shape.
+func writeServiceError(w http.ResponseWriter, err error) {
+	status := http.StatusInternalServerError
+	switch {
+	case errors.Is(err, service.ErrNotFound):
+		status = http.StatusNotFound
+	case errors.Is(err, service.ErrInvalidInput):
+		status = http.StatusBadRequest
+	case errors.Is(err, service.ErrTooLarge):
+		status = http.StatusRequestEntityTooLarge
+	case errors.Is(err, service.ErrUnsupportedType):
+		status = http.StatusUnsupportedMediaType
+	case errors.Is(err, context.DeadlineExceeded):
+		status = http.StatusGatewayTimeout
+	case errors.Is(err, context.Canceled):
+		// The client hung up, so there is nobody left to read the response.
+		return
+	}
+	writeJSON(w, status, map[string]string{"error": err.Error()})
+}
+
+// writeGraphQLRequestError reports a malformed request. A request that never
+// reached the executor is a client error, so this is a 4xx rather than a 200
+// with an errors array.
+func writeGraphQLRequestError(w http.ResponseWriter, message string) {
+	writeJSON(w, http.StatusBadRequest, map[string]interface{}{
+		"errors": []map[string]string{{"message": message}},
+	})
+}
+
+func writeJSON(w http.ResponseWriter, status int, body interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	// The status line is already committed, so an encoding failure here can only
+	// truncate the body; there is nothing left to tell the client.
+	_ = json.NewEncoder(w).Encode(body)
+}
+
+// withLogging records one line per request.
+func (s *Server) withLogging(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+		next.ServeHTTP(rec, r)
+		s.logger.Info("request",
+			"method", r.Method,
+			"path", r.URL.Path,
+			"status", rec.status,
+			"bytes", rec.written,
+			"duration", time.Since(start).String(),
+		)
+	})
+}
+
+// recoverPanics keeps one failing request from taking the whole process down.
+func (s *Server) recoverPanics(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		defer func() {
+			if rec := recover(); rec != nil {
+				s.logger.Error("panic while serving request",
+					"method", r.Method,
+					"path", r.URL.Path,
+					"panic", rec,
+				)
+				http.Error(w, "internal server error", http.StatusInternalServerError)
+			}
+		}()
+		next.ServeHTTP(w, r)
+	})
+}
+
+// statusRecorder captures the status and byte count for the access log.
+type statusRecorder struct {
+	http.ResponseWriter
+	status      int
+	written     int
+	wroteHeader bool
+}
+
+func (r *statusRecorder) WriteHeader(status int) {
+	if !r.wroteHeader {
+		r.status = status
+		r.wroteHeader = true
+	}
+	r.ResponseWriter.WriteHeader(status)
+}
+
+func (r *statusRecorder) Write(b []byte) (int, error) {
+	r.wroteHeader = true
+	n, err := r.ResponseWriter.Write(b)
+	r.written += n
+	return n, err
+}
+
+// firstErrorMessage returns the first resolver error message for the log line.
+func firstErrorMessage(result *graphql.Result) string {
+	if result == nil || len(result.Errors) == 0 {
+		return ""
+	}
+	return result.Errors[0].Message
+}
diff --git a/api/internal/transport/transport_test.go b/api/internal/transport/transport_test.go
new file mode 100644
index 0000000..233aa21
--- /dev/null
+++ b/api/internal/transport/transport_test.go
@@ -0,0 +1,386 @@
+package transport
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"net/textproto"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"go-places-api/internal/analysis"
+	"go-places-api/internal/auth"
+	"go-places-api/internal/blobstore"
+	"go-places-api/internal/config"
+	"go-places-api/internal/service"
+	"go-places-api/internal/store"
+)
+
+// newTestServer wires a Server against a throwaway database and media
+// directory, so a test exercises the real store and blob store rather than
+// fakes. mutate, when given, adjusts the configuration before wiring.
+func newTestServer(t *testing.T, mutate ...func(*config.Config)) *Server {
+	t.Helper()
+
+	dir := t.TempDir()
+	cfg := config.Default()
+	cfg.DatabaseURL = "file:" + filepath.Join(dir, "test.db")
+	cfg.MediaDir = filepath.Join(dir, "media")
+	cfg.AuthMode = "dev"
+	cfg.MaxUploadBytes = 1 << 20
+	for _, m := range mutate {
+		m(&cfg)
+	}
+
+	db, err := store.OpenSQL(cfg.DatabaseURL)
+	if err != nil {
+		t.Fatalf("open store: %v", err)
+	}
+	t.Cleanup(func() { db.Close() })
+
+	blobs, err := blobstore.NewLocalFS(cfg.MediaDir)
+	if err != nil {
+		t.Fatalf("open blob store: %v", err)
+	}
+
+	svc := service.New(service.Options{
+		Store:    db,
+		Blobs:    blobs,
+		Analyzer: stubAnalyzer{},
+		Config:   cfg,
+	})
+
+	srv, err := NewServer(Options{
+		Service: svc,
+		Config:  cfg,
+		Auth:    auth.NewMiddleware(auth.NewDevVerifier(nil)),
+		Blobs:   blobs,
+	})
+	if err != nil {
+		t.Fatalf("new server: %v", err)
+	}
+	return srv
+}
+
+// stubAnalyzer keeps the badge rules out of these tests, which are about the
+// transport rather than about image classification.
+type stubAnalyzer struct{}
+
+var _ analysis.Analyzer = stubAnalyzer{}
+
+func (stubAnalyzer) Name() string { return "stub" }
+
+func (stubAnalyzer) Analyze(ctx context.Context, in analysis.Input) (*analysis.Result, error) {
+	return &analysis.Result{Width: 8, Height: 4}, nil
+}
+
+// do runs a GraphQL request against the handler and returns the decoded body.
+func do(t *testing.T, srv *Server, query string, vars map[string]any) (int, map[string]any) {
+	t.Helper()
+
+	body, err := json.Marshal(map[string]any{"query": query, "variables": vars})
+	if err != nil {
+		t.Fatalf("marshal request: %v", err)
+	}
+	req := httptest.NewRequest(http.MethodPost, "/graphql", bytes.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Authorization", "Bearer valid_token")
+	rec := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, req)
+
+	var out map[string]any
+	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+		t.Fatalf("decode response %q: %v", rec.Body.String(), err)
+	}
+	return rec.Code, out
+}
+
+// data returns the "data" object, failing the test if the response carried
+// errors.
+func data(t *testing.T, resp map[string]any) map[string]any {
+	t.Helper()
+	if errs, ok := resp["errors"]; ok {
+		t.Fatalf("unexpected GraphQL errors: %v", errs)
+	}
+	d, ok := resp["data"].(map[string]any)
+	if !ok {
+		t.Fatalf("response has no data object: %v", resp)
+	}
+	return d
+}
+
+// TestNewSchemaCompiles guards the self-referential Place/Image/RelatedPlace
+// types against being constructed more than once. graphql-go rejects a schema
+// that contains two distinct types under the same name, and that failure only
+// surfaces at startup, so it is worth pinning down here.
+func TestNewSchemaCompiles(t *testing.T) {
+	srv := newTestServer(t)
+
+	if _, err := newSchema(srv.svc); err != nil {
+		t.Fatalf("schema did not compile: %v", err)
+	}
+	for _, name := range []string{"Image", "Place", "RelatedPlace", "Badge", "Coordinate", "Comment", "User"} {
+		if srv.schema.Type(name) == nil {
+			t.Errorf("schema is missing the %s type", name)
+		}
+	}
+}
+
+// TestPlaceLifecycle walks a place through creation, upload and read-back.
+func TestPlaceLifecycle(t *testing.T) {
+	srv := newTestServer(t)
+
+	code, resp := do(t, srv,
+		`mutation($i: PlaceInput!) { createPlace(input: $i) { id title coordinate { latitude longitude } } }`,
+		map[string]any{"i": map[string]any{
+			"title": "Test Place", "latitude": 48.85, "longitude": 2.33,
+		}})
+	if code != http.StatusOK {
+		t.Fatalf("createPlace status = %d, want 200", code)
+	}
+	created := data(t, resp)["createPlace"].(map[string]any)
+	placeID := created["id"].(string)
+	if placeID == "" {
+		t.Fatal("createPlace returned an empty id")
+	}
+	if created["title"] != "Test Place" {
+		t.Errorf("title = %v, want %q", created["title"], "Test Place")
+	}
+
+	// Upload a real PNG so the analysis and decode paths are exercised.
+	uploadCode, img := uploadPNG(t, srv, placeID)
+	if uploadCode != http.StatusCreated {
+		t.Fatalf("upload status = %d, want 201", uploadCode)
+	}
+	imageID := img["id"].(string)
+	url := img["url"].(string)
+	if !strings.HasPrefix(url, "/media/") {
+		t.Errorf("url = %q, want a /media/ prefix", url)
+	}
+	if img["analysisStatus"] != "completed" {
+		t.Errorf("analysisStatus = %v, want completed", img["analysisStatus"])
+	}
+
+	// The picture must be readable back through the place.
+	_, resp = do(t, srv,
+		`query($id: String!) { place(id: $id) { images { id url } } }`,
+		map[string]any{"id": placeID})
+	images := data(t, resp)["place"].(map[string]any)["images"].([]any)
+	if len(images) != 1 {
+		t.Fatalf("got %d images on the place, want 1", len(images))
+	}
+	if got := images[0].(map[string]any)["url"]; got != url {
+		t.Errorf("url = %v, want %q", got, url)
+	}
+
+	// And the media route must serve the stored bytes.
+	req := httptest.NewRequest(http.MethodGet, url, nil)
+	rec := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET %s status = %d, want 200", url, rec.Code)
+	}
+	if got := rec.Header().Get("Content-Type"); got != "image/png" {
+		t.Errorf("Content-Type = %q, want image/png", got)
+	}
+
+	// Deleting the place must remove the stored blob too.
+	_, resp = do(t, srv, `mutation($id: String!) { deletePlace(id: $id) }`, map[string]any{"id": placeID})
+	if data(t, resp)["deletePlace"] != true {
+		t.Fatalf("deletePlace did not return true: %v", resp)
+	}
+	entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
+	if err == nil && len(entries) > 0 {
+		t.Errorf("blob directory still holds %d entries after delete", len(entries))
+	}
+	_ = imageID
+}
+
+// TestResolverErrorYieldsNull checks that a failed resolver nulls its field
+// instead of emitting the zero value of the domain type, which would otherwise
+// hand clients a plausible-looking empty record.
+func TestResolverErrorYieldsNull(t *testing.T) {
+	srv := newTestServer(t)
+
+	code, resp := do(t, srv, `{ place(id: "does-not-exist") { id title } }`, nil)
+	if code != http.StatusOK {
+		t.Fatalf("status = %d, want 200 (errors travel in the body)", code)
+	}
+	if _, ok := resp["errors"]; !ok {
+		t.Error("expected an error entry for a missing place")
+	}
+	if got := data2(t, resp)["place"]; got != nil {
+		t.Errorf("data.place = %v, want null", got)
+	}
+}
+
+// data2 returns the data object without asserting the absence of errors, for
+// the case where errors are expected.
+func data2(t *testing.T, resp map[string]any) map[string]any {
+	t.Helper()
+	d, ok := resp["data"].(map[string]any)
+	if !ok {
+		t.Fatalf("response has no data object: %v", resp)
+	}
+	return d
+}
+
+// TestUnauthenticatedIsRejected checks the auth middleware is actually mounted.
+func TestUnauthenticatedIsRejected(t *testing.T) {
+	srv := newTestServer(t)
+
+	req := httptest.NewRequest(http.MethodPost, "/graphql", strings.NewReader(`{"query":"{ places { id } }"}`))
+	req.Header.Set("Content-Type", "application/json")
+	rec := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusUnauthorized {
+		t.Errorf("status = %d, want 401", rec.Code)
+	}
+}
+
+// TestProbesAreReachableWithoutAuth guards against a probe route accidentally
+// being mounted behind the auth middleware: a kubelet cannot present a token.
+func TestProbesAreReachableWithoutAuth(t *testing.T) {
+	srv := newTestServer(t)
+
+	for _, path := range []string{"/healthz", "/readyz"} {
+		req := httptest.NewRequest(http.MethodGet, path, nil)
+		rec := httptest.NewRecorder()
+		srv.Handler().ServeHTTP(rec, req)
+		if rec.Code != http.StatusOK {
+			t.Errorf("GET %s status = %d, want 200", path, rec.Code)
+		}
+	}
+}
+
+// TestUploadRejectsUnsupportedType checks the content-type guard.
+func TestUploadRejectsUnsupportedType(t *testing.T) {
+	srv := newTestServer(t)
+
+	_, resp := do(t, srv,
+		`mutation { createPlace(input: {title: "T"}) { id } }`, nil)
+	placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
+
+	uploadCode, _ := upload(t, srv, placeID, "notes.txt", "text/plain", []byte("hello"))
+	if uploadCode != http.StatusUnsupportedMediaType {
+		t.Errorf("upload status = %d, want 415", uploadCode)
+	}
+}
+
+// TestUploadRejectsOversizedPayload checks the size guard and that no partial
+// blob is left behind.
+func TestUploadRejectsOversizedPayload(t *testing.T) {
+	srv := newTestServer(t, func(c *config.Config) { c.MaxUploadBytes = 64 })
+
+	_, resp := do(t, srv, `mutation { createPlace(input: {title: "T"}) { id } }`, nil)
+	placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
+
+	uploadCode, _ := upload(t, srv, placeID, "big.png", "image/png", bytes.Repeat([]byte{0x41}, 4096))
+	if uploadCode != http.StatusRequestEntityTooLarge {
+		t.Errorf("upload status = %d, want 413", uploadCode)
+	}
+	entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
+	if err == nil && len(entries) > 0 {
+		t.Errorf("blob directory holds %d entries after a rejected upload", len(entries))
+	}
+}
+
+// uploadPNG posts a small generated PNG and returns the decoded response.
+func uploadPNG(t *testing.T, srv *Server, placeID string) (int, map[string]any) {
+	t.Helper()
+	var buf bytes.Buffer
+	img := image.NewRGBA(image.Rect(0, 0, 8, 4))
+	for y := 0; y < 4; y++ {
+		for x := 0; x < 8; x++ {
+			img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255})
+		}
+	}
+	if err := png.Encode(&buf, img); err != nil {
+		t.Fatalf("encode png: %v", err)
+	}
+	code, resp := upload(t, srv, placeID, "pic.png", "image/png", buf.Bytes())
+	if code != http.StatusCreated {
+		t.Fatalf("upload status = %d, want 201: %v", code, resp)
+	}
+	inner, _ := resp["image"].(map[string]any)
+	if inner == nil {
+		t.Fatalf("upload response has no image object: %v", resp)
+	}
+	return code, inner
+}
+
+// TestGraphQLGetCannotExecuteQueries checks that the unauthenticated GET route
+// only serves the console. If a ?query= URL ran a query, anyone who got hold of
+// the link could read the whole travel log.
+func TestGraphQLGetCannotExecuteQueries(t *testing.T) {
+	srv := newTestServer(t)
+	_, resp := do(t, srv, `mutation { createPlace(input: {title: "Secret"}) { id } }`, nil)
+	placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
+
+	req := httptest.NewRequest(http.MethodGet, "/graphql?"+url.Values{
+		"query": {`{place(id:"` + placeID + `"){title}}`},
+	}.Encode(), nil)
+	rec := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusBadRequest {
+		t.Fatalf("GET /graphql?query status = %d, want 400", rec.Code)
+	}
+	if strings.Contains(rec.Body.String(), "Secret") {
+		t.Fatalf("GET /graphql?query leaked data: %s", rec.Body.String())
+	}
+
+	// The console itself stays reachable, since it carries no data.
+	rec = httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/graphql", nil))
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /graphql status = %d, want 200", rec.Code)
+	}
+	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+		t.Fatalf("GET /graphql content-type = %q, want text/html", ct)
+	}
+}
+
+// upload posts a multipart image and returns the status and decoded body.
+func upload(t *testing.T, srv *Server, placeID, filename, contentType string, payload []byte) (int, map[string]any) {
+	t.Helper()
+
+	var body bytes.Buffer
+	w := multipart.NewWriter(&body)
+	h := make(textproto.MIMEHeader)
+	h.Set("Content-Disposition", `form-data; name="image"; filename="`+filename+`"`)
+	h.Set("Content-Type", contentType)
+	part, err := w.CreatePart(h)
+	if err != nil {
+		t.Fatalf("create part: %v", err)
+	}
+	if _, err := part.Write(payload); err != nil {
+		t.Fatalf("write part: %v", err)
+	}
+	if err := w.Close(); err != nil {
+		t.Fatalf("close writer: %v", err)
+	}
+
+	req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", &body)
+	req.Header.Set("Content-Type", w.FormDataContentType())
+	req.Header.Set("Authorization", "Bearer valid_token")
+	rec := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(rec, req)
+
+	var out map[string]any
+	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+		// Error responses are plain text, which is fine here.
+		return rec.Code, map[string]any{"error": strings.TrimSpace(rec.Body.String())}
+	}
+	return rec.Code, out
+}
diff --git a/charts/api/Chart.yaml b/charts/api/Chart.yaml
new file mode 100644
index 0000000..950e8e1
--- /dev/null
+++ b/charts/api/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: go-places
+description: A Helm chart for deploying the Go Places API service.
+type: application
+version: 0.1.0
+appVersion: "latest"
diff --git a/charts/api/templates/_helpers.tpl b/charts/api/templates/_helpers.tpl
new file mode 100644
index 0000000..5789b4e
--- /dev/null
+++ b/charts/api/templates/_helpers.tpl
@@ -0,0 +1,41 @@
+{{/*
+Full name.
+Used for naming resources like Deployments and Services.
+*/}}
+{{- define "go-places.fullname" -}}
+{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+
+{{/*
+Labels.
+Used to mark resources for selection.
+*/}}
+{{- define "go-places.labels" -}}
+helm.sh/chart: {{ include "go-places.chart" . }}
+{{ include "go-places.selectorLabels" . }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end -}}
+
+{{/*
+Selector labels.
+Used by a Service to find the pods it should route traffic to.
+*/}}
+{{- define "go-places.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "go-places.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end -}}
+
+{{/*
+Name template helper.
+*/}}
+{{- define "go-places.name" -}}
+{{- default .Chart.Name .Values.nameOverride -}}
+{{- end -}}
+
+{{/*
+Chart name helper.
+*/}}
+{{- define "go-places.chart" -}}
+{{- printf "%s-%s-%s" .Chart.Name .Chart.Version .Chart.AppVersion | replace "+" "_" -}}
+{{- end -}}
\ No newline at end of file
diff --git a/charts/api/templates/deployment.yaml b/charts/api/templates/deployment.yaml
new file mode 100644
index 0000000..8f04216
--- /dev/null
+++ b/charts/api/templates/deployment.yaml
@@ -0,0 +1,99 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "go-places.fullname" . }}
+  labels:
+    {{- include "go-places.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.replicaCount }}
+  {{- if .Values.persistence.enabled }}
+  # Recreate, not RollingUpdate: the volume is ReadWriteOnce and SQLite allows a
+  # single writer, so the old pod must be gone before the new one mounts the
+  # claim and opens the database.
+  strategy:
+    type: Recreate
+  {{- end }}
+  selector:
+    matchLabels:
+      {{- include "go-places.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        {{- include "go-places.selectorLabels" . | nindent 8 }}
+    spec:
+      securityContext:
+        # The image runs as uid 10001. fsGroup makes the mounted volume group
+        # writable by that user, which is what allows the non-root process to
+        # create the database file and the media directory.
+        {{- if .Values.persistence.enabled }}
+        fsGroup: {{ .Values.securityContext.fsGroup }}
+        {{- end }}
+      containers:
+        - name: {{ .Chart.Name }}
+          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
+          imagePullPolicy: {{ .Values.image.pullPolicy }}
+          ports:
+            - name: http
+              containerPort: 8080
+              protocol: TCP
+          env:
+            # These names must match the variables read by
+            # internal/config.Load(). Unrecognised names are ignored, so a typo
+            # here would silently leave the service on its defaults.
+            - name: ADDR
+              value: ":8080"
+            - name: DATABASE_URL
+              value: {{ .Values.apiConfig.databaseURL | quote }}
+            - name: MEDIA_DIR
+              value: {{ .Values.apiConfig.mediaDir | quote }}
+            - name: PUBLIC_BASE_URL
+              value: {{ .Values.apiConfig.publicBaseURL | quote }}
+            # "jwt" verifies the HS256 tokens go-loose issues. The secret is
+            # referenced rather than inlined so it can come from a Secret.
+            - name: AUTH_MODE
+              value: {{ .Values.apiConfig.authMode | quote }}
+            - name: JWT_SECRET
+              valueFrom:
+                secretKeyRef:
+                  name: {{ .Values.apiConfig.jwtSecretName | quote }}
+                  key: {{ .Values.apiConfig.jwtSecretKey | quote }}
+                  optional: true
+            - name: JWT_AUDIENCE
+              value: {{ .Values.apiConfig.jwtAudience | quote }}
+            - name: ANALYZER_MODE
+              value: {{ .Values.apiConfig.analyzerMode | quote }}
+            - name: ANALYZER_URL
+              value: {{ .Values.apiConfig.analyzerUrl | quote }}
+            - name: LOG_FORMAT
+              value: {{ .Values.apiConfig.logFormat | quote }}
+            {{- if .Values.apiConfig.amqpUrl }}
+            # Event publishing is off unless a broker URL is given; without it
+            # the service logs events instead.
+            - name: AMQP_URL
+              value: {{ .Values.apiConfig.amqpUrl | quote }}
+            {{- end }}
+            - name: EVENTS_EXCHANGE
+              value: {{ .Values.apiConfig.eventsExchange | quote }}
+          {{- with .Values.probes.livenessProbe }}
+          livenessProbe:
+            {{- toYaml . | nindent 12 }}
+          {{- end }}
+          {{- with .Values.probes.readinessProbe }}
+          readinessProbe:
+            {{- toYaml . | nindent 12 }}
+          {{- end }}
+          resources:
+            {{- toYaml .Values.resources | nindent 12 }}
+          volumeMounts:
+            - name: data
+              mountPath: {{ .Values.persistence.mountPath | quote }}
+      volumes:
+        - name: data
+          {{- if .Values.persistence.enabled }}
+          persistentVolumeClaim:
+            claimName: {{ .Values.persistence.existingClaim | default (printf "%s-data" (include "go-places.fullname" .)) }}
+          {{- else }}
+          # Without persistence the service still runs, but the database and every
+          # uploaded picture are lost whenever the pod is replaced.
+          emptyDir: {}
+          {{- end }}
\ No newline at end of file
diff --git a/charts/api/templates/pvc.yaml b/charts/api/templates/pvc.yaml
new file mode 100644
index 0000000..73eba69
--- /dev/null
+++ b/charts/api/templates/pvc.yaml
@@ -0,0 +1,20 @@
+{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: {{ include "go-places.fullname" . }}-data
+  labels:
+    {{- include "go-places.labels" . | nindent 4 }}
+spec:
+  accessModes:
+    {{- toYaml .Values.persistence.accessModes | nindent 4 }}
+  {{- if .Values.persistence.storageClass }}
+  # An empty storageClassName would ask for dynamic provisioning from *no*
+  # class, which leaves the claim Pending, so the field is only emitted when a
+  # class is actually named.
+  storageClassName: {{ .Values.persistence.storageClass | quote }}
+  {{- end }}
+  resources:
+    requests:
+      storage: {{ .Values.persistence.size | quote }}
+{{- end }}
\ No newline at end of file
diff --git a/charts/api/templates/service.yaml b/charts/api/templates/service.yaml
new file mode 100644
index 0000000..b63a69f
--- /dev/null
+++ b/charts/api/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "go-places.fullname" . }}
+  labels:
+    {{- include "go-places.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.service.type }} # Should be ClusterIP for internal communication
+  ports:
+    - port: {{ .Values.service.port }} # Port the Service exposes internally
+      targetPort: http # TargetPort should match the containerPort in deployment.yaml
+      protocol: TCP
+      name: http
+  selector:
+    {{- include "go-places.selectorLabels" . | nindent 4 }}
\ No newline at end of file
diff --git a/charts/api/values.yaml b/charts/api/values.yaml
new file mode 100644
index 0000000..15f88e7
--- /dev/null
+++ b/charts/api/values.yaml
@@ -0,0 +1,92 @@
+# Values for the go-places chart.
+
+replicaCount: 1
+# SQLite accepts a single writer and the data volume is ReadWriteOnce, so this
+# service must run as exactly one replica. The chart forces a Recreate strategy
+# when persistence is enabled for the same reason.
+
+image:
+  repository: go-places
+  pullPolicy: IfNotPresent
+  tag: "latest"
+
+service:
+  type: ClusterIP
+  port: 8080
+
+apiConfig:
+  # SQLite lives on the pod's data volume. The pragmas mirror the local
+  # development default in internal/config.
+  databaseURL: "file:/app/data/go-places.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
+  mediaDir: /app/data/media
+  # Prefix applied to stored media paths when building absolute URLs. Set this
+  # to the externally reachable origin so pictures resolve outside the cluster.
+  publicBaseURL: ""
+
+  # "jwt" verifies the HS256 tokens go-loose issues. Never set "dev" here: it
+  # accepts a fixed token table and would let anyone in act as any user.
+  authMode: jwt
+  jwtSecretName: go-loose-jwt
+  jwtSecretKey: secret
+  jwtAudience: ""
+
+  # "local" uses the built-in heuristics. "remote" additionally calls the
+  # inference service at analyzerUrl; the local pass still runs first, so
+  # pictures get badges even if that service is down.
+  analyzerMode: local
+  analyzerUrl: ""
+  logFormat: json
+
+  # Empty disables the event broker; events are then written to the log.
+  amqpUrl: ""
+  eventsExchange: go-places.events
+
+probes:
+  livenessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 10
+    periodSeconds: 20
+  readinessProbe:
+    httpGet:
+      path: /readyz
+      port: http
+    initialDelaySeconds: 5
+    periodSeconds: 10
+
+resources:
+  limits:
+    cpu: 500m
+    memory: 512Mi
+  requests:
+    cpu: 250m
+    memory: 256Mi
+
+persistence:
+  # When disabled the pod still starts, but the database and every uploaded
+  # picture live in an emptyDir and are lost on each pod replacement.
+  enabled: true
+  # Mounted over /app/data so the database file and the media directory share
+  # one volume. apiConfig.databaseURL and apiConfig.mediaDir must stay inside
+  # this path or they will not be persisted.
+  mountPath: /app/data
+  # WARNING: raising this on a running release breaks `helm upgrade`. The
+  # local-path StorageClass does not allow volume expansion, so the API server
+  # rejects the resized claim, the upgrade aborts part-way, and the release is
+  # left in STATUS=failed until it is rolled back or upgraded again without the
+  # size change. Grow the underlying storage (or add a StorageClass that
+  # supports expansion) before editing this value.
+  size: 2Gi
+  accessModes:
+    - ReadWriteOnce
+  # Empty means "use the cluster's default StorageClass". The cluster this
+  # deploys to provides "local-path", which stores data on the node's own disk:
+  # that survives pod replacement but not loss of the node.
+  storageClass: ""
+  # Set to the name of a pre-created claim to use that instead of creating one.
+  existingClaim: ""
+
+securityContext:
+  # Must match the uid the image runs as, so the mounted volume is writable.
+  fsGroup: 10001
\ No newline at end of file
diff --git a/charts/go-loose/Chart.lock b/charts/go-loose/Chart.lock
new file mode 100644
index 0000000..1be5a64
--- /dev/null
+++ b/charts/go-loose/Chart.lock
@@ -0,0 +1,6 @@
+dependencies:
+- name: postgresql
+  repository: https://charts.bitnami.com/bitnami
+  version: 16.7.27
+digest: sha256:e8b999a3aaad6328a13d0c666207650897b69c61367c947d363b2acbe0ea1b42
+generated: "2026-10-04T19:38:06.343578353+02:00"
diff --git a/charts/go-loose/Chart.yaml b/charts/go-loose/Chart.yaml
new file mode 100644
index 0000000..e51ed91
--- /dev/null
+++ b/charts/go-loose/Chart.yaml
@@ -0,0 +1,13 @@
+# Chart metadata for go-loose, the authentication service.
+# Note: this file is plain YAML, not a Helm template, so comments start with '#'.
+apiVersion: v2
+name: go-loose
+description: A Helm chart for deploying the go-loose SSO service.
+type: application
+version: 0.1.0
+appVersion: "latest"
+dependencies:
+  - name: postgresql
+    version: "16.x.x"
+    repository: "https://charts.bitnami.com/bitnami"
+    condition: postgresql.enabled
\ No newline at end of file
diff --git a/charts/go-loose/charts/postgresql-16.7.27.tgz b/charts/go-loose/charts/postgresql-16.7.27.tgz
new file mode 100644
index 0000000..73dc91a
Binary files /dev/null and b/charts/go-loose/charts/postgresql-16.7.27.tgz differ
diff --git a/charts/go-loose/templates/_helpers.tpl b/charts/go-loose/templates/_helpers.tpl
new file mode 100644
index 0000000..683bb87
--- /dev/null
+++ b/charts/go-loose/templates/_helpers.tpl
@@ -0,0 +1,41 @@
+{{/*
+Full name.
+Used for naming resources like Deployments and Services.
+*/}}
+{{- define "go-loose.fullname" -}}
+{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+
+{{/*
+Labels.
+Used to mark resources for selection.
+*/}}
+{{- define "go-loose.labels" -}}
+helm.sh/chart: {{ include "go-loose.chart" . }}
+{{ include "go-loose.selectorLabels" . }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end -}}
+
+{{/*
+Selector labels.
+Used by a Service to find the pods it should route traffic to.
+*/}}
+{{- define "go-loose.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "go-loose.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end -}}
+
+{{/*
+Name template helper.
+*/}}
+{{- define "go-loose.name" -}}
+{{- default .Chart.Name .Values.nameOverride -}}
+{{- end -}}
+
+{{/*
+Chart name helper.
+*/}}
+{{- define "go-loose.chart" -}}
+{{- printf "%s-%s-%s" .Chart.Name .Chart.Version .Chart.AppVersion | replace "+" "_" -}}
+{{- end -}}
\ No newline at end of file
diff --git a/charts/go-loose/templates/deployment.yaml b/charts/go-loose/templates/deployment.yaml
new file mode 100644
index 0000000..3f54538
--- /dev/null
+++ b/charts/go-loose/templates/deployment.yaml
@@ -0,0 +1,47 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "go-loose.fullname" . }}
+  labels:
+    {{- include "go-loose.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.replicaCount }}
+  selector:
+    matchLabels:
+      {{- include "go-loose.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        {{- include "go-loose.labels" . | nindent 8 }}
+    spec:
+      containers:
+        - name: {{ .Chart.Name }}
+          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+          imagePullPolicy: {{ .Values.image.pullPolicy }}
+          ports:
+            - name: http
+              containerPort: {{ .Values.service.port }}
+              protocol: TCP
+          env:
+            - name: GO_LOOSE_SESSION_SECRET
+              value: {{ .Values.sessionSecret | quote }}
+            - name: GO_LOOSE_DATABASE_URL
+              value: "postgres://{{ .Values.postgresql.auth.username }}:{{ .Values.postgresql.auth.password }}@{{ .Release.Name }}-postgresql:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable"
+          {{- with .Values.probes.livenessProbe }}
+          livenessProbe:
+            httpGet:
+              path: {{ .httpGet.path }}
+              port: http
+            initialDelaySeconds: {{ .initialDelaySeconds }}
+            periodSeconds: {{ .periodSeconds }}
+          {{- end }}
+          {{- with .Values.probes.readinessProbe }}
+          readinessProbe:
+            httpGet:
+              path: {{ .httpGet.path }}
+              port: http
+            initialDelaySeconds: {{ .initialDelaySeconds }}
+            periodSeconds: {{ .periodSeconds }}
+          {{- end }}
+          resources:
+            {{- toYaml .Values.resources | nindent 12 }}
\ No newline at end of file
diff --git a/charts/go-loose/templates/ingress.yaml b/charts/go-loose/templates/ingress.yaml
new file mode 100644
index 0000000..80a9e14
--- /dev/null
+++ b/charts/go-loose/templates/ingress.yaml
@@ -0,0 +1,34 @@
+{{- if .Values.ingress.enabled -}}
+{{- $fullName := include "go-loose.fullname" . }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: {{ $fullName }}
+  labels:
+    {{- include "go-loose.labels" . | nindent 4 }}
+  {{- with .Values.ingress.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  ingressClassName: {{ .Values.ingress.className }}
+  {{- if .Values.ingress.tls }}
+  tls:
+    {{- toYaml .Values.ingress.tls | nindent 4 }}
+  {{- end }}
+  rules:
+    {{- range .Values.ingress.hosts }}
+    - host: {{ .host }}
+      http:
+        paths:
+          {{- range .paths }}
+          - path: {{ .path }}
+            pathType: {{ .pathType }}
+            backend:
+              service:
+                name: {{ $fullName }}
+                port:
+                  number: {{ $.Values.service.port }}
+          {{- end }}
+    {{- end }}
+{{- end }}
\ No newline at end of file
diff --git a/charts/go-loose/templates/service.yaml b/charts/go-loose/templates/service.yaml
new file mode 100644
index 0000000..ee06bf5
--- /dev/null
+++ b/charts/go-loose/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "go-loose.fullname" . }}
+  labels:
+    {{- include "go-loose.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.service.type }} # ClusterIP for internal service discovery
+  ports:
+    - port: {{ .Values.service.port }}
+      targetPort: http # Must match the containerPort in deployment.yaml
+      protocol: TCP
+      name: http
+  selector:
+    {{- include "go-loose.selectorLabels" . | nindent 4 }}
\ No newline at end of file
diff --git a/charts/go-loose/values-dev.yaml b/charts/go-loose/values-dev.yaml
new file mode 100644
index 0000000..9900907
--- /dev/null
+++ b/charts/go-loose/values-dev.yaml
@@ -0,0 +1,73 @@
+# Development values for go-loose
+replicaCount: 1
+
+sessionSecret: "dev-session-secret-change-me-to-32-plus-chars"
+
+postgresql:
+  enabled: true
+  image:
+    registry: docker.io
+    repository: bitnami/postgresql
+    tag: latest
+    pullPolicy: IfNotPresent
+  auth:
+    username: goloose
+    password: goloose
+    database: goloose
+  primary:
+    persistence:
+      enabled: true
+      size: 2Gi
+
+image:
+  repository: batty1039.startdedicated.net:5000/go-loose
+  pullPolicy: Always
+  tag: latest
+
+service:
+  type: ClusterIP
+  port: 8080
+
+probes:
+  livenessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 15
+    periodSeconds: 20
+  readinessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 5
+    periodSeconds: 10
+
+resources:
+  limits:
+    cpu: 500m
+    memory: 512Mi
+  requests:
+    cpu: 100m
+    memory: 128Mi
+
+ingress:
+  enabled: true
+  className: nginx
+  annotations:
+    cert-manager.io/cluster-issuer: letsencrypt-staging
+    nginx.ingress.kubernetes.io/ssl-redirect: "true"
+  hosts:
+    - host: auth-dev.urpi.be
+      paths:
+        - path: /
+          pathType: Prefix
+  tls:
+    - secretName: go-loose-tls
+      hosts:
+        - auth-dev.urpi.be
+
+certManager:
+  enabled: true
+  issuerRef:
+    name: letsencrypt-staging
+    kind: ClusterIssuer
\ No newline at end of file
diff --git a/charts/go-loose/values-lcl.yaml b/charts/go-loose/values-lcl.yaml
new file mode 100644
index 0000000..4236d8d
--- /dev/null
+++ b/charts/go-loose/values-lcl.yaml
@@ -0,0 +1,73 @@
+# Local (LCL) values for go-loose (the auth service).
+# Served at https://auth-lcl.intern-urpi.be by the docker TLS edge.
+replicaCount: 1
+
+sessionSecret: "lcl-session-secret-change-me-to-32-plus-chars"
+
+postgresql:
+  enabled: true
+  image:
+    registry: docker.io
+    repository: bitnami/postgresql
+    tag: latest
+    pullPolicy: IfNotPresent
+  auth:
+    username: goloose
+    password: goloose
+    database: goloose
+  primary:
+    persistence:
+      enabled: true
+      size: 2Gi
+
+image:
+  repository: localhost:5000/go-loose
+  pullPolicy: IfNotPresent
+  tag: "0.0.1"
+
+service:
+  type: ClusterIP
+  port: 8080
+
+probes:
+  livenessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 15
+    periodSeconds: 20
+  readinessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 5
+    periodSeconds: 10
+
+resources:
+  limits:
+    cpu: 500m
+    memory: 512Mi
+  requests:
+    cpu: 100m
+    memory: 128Mi
+
+ingress:
+  enabled: true
+  className: traefik
+  annotations: {}
+  hosts:
+    - host: auth-lcl.intern-urpi.be
+      paths:
+        - path: /
+          pathType: Prefix
+  tls:
+    - secretName: local-dev-cert
+      hosts:
+        - auth-lcl.intern-urpi.be
+
+# No cert-manager in the local cluster.
+certManager:
+  enabled: false
+  issuerRef:
+    name: letsencrypt-staging
+    kind: ClusterIssuer
diff --git a/charts/go-loose/values-prd.yaml b/charts/go-loose/values-prd.yaml
new file mode 100644
index 0000000..20f158d
--- /dev/null
+++ b/charts/go-loose/values-prd.yaml
@@ -0,0 +1,73 @@
+# Production values for go-loose
+replicaCount: 2
+
+sessionSecret: ""  # Set via --set or secret
+
+postgresql:
+  enabled: true
+  image:
+    registry: docker.io
+    repository: bitnami/postgresql
+    tag: latest
+    pullPolicy: IfNotPresent
+  auth:
+    username: goloose
+    password: goloose
+    database: goloose
+  primary:
+    persistence:
+      enabled: true
+      size: 10Gi
+
+image:
+  repository: batty1039.startdedicated.net:5000/go-loose
+  pullPolicy: Always
+  tag: latest
+
+service:
+  type: ClusterIP
+  port: 8080
+
+probes:
+  livenessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 15
+    periodSeconds: 20
+  readinessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 5
+    periodSeconds: 10
+
+resources:
+  limits:
+    cpu: 1000m
+    memory: 1Gi
+  requests:
+    cpu: 200m
+    memory: 256Mi
+
+ingress:
+  enabled: true
+  className: nginx
+  annotations:
+    cert-manager.io/cluster-issuer: letsencrypt-prod
+    nginx.ingress.kubernetes.io/ssl-redirect: "true"
+  hosts:
+    - host: auth.urpi.be
+      paths:
+        - path: /
+          pathType: Prefix
+  tls:
+    - secretName: go-loose-tls
+      hosts:
+        - auth.urpi.be
+
+certManager:
+  enabled: true
+  issuerRef:
+    name: letsencrypt-prod
+    kind: ClusterIssuer
\ No newline at end of file
diff --git a/charts/go-loose/values-tst.yaml b/charts/go-loose/values-tst.yaml
new file mode 100644
index 0000000..7112a3e
--- /dev/null
+++ b/charts/go-loose/values-tst.yaml
@@ -0,0 +1,73 @@
+# TST values for go-loose
+replicaCount: 1
+
+sessionSecret: "tst-session-secret-change-me-to-32-plus-chars"
+
+postgresql:
+  enabled: true
+  image:
+    registry: docker.io
+    repository: bitnami/postgresql
+    tag: latest
+    pullPolicy: IfNotPresent
+  auth:
+    username: goloose
+    password: goloose
+    database: goloose
+  primary:
+    persistence:
+      enabled: true
+      size: 5Gi
+
+image:
+  repository: batty1039.startdedicated.net:5000/go-loose
+  pullPolicy: Always
+  tag: latest
+
+service:
+  type: ClusterIP
+  port: 8080
+
+probes:
+  livenessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 15
+    periodSeconds: 20
+  readinessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 5
+    periodSeconds: 10
+
+resources:
+  limits:
+    cpu: 750m
+    memory: 512Mi
+  requests:
+    cpu: 150m
+    memory: 256Mi
+
+ingress:
+  enabled: true
+  className: nginx
+  annotations:
+    cert-manager.io/cluster-issuer: letsencrypt-staging
+    nginx.ingress.kubernetes.io/ssl-redirect: "true"
+  hosts:
+    - host: auth-tst.intern-urpi.be
+      paths:
+        - path: /
+          pathType: Prefix
+  tls:
+    - secretName: go-loose-tls
+      hosts:
+        - auth-tst.intern-urpi.be
+
+certManager:
+  enabled: true
+  issuerRef:
+    name: letsencrypt-staging
+    kind: ClusterIssuer
diff --git a/charts/go-loose/values.yaml b/charts/go-loose/values.yaml
new file mode 100644
index 0000000..d481e93
--- /dev/null
+++ b/charts/go-loose/values.yaml
@@ -0,0 +1,53 @@
+# Default values for go-loose.
+# This is where you can override the default values in the chart.
+
+replicaCount: 1
+
+sessionSecret: "local-development-secret-change-me-now"
+databaseURL: "postgres://goloose:goloose@go-guess-postgresql:5432/goloose?sslmode=disable"
+
+image:
+  # Placeholder: needs the real registry path for the go-loose image.
+  repository: batty1039.startdedicated.net:5000/go-loose
+  pullPolicy: IfNotPresent
+  tag: latest
+
+service:
+  type: ClusterIP
+  port: 8080
+
+# ingress is off by default. The template reads .Values.ingress.enabled
+# directly, so the key has to exist even when ingress is disabled, otherwise
+# `helm template` fails with a nil pointer error.
+ingress:
+  enabled: false
+  className: nginx
+  annotations: {}
+  # tls: []
+  hosts:
+    - host: go-loose.local
+      paths:
+        - path: /
+          pathType: Prefix
+
+probes:
+  livenessProbe:
+    httpGet:
+      path: /healthz
+      port: http
+    initialDelaySeconds: 15
+    periodSeconds: 20
+  readinessProbe:
+    httpGet:
+      path: /readyz
+      port: http
+    initialDelaySeconds: 5
+    periodSeconds: 10
+
+resources:
+  limits:
+    cpu: 500m
+    memory: 512Mi
+  requests:
+    cpu: 100m
+    memory: 128Mi
\ No newline at end of file
diff --git a/dur.png b/dur.png
new file mode 100644
index 0000000..c9591ee
Binary files /dev/null and b/dur.png differ
diff --git a/dusk.png b/dusk.png
new file mode 100644
index 0000000..3a1505f
Binary files /dev/null and b/dusk.png differ
diff --git a/go-places.db b/go-places.db
new file mode 100644
index 0000000..d4fec8b
Binary files /dev/null and b/go-places.db differ
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..915ba5b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,376 @@
+{
+  "name": "workspace",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "workspace",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "axios": "^1.20.0",
+        "react": "^19.3.0",
+        "react-dom": "^19.3.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
+      "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.16.0",
+        "form-data": "^4.0.6",
+        "https-proxy-agent": "^5.0.1",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
+      "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.4",
+        "mime-types": "^2.1.35"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.3.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+      "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.3.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+      "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.28.0"
+      },
+      "peerDependencies": {
+        "react": "^19.3.0"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+      "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+      "license": "MIT"
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..62ef026
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+  "name": "workspace",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "type": "commonjs",
+  "dependencies": {
+    "axios": "^1.20.0",
+    "react": "^19.3.0",
+    "react-dom": "^19.3.0"
+  }
+}
diff --git a/shared-interfaces/schema/models.graphql b/shared-interfaces/schema/models.graphql
new file mode 100644
index 0000000..f4df191
--- /dev/null
+++ b/shared-interfaces/schema/models.graphql
@@ -0,0 +1,160 @@
+# --- Shared Data Models (Go source of truth) ---
+#
+# These structs are documentation of the domain layer; the executable GraphQL
+# schema lives in `api/internal/transport/schema.go` and is what clients get
+# from an introspection query. The two are kept in step: every field below has a
+# matching field in the executable schema, and vice versa.
+#
+# Note the `json` tag on each field. The Go handlers serialise the domain types
+# directly, so these tags are also the REST field names, and the REST client in
+# `web/src/types.ts` mirrors them.
+
+// Coordinate is a WGS-84 geographic point.
+type Coordinate struct {
+	Latitude  float64 `json:"latitude"  gql:"latitude"`
+	Longitude float64 `json:"longitude" gql:"longitude"`
+}
+
+// User represents a logged-in user. Identity is owned by the external go-loose
+// SSO service; this service only stores the identifier it asserts.
+type User struct {
+	ID       string `json:"id"       gql:"id"`
+	Username string `json:"username" gql:"username"`
+	Email    string `json:"email"    gql:"email"`
+}
+
+// Badge is a recognisable attribute detected in a picture, for example an object
+// ("dog"), a scene ("beach") or a property ("low-light"). Label is the stable
+// machine-readable name; Display is the human-facing rendering. Confidence is a
+// 0..1 score, and Source records which analyzer produced the badge.
+type Badge struct {
+	Label      string  `json:"label"      gql:"label"`
+	Display    string  `json:"display"    gql:"display"`
+	Confidence float64 `json:"confidence" gql:"confidence"`
+	Source     string  `json:"source"     gql:"source"`
+}
+
+// Image is a picture attached to a Place.
+type Image struct {
+	ID      string `json:"id"      gql:"id"`
+	PlaceID string `json:"placeId" gql:"placeId"`
+
+	// StorageKey is the location of the bytes inside the blob store. It is never
+	// exposed: URL is the public rendering of this key.
+	StorageKey  string `json:"-"             gql:"-"`
+	URL         string `json:"url"         gql:"url"`
+	Caption     string `json:"caption"     gql:"caption"`
+	ContentType string `json:"contentType" gql:"contentType"`
+	ByteSize    int64  `json:"byteSize"    gql:"byteSize"`
+	Width       int    `json:"width"       gql:"width"`
+	Height      int    `json:"height"      gql:"height"`
+
+	// Coordinate is where the picture was taken: the EXIF GPS tag when present,
+	// and the parent Place otherwise.
+	Coordinate *Coordinate `json:"coordinate" gql:"coordinate"`
+	// TakenAt, CameraMake and CameraModel come from EXIF.
+	TakenAt     *time.Time `json:"takenAt"     gql:"takenAt"`
+	CameraMake  string     `json:"cameraMake"  gql:"cameraMake"`
+	CameraModel string     `json:"cameraModel" gql:"cameraModel"`
+
+	Badges         []Badge `json:"badges"         gql:"badges"`
+	AnalysisStatus string  `json:"analysisStatus" gql:"analysisStatus"`
+	AnalysisError  string  `json:"analysisError"  gql:"analysisError"`
+
+	// LinkedImages are other pictures taken at the same location. They are
+	// resolved by the linking pass on read, never stored.
+	LinkedImages []Image `json:"linkedImages" gql:"linkedImages"`
+
+	CreatedAt time.Time `json:"createdAt" gql:"createdAt"`
+}
+
+// Place is a location the user visited and shared. It anchors geographic linking.
+type Place struct {
+	ID          string `json:"id"          gql:"id"`
+	Title       string `json:"title"       gql:"title"`
+	Description string `json:"description" gql:"description"`
+
+	// Coordinate is required for linking to work. LinkRadiusMeters is how close
+	// two pictures must be to count as the same place.
+	Coordinate       *Coordinate `json:"coordinate"       gql:"coordinate"`
+	LinkRadiusMeters float64     `json:"linkRadiusMeters" gql:"linkRadiusMeters"`
+
+	Creator  User      `json:"creator"  gql:"creator"`
+	Images   []Image   `json:"images"   gql:"images"`
+	Comments []Comment `json:"comments" gql:"comments"`
+	// RelatedPlaces are other Places whose pictures overlap this one.
+	RelatedPlaces []RelatedPlace `json:"relatedPlaces" gql:"relatedPlaces"`
+
+	CreatedAt time.Time `json:"createdAt" gql:"createdAt"`
+}
+
+// RelatedPlace records the link between two Places discovered by the linking
+// pass, along with the evidence for it.
+type RelatedPlace struct {
+	Place          Place    `json:"place"          gql:"place"`
+	DistanceMeters float64  `json:"distanceMeters" gql:"distanceMeters"`
+	SharedImageIDs []string `json:"sharedImageIds" gql:"sharedImageIds"`
+}
+
+// Comment is a user reaction on a Place.
+type Comment struct {
+	ID        string    `json:"id"        gql:"id"`
+	PlaceID   string    `json:"placeId"   gql:"placeId"`
+	Content   string    `json:"content"   gql:"content"`
+	Creator   User      `json:"creator"   gql:"creator"`
+	CreatedAt time.Time `json:"createdAt" gql:"createdAt"`
+}
+
+// Link is a single edge produced by the linking pass, connecting one picture to
+// another taken at the same place. Links are a diagnostic view and are not
+// exposed over the API.
+type Link struct {
+	ImageID        string  `json:"imageId"        gql:"-"`
+	RelatedImageID string  `json:"relatedImageId" gql:"-"`
+	DistanceMeters float64 `json:"distanceMeters" gql:"-"`
+	// SamePlace is true when both pictures belong to the same Place record.
+	SamePlace bool   `json:"samePlace" gql:"-"`
+	Reason    string `json:"reason"    gql:"-"`
+}
+
+// PlaceInput is the payload for creating a Place, and the argument to the
+// createPlace mutation. Latitude and Longitude are pointers so an omitted
+// coordinate can be told apart from the origin, which is a legitimate point.
+type PlaceInput struct {
+	Title            string   `json:"title"            gql:"title"`
+	Description      string   `json:"description"      gql:"description"`
+	Latitude         *float64 `json:"latitude"         gql:"latitude"`
+	Longitude        *float64 `json:"longitude"        gql:"longitude"`
+	LinkRadiusMeters *float64 `json:"linkRadiusMeters" gql:"linkRadiusMeters"`
+	// Caption is the description of the first picture in the upload, if any.
+	Caption string `json:"caption" gql:"caption"`
+}
+
+// --- Queries ---
+type Query struct {
+	GetPlace(id string) *Place `gql:"place"`
+	GetAllPlaces()     []*Place `gql:"places"`
+	GetImages()        []*Image  `gql:"images"`
+	GetRelatedPlaces() []*RelatedPlace `gql:"relatedPlaces"`
+}
+
+// --- Mutations ---
+type Mutation struct {
+	CreatePlace(input PlaceInput) *Place   `gql:"createPlace"`
+	AddComment(placeID, content)   *Comment `gql:"addComment"`
+	UpdateImageCaption(imageID, caption) *Image `gql:"updateImageCaption"`
+	DeletePlace(id string) bool     `gql:"deletePlace"`
+	DeleteImage(id string) bool     `gql:"deleteImage"`
+}
+
+// --- REST surface ---
+//
+// The React client uses these instead of GraphQL; both serialise the same
+// domain structs, so the shapes match field for field. Enabled with
+// ENABLE_REST=true.
+//
+//	GET  /api/places                     -> []Place
+//	POST /api/places                     -> Place
+//	GET  /api/places/{placeId}           -> Place   (with relatedPlaces)
+//	GET  /api/places/{placeId}/images    -> []Image
+//	POST /places/{placeId}/images        -> {"image": Image}   multipart
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..cbd50c5
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,12 @@
+
+
+  
+    
+    
+    Go-Places Explorer
+  
+  
+    
+ + + \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..1f75db4 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1227 @@ +{ + "name": "web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@vitejs/plugin-react": "^6.1.1", + "axios": "^1.20.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "vite": "^8.3.2" + }, + "devDependencies": { + "@types/react-dom": "^19.3.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..38e5eb2 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "web", + "version": "1.0.0", + "description": "React client for the go-places API", + "main": "index.js", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@vitejs/plugin-react": "^6.1.1", + "axios": "^1.20.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "vite": "^8.3.2" + }, + "devDependencies": { + "@types/react-dom": "^19.3.0", + "typescript": "^5.9.3" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..9c93b83 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,402 @@ +/** + * Go Places explorer. + * + * Lists the places the API knows about, lets one be opened to see its pictures + * and the pictures linked to them as having been taken at the same spot, and + * creates a new place with an optional first picture. + */ + +import React, {useCallback, useEffect, useState} from 'react' + +import { + ApiError, + createPlace, + getPlace, + getToken, + listPlaces, + resolveMedia, + setToken, + uploadImage, +} from './api' +import type {Image, Place} from './types' +import './styles.css' + +/** BadgeChip renders one analysis badge, showing confidence when the analyzer supplied it. */ +function BadgeChip({label, confidence}: {label: string; confidence: number}) { + const pct = Math.round(confidence * 100) + return ( + + {label} + + ) +} + +/** PictureCard shows one picture with its badges and how it was captured. */ +function PictureCard({image, onOpenPlace}: {image: Image; onOpenPlace: (id: string) => void}) { + const linked = image.linkedImages ?? [] + const badges = image.badges ?? [] + return ( +
+ {image.caption +
+ {image.caption &&

{image.caption}

} + + {badges.length > 0 && ( +
+ {badges.map((b) => ( + + ))} +
+ )} + +
+ {image.coordinate && ( + <> +
Location
+
+ {image.coordinate.latitude.toFixed(5)}, {image.coordinate.longitude.toFixed(5)} +
+ + )} + {image.takenAt && ( + <> +
Taken
+
{new Date(image.takenAt).toLocaleString()}
+ + )} + {image.cameraModel && ( + <> +
Camera
+
+ {[image.cameraMake, image.cameraModel].filter(Boolean).join(' ')} +
+ + )} + {image.analysisStatus === 'failed' && ( + <> +
Analysis
+
{image.analysisError || 'failed'}
+ + )} +
+ + {linked.length > 0 && ( +
+

Same place

+
    + {linked.map((other) => ( +
  • + +
  • + ))} +
+
+ )} +
+
+ ) +} + +/** CreatePlaceForm collects a place and optionally a first picture for it. */ +function CreatePlaceForm({onCreated}: {onCreated: (place: Place) => void}) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [latitude, setLatitude] = useState('') + const [longitude, setLongitude] = useState('') + const [file, setFile] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const submit = async (event: React.FormEvent) => { + event.preventDefault() + setBusy(true) + setError(null) + try { + const place = await createPlace({ + title, + description, + // An empty field means "no coordinate", which is distinct from 0,0, so + // the optional fields are only sent when both halves are filled in. + ...(latitude && longitude + ? {latitude: Number(latitude), longitude: Number(longitude)} + : {}), + }) + if (file) { + // The place exists either way, so a failed picture upload is reported + // without discarding the place. + try { + await uploadImage(place.id, file, description) + } catch (err) { + setError(`The place was created but the picture was rejected: ${messageOf(err)}`) + onCreated(place) + return + } + } + setTitle('') + setDescription('') + setLatitude('') + setLongitude('') + setFile(null) + onCreated(place) + } catch (err) { + setError(messageOf(err)) + } finally { + setBusy(false) + } + } + + return ( +
+

Add a place

+ {error &&

{error}

} + +