initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

11
.env.example Normal file
View file

@ -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

9
.gitignore vendored Normal file
View file

@ -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

162
DEVELOPER.md Normal file
View file

@ -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 <http://localhost:8080/graphql> 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 `<img>` 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.

48
Dockerfile Normal file
View file

@ -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"]

102
INFRASTRUCTURE.md Normal file
View file

@ -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 <target-cluster>`).
### 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/<key>` should return `200` and `Cache-Control: public, max-age=31536000, immutable`. Media is deliberately unauthenticated so `<img>` 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.*

110
Makefile Normal file
View file

@ -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."

211
api/cmd/main.go Normal file
View file

@ -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...) }

23
api/go.mod Normal file
View file

@ -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
)

53
api/go.sum Normal file
View file

@ -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=

View file

@ -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)
}

View file

@ -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() }

View file

@ -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) }

View file

@ -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
}

View file

@ -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 }

View file

@ -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...)
}

View file

@ -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
}

View file

@ -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 }

200
api/internal/auth/auth.go Normal file
View file

@ -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 <token>" 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 ""
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

92
api/internal/geo/geo.go Normal file
View file

@ -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))
}

View file

@ -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}
}

View file

@ -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
})
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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")

View file

@ -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) {}

View file

@ -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

795
api/internal/store/sql.go Normal file
View file

@ -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)
}
}

116
api/internal/store/store.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>go-places GraphQL</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 0; padding: 1.5rem; }
h1 { font-size: 1rem; margin: 0 0 1rem; }
textarea, pre { width: 100%; box-sizing: border-box; padding: .75rem; border: 1px solid #8884;
border-radius: 6px; background: #0001; color: inherit; }
textarea { min-height: 12rem; font: inherit; }
button { margin: .75rem 0; padding: .5rem 1rem; font: inherit; cursor: pointer;
border-radius: 6px; border: 1px solid #8886; background: #0002; color: inherit; }
pre { min-height: 6rem; overflow: auto; }
.row { display: flex; gap: .75rem; align-items: baseline; }
.hint { opacity: .7; }
</style>
</head>
<body>
<h1>go-places GraphQL</h1>
<div class="row">
<label for="token">Bearer token</label>
<input id="token" size="40" value="valid_token" style="font:inherit;padding:.4rem">
<span class="hint">AUTH_MODE=dev accepts <code>valid_token</code></span>
</div>
<p><textarea id="q">query {
places {
id
title
coordinate { latitude longitude }
images { id url badges { label confidence } }
}
}</textarea></p>
<button id="run">Run</button>
<pre id="out"></pre>
<script>
const q = document.getElementById('q');
const out = document.getElementById('out');
async function run() {
out.textContent = 'running...';
const headers = { 'Content-Type': 'application/json' };
const token = document.getElementById('token').value.trim();
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const res = await fetch('/graphql', {
method: 'POST',
headers,
body: JSON.stringify({ query: q.value }),
});
out.textContent = JSON.stringify(await res.json(), null, 2);
} catch (e) {
out.textContent = 'request failed: ' + e;
}
}
document.getElementById('run').addEventListener('click', run);
</script>
</body>
</html>
`

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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 <img> 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
}

View file

@ -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
}

6
charts/api/Chart.yaml Normal file
View file

@ -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"

View file

@ -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 -}}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

92
charts/api/values.yaml Normal file
View file

@ -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

View file

@ -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"

View file

@ -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

Binary file not shown.

View file

@ -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 -}}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

BIN
dur.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

BIN
dusk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

BIN
go-places.db Normal file

Binary file not shown.

376
package-lock.json generated Normal file
View file

@ -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"
}
}
}

18
package.json Normal file
View file

@ -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"
}
}

View file

@ -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

12
web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Go-Places Explorer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

1227
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
web/package.json Normal file
View file

@ -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"
}
}

402
web/src/App.tsx Normal file
View file

@ -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 (
<span className="badge" title={`confidence ${pct}%`}>
{label}
</span>
)
}
/** 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 (
<figure className="picture">
<img
src={resolveMedia(image.url)}
alt={image.caption || image.cameraModel || 'Uploaded picture'}
loading="lazy"
/>
<figcaption>
{image.caption && <p className="picture-caption">{image.caption}</p>}
{badges.length > 0 && (
<div className="badges">
{badges.map((b) => (
<BadgeChip key={`${b.source}:${b.label}`} label={b.display || b.label} confidence={b.confidence} />
))}
</div>
)}
<dl className="picture-meta">
{image.coordinate && (
<>
<dt>Location</dt>
<dd>
{image.coordinate.latitude.toFixed(5)}, {image.coordinate.longitude.toFixed(5)}
</dd>
</>
)}
{image.takenAt && (
<>
<dt>Taken</dt>
<dd>{new Date(image.takenAt).toLocaleString()}</dd>
</>
)}
{image.cameraModel && (
<>
<dt>Camera</dt>
<dd>
{[image.cameraMake, image.cameraModel].filter(Boolean).join(' ')}
</dd>
</>
)}
{image.analysisStatus === 'failed' && (
<>
<dt>Analysis</dt>
<dd className="error-text">{image.analysisError || 'failed'}</dd>
</>
)}
</dl>
{linked.length > 0 && (
<div className="linked">
<h4>Same place</h4>
<ul>
{linked.map((other) => (
<li key={other.id}>
<button type="button" className="link-button" onClick={() => onOpenPlace(other.placeId)}>
{other.caption || 'Untitled picture'}
</button>
</li>
))}
</ul>
</div>
)}
</figcaption>
</figure>
)
}
/** 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<File | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(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 (
<form className="create-form" onSubmit={submit}>
<h2>Add a place</h2>
{error && <p className="error-text">{error}</p>}
<label>
Title
<input value={title} onChange={(e) => setTitle(e.target.value)} required maxLength={200} />
</label>
<label>
Description
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</label>
<div className="row">
<label>
Latitude
<input
type="number"
step="any"
min={-90}
max={90}
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
placeholder="optional"
/>
</label>
<label>
Longitude
<input
type="number"
step="any"
min={-180}
max={180}
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
placeholder="optional"
/>
</label>
</div>
<label>
First picture
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</label>
<button type="submit" disabled={busy || !title.trim()}>
{busy ? 'Saving…' : 'Create place'}
</button>
</form>
)
}
/**
* TokenBar collects the go-loose token the API requires.
*
* There is no login flow here: identity is owned by the SSO service, and this
* app only has to hand its token to the API. The token is kept in localStorage
* so a reload does not ask for it again.
*/
function TokenBar() {
const [token, setTokenValue] = useState(getToken())
const [show, setShow] = useState(false)
if (!show) {
return (
<div className="row token-bar">
<span className="muted">
{token ? `Token loaded${token === 'valid_token' ? ' (dev)' : ''}` : 'No token set'}
</span>
<button type="button" onClick={() => setShow(true)}>
{token ? 'Change token' : 'Set token'}
</button>
</div>
)
}
return (
<form
className="token-bar"
onSubmit={(e) => {
e.preventDefault()
setToken(token.trim())
// Reloading is the simplest way to re-run the initial load with the new
// identity, and it avoids tracking every dependent piece of state.
window.location.reload()
}}
>
<label>
go-loose token
<input
type="password"
autoComplete="off"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="eyJhbGciOiJIUzI1NiIs…"
/>
</label>
<button type="submit">Save</button>
<button type="button" onClick={() => setShow(false)}>
Cancel
</button>
</form>
)
}
/** messageOf renders any thrown value as a sentence for the user. */
function messageOf(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 401) return 'Unauthorized. Set a valid go-loose token.'
return err.message
}
return err instanceof Error ? err.message : String(err)
}
export default function App() {
const [places, setPlaces] = useState<Place[]>([])
const [selected, setSelected] = useState<Place | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
setPlaces(await listPlaces())
} catch (err) {
setError(messageOf(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const open = useCallback(async (id: string) => {
try {
// The list endpoint returns places without their pictures, so a place is
// re-read here to pick up images, comments and related places.
setSelected(await getPlace(id))
} catch (err) {
setError(messageOf(err))
}
}, [])
const created = useCallback(
(place: Place) => {
setShowCreate(false)
setPlaces((prev) => [place, ...prev])
void open(place.id)
},
[open],
)
return (
<div className="app">
<header>
<h1>Go Places</h1>
<p className="subtitle">Pictures, tagged by where they were taken</p>
<button type="button" onClick={() => setShowCreate((v) => !v)}>
{showCreate ? 'Cancel' : 'Add a place'}
</button>
</header>
<TokenBar />
{error && <p className="error-text">{error}</p>}
{showCreate && <CreatePlaceForm onCreated={created} />}
{selected ? (
<section>
<button type="button" className="link-button" onClick={() => setSelected(null)}>
← All places
</button>
<h2>{selected.title}</h2>
{selected.description && <p>{selected.description}</p>}
{selected.coordinate && (
<p className="muted">
{selected.coordinate.latitude.toFixed(5)}, {selected.coordinate.longitude.toFixed(5)} · links
within {selected.linkRadiusMeters} m
</p>
)}
{selected.relatedPlaces.length > 0 && (
<div className="related-places">
<h3>Possible duplicates</h3>
<ul>
{selected.relatedPlaces.map((r) => (
<li key={r.place.id}>
<button type="button" className="link-button" onClick={() => void open(r.place.id)}>
{r.place.title}
</button>{' '}
<span className="muted">{Math.round(r.distanceMeters)} m away</span>
</li>
))}
</ul>
</div>
)}
<h3>Pictures</h3>
{selected.images.length === 0 ? (
<p className="muted">No pictures yet.</p>
) : (
<div className="pictures">
{selected.images.map((img) => (
<PictureCard key={img.id} image={img} onOpenPlace={(id) => void open(id)} />
))}
</div>
)}
{selected.comments.length > 0 && (
<>
<h3>Comments</h3>
<ul className="comments">
{selected.comments.map((c) => (
<li key={c.id}>
<strong>{c.creator.username || c.creator.id}</strong> {c.content}
</li>
))}
</ul>
</>
)}
</section>
) : (
<section>
<h2>Places</h2>
{loading ? (
<p className="muted">Loading…</p>
) : places.length === 0 ? (
<p className="muted">No places yet. Create the first one.</p>
) : (
<ul className="place-list">
{places.map((p) => (
<li key={p.id}>
<button type="button" className="link-button" onClick={() => void open(p.id)}>
{p.title}
</button>
{p.description && <p className="muted">{p.description}</p>}
<p className="muted">
by {p.creator.username || p.creator.id} · {new Date(p.createdAt).toLocaleDateString()}
</p>
</li>
))}
</ul>
)}
</section>
)}
</div>
)
}

174
web/src/api.ts Normal file
View file

@ -0,0 +1,174 @@
/**
* Client for the go-places API.
*
* Two routes matter to the UI: the REST endpoints behind ENABLE_REST, which
* cover listing places, creating a place and listing its pictures, and the
* multipart upload endpoint. Both are behind the auth middleware, so every
* request carries a bearer token.
*/
import type {CreatePlaceInput, Image, Place} from './types'
/** Stored per browser profile; devToken stands in for a real go-loose token. */
const TOKEN_KEY = 'go-places.token'
const DEV_TOKEN = 'valid_token'
/**
* A build-time token, for deployments that bake credentials into the bundle.
* Empty by default, so nothing ships unless it is asked for.
*/
const BUILT_IN_TOKEN = import.meta.env.VITE_API_TOKEN ?? ''
/**
* getToken returns the bearer token to send, or an empty string when none is
* configured.
*
* The dev token is only substituted in a development build. Falling back to it
* in production would send a well-known credential to a real deployment, and
* would hide the fact that no go-loose token had been configured.
*/
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) || BUILT_IN_TOKEN || (import.meta.env.DEV ? DEV_TOKEN : '')
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
/**
* API_BASE is the origin every request is sent to. Empty by default, which keeps
* the paths relative and therefore same-origin: that is what the Vite dev proxy
* and a co-hosted deployment both want. A cross-origin deployment sets
* VITE_API_BASE at build time.
*/
const API_BASE = (import.meta.env.VITE_API_BASE ?? '').replace(/\/$/, '')
/** apiPath prefixes a route with the configured API origin. */
function apiPath(path: string): string {
return `${API_BASE}${path}`
}
/**
* resolveMedia turns the `url` the API returns into something renderable.
*
* The server sends a root-relative path such as /media/<key>, which only
* resolves when the API is same-origin. Media is served unauthenticated, so an
* <img> can load it without a header.
*/
export function resolveMedia(url: string): string {
return url.startsWith('/') ? apiPath(url) : url
}
/** ApiError carries the status so the UI can distinguish auth from other failures. */
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
/** readError pulls the server's message out of a JSON error body, falling back to text. */
async function readError(res: Response): Promise<string> {
const body = await res.text()
if (!body) return `request failed with status ${res.status}`
try {
const parsed = JSON.parse(body)
const message = parsed.error ?? parsed.message
if (typeof message === 'string') return message
} catch {
// Not JSON; fall through to the raw body.
}
return body
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken()
if (!token) {
// Sent without a token the server answers 401 anyway, but this says why.
throw new ApiError(401, 'no API token is configured')
}
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiPath(path), {...init, headers})
if (!res.ok) {
throw new ApiError(res.status, await readError(res))
}
if (res.status === 204) {
return undefined as T
}
return (await res.json()) as T
}
// The Go handlers serialise an empty result as JSON null rather than [], so an
// empty list is normalised here rather than at every call site.
export async function listPlaces(limit = 50, offset = 0): Promise<Place[]> {
return (await request<Place[] | null>(`/api/places?limit=${limit}&offset=${offset}`)) ?? []
}
export async function getPlace(id: string): Promise<Place> {
const place = await request<Place>(`/api/places/${encodeURIComponent(id)}`)
// The nested collections are omitted when empty, so default them before the
// UI renders. Badges and linked images are the two the UI iterates over.
return {
...place,
images: place.images ?? [],
comments: place.comments ?? [],
relatedPlaces: place.relatedPlaces ?? [],
}
}
export async function listPlaceImages(placeId: string, limit = 100): Promise<Image[]> {
return (
(await request<Image[] | null>(`/api/places/${encodeURIComponent(placeId)}/images?limit=${limit}`)) ?? []
)
}
export function createPlace(input: CreatePlaceInput): Promise<Place> {
return request<Place>('/api/places', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(input),
})
}
/**
* uploadImages posts one picture to a place.
*
* The file's own type is sent explicitly rather than letting the browser guess,
* because the service validates the declared type. An empty result array means
* the picture was stored but the analysis produced no badges.
*/
export async function uploadImage(
placeId: string,
file: File,
caption = '',
): Promise<Image> {
const body = new FormData()
body.append('image', file, file.name)
if (caption) body.append('caption', caption)
const headers = new Headers()
const token = getToken()
if (!token) {
throw new ApiError(401, 'no API token is configured')
}
headers.set('Authorization', `Bearer ${token}`)
// Content-Type is deliberately unset so the browser adds the multipart
// boundary; setting it by hand produces an unparsable body.
const res = await fetch(apiPath(`/places/${encodeURIComponent(placeId)}/images`), {
method: 'POST',
headers,
body,
})
if (!res.ok) {
throw new ApiError(res.status, await readError(res))
}
const payload = (await res.json()) as {image: Image}
return payload.image
}

9
web/src/main.tsx Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import {createRoot} from "react-dom/client";
import App from "./App";
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App/>
</React.StrictMode>
)

285
web/src/styles.css Normal file
View file

@ -0,0 +1,285 @@
:root {
color-scheme: light dark;
--bg: #0f1117;
--card: #181b24;
--text: #e7e9ee;
--muted: #949bab;
--accent: #6ea8fe;
--border: #272b36;
--error: #f28b82;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6f8;
--card: #ffffff;
--text: #1c1f26;
--muted: #5c6373;
--accent: #2563eb;
--border: #dfe3ea;
--error: #b3261e;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5;
}
.app {
max-width: 68rem;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
header {
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 2rem;
}
.subtitle,
.muted {
color: var(--muted);
}
.subtitle {
margin: 0.25rem 0 1rem;
}
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
padding: 0.5rem 0.9rem;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--accent);
text-decoration: underline;
text-align: left;
}
.link-button:hover {
text-decoration: none;
}
.error-text {
color: var(--error);
}
/* ---- create form ---- */
.create-form {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
display: grid;
gap: 0.85rem;
margin-bottom: 2rem;
}
.token-bar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.token-bar label {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
color: var(--muted);
}
.token-bar input {
font: inherit;
padding: 0.5rem;
min-width: 20rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.create-form h2 {
margin: 0;
font-size: 1.2rem;
}
.create-form label {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
color: var(--muted);
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
.create-form input,
.create-form textarea {
font: inherit;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.create-form button[type='submit'] {
justify-self: start;
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* ---- lists ---- */
.place-list,
.comments {
list-style: none;
padding: 0;
display: grid;
gap: 1.1rem;
}
.place-list li {
border-bottom: 1px solid var(--border);
padding-bottom: 0.9rem;
}
.place-list p {
margin: 0.2rem 0 0;
font-size: 0.9rem;
}
/* ---- pictures ---- */
.pictures {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 1.25rem;
}
.picture {
margin: 0;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.picture img {
width: 100%;
height: 11rem;
object-fit: cover;
display: block;
background: var(--bg);
}
.picture figcaption {
padding: 0.8rem;
}
.picture-caption {
margin: 0 0 0.5rem;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.badge {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
}
.picture-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.6rem;
margin: 0;
font-size: 0.8rem;
}
.picture-meta dt {
color: var(--muted);
}
.picture-meta dd {
margin: 0;
}
.linked {
margin-top: 0.7rem;
border-top: 1px solid var(--border);
padding-top: 0.5rem;
font-size: 0.85rem;
}
.linked h4 {
margin: 0 0 0.3rem;
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.linked ul,
.related-places ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.25rem;
}
.related-places {
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem 1.1rem;
margin: 1.25rem 0;
}
.related-places h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}

75
web/src/types.ts Normal file
View file

@ -0,0 +1,75 @@
/** Shapes returned by the go-places API, mirroring internal/domain/models.go. */
export type Coordinate = {
latitude: number
longitude: number
}
export type Badge = {
label: string
display: string
confidence: number
source: string
}
export type User = {
id: string
username: string
email: string
}
export type Image = {
id: string
placeId: string
url: string
caption: string
contentType: string
byteSize: number
width: number
height: number
coordinate: Coordinate | null
takenAt: string | null
cameraMake: string
cameraModel: string
badges: Badge[]
analysisStatus: 'pending' | 'completed' | 'failed'
analysisError: string
linkedImages: Image[]
createdAt: string
}
export type Comment = {
id: string
placeId: string
content: string
creator: User
createdAt: string
}
export type Place = {
id: string
title: string
description: string
coordinate: Coordinate | null
linkRadiusMeters: number
creator: User
images: Image[]
comments: Comment[]
relatedPlaces: RelatedPlace[]
createdAt: string
}
export type RelatedPlace = {
place: Place
distanceMeters: number
sharedImageIds: string[]
}
export type CreatePlaceInput = {
title: string
description: string
latitude?: number
longitude?: number
linkRadiusMeters?: number
caption?: string
}

24
web/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}

35
web/vite.config.ts Normal file
View file

@ -0,0 +1,35 @@
import {defineConfig, loadEnv} from 'vite'
import react from '@vitejs/plugin-react'
// The dev server proxies the API routes so the browser sees a single origin and
// no CORS configuration is needed during development. /graphql, /media and
// /healthz are listed alongside /api because the service mounts them at the root
// rather than under /api.
export default defineConfig(({mode}) => {
// loadEnv is needed here rather than import.meta.env, because vite.config.ts is
// evaluated before the .env files are exposed to client code. '.' is the env
// dir, which defaults to the project root.
const env = loadEnv(mode, '.', 'VITE_')
const target = env.VITE_PROXY_TARGET || 'http://localhost:8080'
const proxy = {
target,
changeOrigin: true,
secure: false,
}
return {
plugins: [react()],
base: './',
server: {
proxy: {
'/api': proxy,
// GET /graphql only serves the static query console; it cannot execute a
// query, so proxying it is safe.
'/graphql': proxy,
'/media': proxy,
'/healthz': proxy,
},
},
}
})