initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
11
.env.example
Normal file
11
.env.example
Normal 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
9
.gitignore
vendored
Normal 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
162
DEVELOPER.md
Normal 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
48
Dockerfile
Normal 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
102
INFRASTRUCTURE.md
Normal 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
110
Makefile
Normal 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
211
api/cmd/main.go
Normal 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
23
api/go.mod
Normal 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
53
api/go.sum
Normal 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=
|
||||
122
api/internal/analysis/analysis.go
Normal file
122
api/internal/analysis/analysis.go
Normal 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)
|
||||
}
|
||||
410
api/internal/analysis/analysis_test.go
Normal file
410
api/internal/analysis/analysis_test.go
Normal 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() }
|
||||
380
api/internal/analysis/badges.go
Normal file
380
api/internal/analysis/badges.go
Normal 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) }
|
||||
99
api/internal/analysis/composite.go
Normal file
99
api/internal/analysis/composite.go
Normal 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
|
||||
}
|
||||
384
api/internal/analysis/exif.go
Normal file
384
api/internal/analysis/exif.go
Normal 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 }
|
||||
423
api/internal/analysis/exif_test.go
Normal file
423
api/internal/analysis/exif_test.go
Normal 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...)
|
||||
}
|
||||
427
api/internal/analysis/local.go
Normal file
427
api/internal/analysis/local.go
Normal 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
|
||||
}
|
||||
139
api/internal/analysis/remote.go
Normal file
139
api/internal/analysis/remote.go
Normal 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
200
api/internal/auth/auth.go
Normal 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 ""
|
||||
}
|
||||
360
api/internal/auth/auth_test.go
Normal file
360
api/internal/auth/auth_test.go
Normal 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
|
||||
}
|
||||
63
api/internal/auth/middleware.go
Normal file
63
api/internal/auth/middleware.go
Normal 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)
|
||||
}
|
||||
29
api/internal/blobstore/blobstore.go
Normal file
29
api/internal/blobstore/blobstore.go
Normal 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
|
||||
}
|
||||
132
api/internal/blobstore/localfs.go
Normal file
132
api/internal/blobstore/localfs.go
Normal 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
|
||||
}
|
||||
260
api/internal/config/config.go
Normal file
260
api/internal/config/config.go
Normal 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
|
||||
}
|
||||
166
api/internal/domain/models.go
Normal file
166
api/internal/domain/models.go
Normal 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
|
||||
}
|
||||
192
api/internal/events/events.go
Normal file
192
api/internal/events/events.go
Normal 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
92
api/internal/geo/geo.go
Normal 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))
|
||||
}
|
||||
219
api/internal/geo/geo_test.go
Normal file
219
api/internal/geo/geo_test.go
Normal 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}
|
||||
}
|
||||
29
api/internal/service/helpers.go
Normal file
29
api/internal/service/helpers.go
Normal 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
|
||||
})
|
||||
}
|
||||
357
api/internal/service/image.go
Normal file
357
api/internal/service/image.go
Normal 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())
|
||||
}
|
||||
286
api/internal/service/link.go
Normal file
286
api/internal/service/link.go
Normal 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
|
||||
}
|
||||
392
api/internal/service/place.go
Normal file
392
api/internal/service/place.go
Normal 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")
|
||||
113
api/internal/service/service.go
Normal file
113
api/internal/service/service.go
Normal 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) {}
|
||||
836
api/internal/service/service_test.go
Normal file
836
api/internal/service/service_test.go
Normal 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
795
api/internal/store/sql.go
Normal 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
116
api/internal/store/store.go
Normal 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
|
||||
}
|
||||
667
api/internal/store/store_test.go
Normal file
667
api/internal/store/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
66
api/internal/transport/playground.go
Normal file
66
api/internal/transport/playground.go
Normal 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>
|
||||
`
|
||||
388
api/internal/transport/rest_test.go
Normal file
388
api/internal/transport/rest_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
513
api/internal/transport/schema.go
Normal file
513
api/internal/transport/schema.go
Normal 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
|
||||
}
|
||||
604
api/internal/transport/server.go
Normal file
604
api/internal/transport/server.go
Normal 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
|
||||
}
|
||||
386
api/internal/transport/transport_test.go
Normal file
386
api/internal/transport/transport_test.go
Normal 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
6
charts/api/Chart.yaml
Normal 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"
|
||||
41
charts/api/templates/_helpers.tpl
Normal file
41
charts/api/templates/_helpers.tpl
Normal 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 -}}
|
||||
99
charts/api/templates/deployment.yaml
Normal file
99
charts/api/templates/deployment.yaml
Normal 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 }}
|
||||
20
charts/api/templates/pvc.yaml
Normal file
20
charts/api/templates/pvc.yaml
Normal 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 }}
|
||||
15
charts/api/templates/service.yaml
Normal file
15
charts/api/templates/service.yaml
Normal 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
92
charts/api/values.yaml
Normal 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
|
||||
6
charts/go-loose/Chart.lock
Normal file
6
charts/go-loose/Chart.lock
Normal 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"
|
||||
13
charts/go-loose/Chart.yaml
Normal file
13
charts/go-loose/Chart.yaml
Normal 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
|
||||
BIN
charts/go-loose/charts/postgresql-16.7.27.tgz
Normal file
BIN
charts/go-loose/charts/postgresql-16.7.27.tgz
Normal file
Binary file not shown.
41
charts/go-loose/templates/_helpers.tpl
Normal file
41
charts/go-loose/templates/_helpers.tpl
Normal 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 -}}
|
||||
47
charts/go-loose/templates/deployment.yaml
Normal file
47
charts/go-loose/templates/deployment.yaml
Normal 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 }}
|
||||
34
charts/go-loose/templates/ingress.yaml
Normal file
34
charts/go-loose/templates/ingress.yaml
Normal 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 }}
|
||||
15
charts/go-loose/templates/service.yaml
Normal file
15
charts/go-loose/templates/service.yaml
Normal 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 }}
|
||||
73
charts/go-loose/values-dev.yaml
Normal file
73
charts/go-loose/values-dev.yaml
Normal 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
|
||||
73
charts/go-loose/values-lcl.yaml
Normal file
73
charts/go-loose/values-lcl.yaml
Normal 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
|
||||
73
charts/go-loose/values-prd.yaml
Normal file
73
charts/go-loose/values-prd.yaml
Normal 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
|
||||
73
charts/go-loose/values-tst.yaml
Normal file
73
charts/go-loose/values-tst.yaml
Normal 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
|
||||
53
charts/go-loose/values.yaml
Normal file
53
charts/go-loose/values.yaml
Normal 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
BIN
dur.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 B |
BIN
dusk.png
Normal file
BIN
dusk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 302 B |
BIN
go-places.db
Normal file
BIN
go-places.db
Normal file
Binary file not shown.
376
package-lock.json
generated
Normal file
376
package-lock.json
generated
Normal 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
18
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
160
shared-interfaces/schema/models.graphql
Normal file
160
shared-interfaces/schema/models.graphql
Normal 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
12
web/index.html
Normal 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
1227
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
web/package.json
Normal file
27
web/package.json
Normal 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
402
web/src/App.tsx
Normal 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
174
web/src/api.ts
Normal 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
9
web/src/main.tsx
Normal 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
285
web/src/styles.css
Normal 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
75
web/src/types.ts
Normal 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
24
web/tsconfig.json
Normal 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
35
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue