initial commit

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

View file

@ -0,0 +1,122 @@
// Package analysis inspects an uploaded picture and derives badges describing
// what can be recognised in it.
//
// Two implementations are provided. LocalAnalyzer measures the picture's own
// pixels and EXIF metadata and turns those measurements into scene and content
// badges; it needs no external service and is fully deterministic. RemoteAnalyzer
// delegates to an external inference service (a trained object-detection model)
// and returns the labels that model predicts. The two can be combined, in which
// case model labels take precedence over heuristics of the same name.
package analysis
import (
"context"
"time"
"go-places-api/internal/domain"
)
// Input is the picture to analyse.
type Input struct {
// ContentType is the MIME type as supplied by the client.
ContentType string
// Bytes is the complete encoded picture.
Bytes []byte
// MaxDimension bounds the longest edge used for pixel measurements. The
// picture is downscaled before sampling so that cost does not grow with
// megapixels. Zero means use the package default.
MaxDimension int
// Hints carries scene labels already known about the picture, which lets a
// remote analyzer disambiguate using earlier results.
Hints []string
}
// Metadata is the capture information recovered from EXIF, if any.
type Metadata struct {
// Coordinate is the GPS position from the EXIF GPS IFD.
Coordinate *domain.Coordinate
// TakenAt is the original capture timestamp.
TakenAt *time.Time
// Make and Model identify the camera or phone.
Make string
Model string
}
// Signals are the measurements taken from the pixels. Every field is
// normalised to 0..1 so the badge rules can use a single comparable scale.
type Signals struct {
// Width and Height are the dimensions of the sampled image.
Width int
Height int
// Brightness is the mean perceptual luminance.
Brightness float64
// Saturation is the mean colour saturation.
Saturation float64
// Colorfulness is the spread of hue across the image, a stand-in for how
// varied the scene is.
Colorfulness float64
// EdgeDensity is the fraction of pixels sitting on a strong gradient, which
// indicates how much fine detail the picture holds.
EdgeDensity float64
// HueWeights holds the fraction of pixels falling into each colour class:
// blue, green, warm (red/orange/yellow), purple and achromatic.
HueWeights HueWeights
// Top, Middle and Bottom hold the same measurements restricted to the
// upper, central and lower horizontal bands of the picture. Sky, ground
// and water all separate cleanly this way.
Top Band
Middle Band
Bottom Band
}
// Band is the measurement set for one horizontal third of a picture.
type Band struct {
Brightness float64
Saturation float64
EdgeDensity float64
HueWeights HueWeights
PixelShare float64
PixelCount int
}
// HueWeights is the distribution of pixels across colour classes. The values
// sum to approximately 1 for a fully populated band.
type HueWeights struct {
Blue float64
Green float64
Warm float64
// Purple covers magenta and violet, which read as neither blue nor warm.
Purple float64
// Achromatic is the share of pixels with little or no hue: greys, whites,
// blacks and near-neutrals.
Achromatic float64
}
// Result is everything the pipeline learned about a picture.
type Result struct {
// Width and Height are the true dimensions of the encoded picture.
Width int
Height int
// Signals are the pixel measurements, retained for debugging and for the
// remote analyzer's fallback.
Signals Signals
// Metadata is what EXIF reported.
Metadata Metadata
// Badges are the conclusions drawn from the above.
Badges []domain.Badge
// Note carries a non-fatal warning, such as a degraded analyzer, so the
// caller can record why the badge set may be incomplete.
Note string
}
// Analyzer inspects a picture.
type Analyzer interface {
// Name identifies the analyzer in badge sources.
Name() string
// Analyze inspects the picture. It returns an error only when the picture
// cannot be inspected at all; a picture that yields no badges is a success.
Analyze(ctx context.Context, in Input) (*Result, error)
}

View file

@ -0,0 +1,410 @@
package analysis
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"testing"
"time"
"go-places-api/internal/domain"
)
// pngOf encodes an RGBA image as PNG bytes.
func pngOf(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
// solid returns a uniformly coloured picture.
func solid(w, h int, c color.RGBA) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetRGBA(x, y, c)
}
}
return img
}
// banded returns a picture whose horizontal thirds are the given colours, which
// is how the tests construct skies, ground and horizons.
func banded(top, middle, bottom color.RGBA) *image.RGBA {
const w, h = 60, 60
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
c := top
switch {
case y >= h*2/3:
c = bottom
case y >= h/3:
c = middle
}
for x := 0; x < w; x++ {
img.SetRGBA(x, y, c)
}
}
return img
}
func analyzePNG(t *testing.T, img image.Image) *Result {
t.Helper()
a := NewLocal(DefaultMaxDimension)
res, err := a.Analyze(context.Background(), Input{
ContentType: "image/png",
Bytes: pngOf(t, img),
})
if err != nil {
t.Fatalf("Analyze: %v", err)
}
return res
}
// labels collects the badge labels from a result.
func labels(res *Result) []string {
out := make([]string, 0, len(res.Badges))
for _, b := range res.Badges {
out = append(out, b.Label)
}
return out
}
func hasBadge(res *Result, label string) bool {
for _, b := range res.Badges {
if b.Label == label {
return true
}
}
return false
}
func confidenceOf(res *Result, label string) (float64, bool) {
for _, b := range res.Badges {
if b.Label == label {
return b.Confidence, true
}
}
return 0, false
}
var (
skyBlue = color.RGBA{R: 60, G: 130, B: 220, A: 255}
grassGreen = color.RGBA{R: 60, G: 160, B: 70, A: 255}
sandColor = color.RGBA{R: 220, G: 195, B: 140, A: 255}
sunsetRed = color.RGBA{R: 230, G: 110, B: 60, A: 255}
midGrey = color.RGBA{R: 128, G: 128, B: 128, A: 255}
darkGrey = color.RGBA{R: 30, G: 30, B: 30, A: 255}
white = color.RGBA{R: 250, G: 250, B: 250, A: 255}
)
func TestAnalyzeReportsTrueDimensions(t *testing.T) {
t.Parallel()
// A small picture is sampled at its own size; a large one is downscaled,
// but the reported dimensions must remain the encoded ones.
for _, dims := range [][2]int{{40, 30}, {800, 600}} {
w, h := dims[0], dims[1]
res := analyzePNG(t, solid(w, h, skyBlue))
if res.Width != w || res.Height != h {
t.Errorf("dimensions = %dx%d, want %dx%d", res.Width, res.Height, w, h)
}
}
}
func TestAnalyzeDetectsSky(t *testing.T) {
t.Parallel()
// Blue upper band over green ground: the classic outdoor landscape.
res := analyzePNG(t, banded(skyBlue, grassGreen, grassGreen))
if !hasBadge(res, labelSky) {
t.Errorf("expected a sky badge, got %v", labels(res))
}
if !hasBadge(res, labelVegetation) {
t.Errorf("expected a vegetation badge, got %v", labels(res))
}
conf, ok := confidenceOf(res, labelSky)
if !ok {
t.Fatal("sky badge has no confidence")
}
if conf <= 0 || conf > 1 {
t.Errorf("sky confidence = %v, want a value in (0,1]", conf)
}
}
func TestAnalyzeDetectsBeach(t *testing.T) {
t.Parallel()
// Sky above warm sand below.
res := analyzePNG(t, banded(skyBlue, sandColor, sandColor))
if !hasBadge(res, labelBeach) {
t.Errorf("expected a beach badge, got %v", labels(res))
}
}
func TestAnalyzeDetectsWater(t *testing.T) {
t.Parallel()
// Blue concentrated in the lower band is water rather than sky.
res := analyzePNG(t, banded(midGrey, skyBlue, skyBlue))
if !hasBadge(res, labelWater) {
t.Errorf("expected a water badge, got %v", labels(res))
}
if hasBadge(res, labelSky) {
t.Errorf("did not expect a sky badge when the blue is in the lower band, got %v",
labels(res))
}
}
func TestAnalyzeDetectsMonochrome(t *testing.T) {
t.Parallel()
res := analyzePNG(t, solid(50, 50, midGrey))
if !hasBadge(res, labelMonochrome) {
t.Errorf("expected a monochrome badge, got %v", labels(res))
}
if hasBadge(res, labelVivid) {
t.Errorf("did not expect a vivid badge from a grey picture, got %v", labels(res))
}
}
func TestAnalyzeDetectsLowLightAndNight(t *testing.T) {
t.Parallel()
dark := analyzePNG(t, solid(50, 50, darkGrey))
if !hasBadge(dark, labelLowLight) {
t.Errorf("expected a low-light badge, got %v", labels(dark))
}
if !hasBadge(dark, labelNight) {
t.Errorf("expected a night badge from brightness alone, got %v", labels(dark))
}
}
func TestAnalyzeDetectsOverexposed(t *testing.T) {
t.Parallel()
bright := analyzePNG(t, solid(50, 50, white))
if !hasBadge(bright, labelOverexposed) {
t.Errorf("expected an overexposed badge, got %v", labels(bright))
}
}
func TestAnalyzeDetectsOrientation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
w, h int
want, notWant string
}{
// Ratios sit comfortably inside each band; a picture whose ratio lands
// exactly on a band edge scores zero confidence, which is by design.
{"tall", 45, 60, labelPortrait, labelLandscape},
{"wide", 60, 45, labelLandscape, labelPortrait},
{"square", 40, 40, "", labelPanorama},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
res := analyzePNG(t, solid(tc.w, tc.h, midGrey))
if tc.want != "" && !hasBadge(res, tc.want) {
t.Errorf("expected %q badge, got %v", tc.want, labels(res))
}
if tc.notWant != "" && hasBadge(res, tc.notWant) {
t.Errorf("did not expect %q badge, got %v", tc.notWant, labels(res))
}
})
}
}
func TestAnalyzeDetectsPanorama(t *testing.T) {
t.Parallel()
res := analyzePNG(t, solid(120, 40, midGrey))
if !hasBadge(res, labelPanorama) {
t.Errorf("expected a panorama badge for a 3:1 picture, got %v", labels(res))
}
}
func TestAnalyzeIsDeterministic(t *testing.T) {
t.Parallel()
img := banded(skyBlue, grassGreen, sandColor)
first := labels(analyzePNG(t, img))
for i := 0; i < 3; i++ {
got := labels(analyzePNG(t, img))
if len(got) != len(first) {
t.Fatalf("run %d produced %v, want %v", i, got, first)
}
for j := range got {
if got[j] != first[j] {
t.Errorf("run %d produced %v, want %v", i, got, first)
break
}
}
}
}
func TestAnalyzeRejectsBadInput(t *testing.T) {
t.Parallel()
a := NewLocal(DefaultMaxDimension)
if _, err := a.Analyze(context.Background(), Input{Bytes: nil}); err == nil {
t.Error("expected an error for an empty payload")
}
if _, err := a.Analyze(context.Background(), Input{Bytes: []byte("not an image")}); err == nil {
t.Error("expected an error for a payload that is not an image")
}
}
func TestAnalyzeRespectsContextCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := NewLocal(DefaultMaxDimension)
_, err := a.Analyze(ctx, Input{ContentType: "image/png", Bytes: pngOf(t, solid(20, 20, midGrey))})
if err == nil {
t.Error("expected a cancelled context to fail the analysis")
}
}
// TestCombinePrefersModelLabels checks the merge rule: a model label wins the
// display name, but a confident heuristic is not thrown away.
func TestCombinePrefersModelLabels(t *testing.T) {
t.Parallel()
local := []domain.Badge{
{Label: labelSky, Display: "Sky", Confidence: 0.9, Source: SourceLocal},
{Label: "beach", Display: "Beach", Confidence: 0.8, Source: SourceLocal},
}
model := []domain.Badge{
{Label: "dog", Display: "Dog", Confidence: 0.95, Source: SourceRemote},
// The model also claims sky, less confidently than the heuristic.
{Label: labelSky, Display: "Clear sky", Confidence: 0.4, Source: SourceRemote},
}
got := Combine(local, model)
byLabel := map[string]domain.Badge{}
for _, b := range got {
byLabel[b.Label] = b
}
if len(got) != 3 {
t.Fatalf("expected 3 distinct badges, got %d: %v", len(got), got)
}
if _, ok := byLabel["dog"]; !ok {
t.Error("the model label was dropped")
}
sky := byLabel[labelSky]
if sky.Source != SourceRemote {
t.Errorf("sky source = %q, want %q", sky.Source, SourceRemote)
}
if sky.Display != "Clear sky" {
t.Errorf("sky display = %q, want the model's %q", sky.Display, "Clear sky")
}
if sky.Confidence != 0.9 {
t.Errorf("sky confidence = %v, want the heuristic's higher 0.9", sky.Confidence)
}
}
func TestCombineIsSortedByConfidence(t *testing.T) {
t.Parallel()
badges := Combine(
[]domain.Badge{{Label: "a", Confidence: 0.2}, {Label: "b", Confidence: 0.9}},
[]domain.Badge{{Label: "c", Confidence: 0.5, Source: SourceRemote}},
)
for i := 1; i < len(badges); i++ {
if badges[i-1].Confidence < badges[i].Confidence {
t.Errorf("badges are not sorted by confidence: %v", badges)
break
}
}
}
func TestCombineFillsMissingDisplayName(t *testing.T) {
t.Parallel()
got := Combine(nil, []domain.Badge{{Label: "low-light", Confidence: 0.7, Source: SourceRemote}})
if len(got) != 1 {
t.Fatalf("expected 1 badge, got %d", len(got))
}
if got[0].Display != "Low light" {
t.Errorf("display = %q, want %q", got[0].Display, "Low light")
}
}
func TestHumanize(t *testing.T) {
t.Parallel()
cases := map[string]string{
"dog": "Dog",
"low-light": "Low light",
"close_up": "Close up",
"already Done": "Already done",
"": "",
"sandwich-board": "Sandwich board",
}
for in, want := range cases {
if got := Humanize(in); got != want {
t.Errorf("Humanize(%q) = %q, want %q", in, got, want)
}
}
}
// TestRuleNightPrefersClockTime confirms the EXIF timestamp takes precedence
// over brightness, so a dark frame shot at noon is not called a night scene.
func TestRuleNightPrefersClockTime(t *testing.T) {
t.Parallel()
dark := Signals{Brightness: 0.01, Width: 100, Height: 100}
noon := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
if got := ruleNight(dark, Metadata{TakenAt: &noon}); got != 0 {
t.Errorf("a dark midday frame scored %v for night, want 0", got)
}
midnight := time.Date(2026, 6, 1, 23, 30, 0, 0, time.UTC)
if got := ruleNight(dark, Metadata{TakenAt: &midnight}); got <= 0 {
t.Errorf("a bright frame shot at 23:30 scored %v for night, want > 0", got)
}
}
// TestAllBadgesCarrySourceAndBounds checks the invariants every badge must
// satisfy regardless of which rule produced it.
func TestAllBadgesCarrySourceAndBounds(t *testing.T) {
t.Parallel()
res := analyzePNG(t, banded(skyBlue, grassGreen, sandColor))
if len(res.Badges) == 0 {
t.Fatal("expected the sample picture to produce badges")
}
for _, b := range res.Badges {
if b.Label == "" {
t.Error("a badge has an empty label")
}
if b.Source != SourceLocal {
t.Errorf("badge %q has source %q, want %q", b.Label, b.Source, SourceLocal)
}
if b.Confidence <= 0 || b.Confidence > 1 {
t.Errorf("badge %q confidence = %v, want a value in (0,1]", b.Label, b.Confidence)
}
if b.Display == "" {
t.Errorf("badge %q has no display name", b.Label)
}
}
}
// newTestContext returns a background context, kept as a helper so tests that
// build payloads read consistently.
func newTestContext() context.Context { return context.Background() }

View file

@ -0,0 +1,380 @@
package analysis
import (
"math"
"sort"
"strings"
"go-places-api/internal/domain"
)
// SourceLocal identifies badges derived from the picture's own pixels and
// metadata rather than from a trained model.
const SourceLocal = "local"
// badge is a candidate rule. A rule contributes a badge when its condition
// holds, with a confidence derived from how far past the threshold the signal
// sits.
type badge struct {
Label string
Display string
// Condition returns a confidence in 0..1, or a negative value to reject.
Condition func(s Signals, md Metadata) float64
}
// DeriveBadges turns measurements into badges.
//
// These are honest heuristics over colour, brightness, detail and EXIF. They
// recognise scene and subject *categories* such as sky, vegetation or a sunset,
// not arbitrary objects; object-level recognition belongs behind the remote
// analyzer, whose labels are merged in by Combine.
func DeriveBadges(res *Result) []domain.Badge {
if res == nil {
return nil
}
rules := []badge{
{labelSky, "Sky", ruleSky},
{labelVegetation, "Vegetation", ruleVegetation},
{labelNature, "Nature", ruleNature},
{labelWater, "Water", ruleWater},
{labelBeach, "Beach", ruleBeach},
{labelSunset, "Sunset", ruleSunset},
{labelSnow, "Snow", ruleSnow},
{labelIndoor, "Indoor", ruleIndoor},
{labelPortrait, "Portrait", rulePortrait},
{labelLandscape, "Landscape", ruleLandscape},
{labelPanorama, "Panorama", rulePanorama},
{labelLowLight, "Low light", ruleLowLight},
{labelNight, "Night", ruleNight},
{labelOverexposed, "Overexposed", ruleOverexposed},
{labelMonochrome, "Monochrome", ruleMonochrome},
{labelVivid, "Vivid", ruleVivid},
{labelCloseUp, "Close-up", ruleCloseUp},
{labelGraphic, "Graphic", ruleGraphic},
{labelScreenshot, "Screenshot or document", ruleScreenshot},
}
var out []domain.Badge
for _, r := range rules {
conf := r.Condition(res.Signals, res.Metadata)
if conf <= 0 {
continue
}
out = append(out, domain.Badge{
Label: r.Label,
Display: r.Display,
Confidence: round2(clamp01(conf)),
Source: SourceLocal,
})
}
return out
}
const (
labelSky = "sky"
labelVegetation = "vegetation"
labelNature = "nature"
labelWater = "water"
labelBeach = "beach"
labelSunset = "sunset"
labelSnow = "snow"
labelIndoor = "indoor"
labelPortrait = "portrait"
labelLandscape = "landscape"
labelPanorama = "panorama"
labelLowLight = "low-light"
labelNight = "night"
labelOverexposed = "overexposed"
labelMonochrome = "monochrome"
labelVivid = "vivid"
labelCloseUp = "close-up"
labelGraphic = "graphic"
labelScreenshot = "screenshot"
)
// Thresholds are collected here so they can be read, and tuned, in one place.
const (
skyBlueShare = 0.40
greenShare = 0.25
waterBlueShare = 0.22
waterBottomSmooth = 0.06
beachSandShare = 0.28
sunsetWarmShare = 0.18
snowBrightness = 0.82
snowLowSaturation = 0.12
lowLightBrightness = 0.20
nightBrightness = 0.16
overexposed = 0.88
monochromeSat = 0.10
vividSat = 0.42
closeUpEdges = 0.30
graphicEdges = 0.38
graphicSaturation = 0.28
screenshotEdges = 0.22
screenshotBright = 0.62
)
// scale maps a signal against a threshold into a confidence that grows with
// the margin, saturating at full confidence once the signal is twice the
// threshold. A signal at exactly the threshold yields 0 and is therefore not
// emitted, which keeps thresholds meaning "at least this much".
func scale(value, threshold float64) float64 {
if threshold <= 0 {
return 0
}
ratio := value / threshold
if ratio <= 1 {
return 0
}
// Ease out so confidence approaches 1 asymptotically.
t := (ratio - 1) / (ratio + 1)
return 0.5 + 0.5*t*2
}
// scaleBelow is the inverse of scale, for signals where a *low* value is the
// positive signal, such as saturation for a monochrome picture. It returns zero
// at or above the threshold and rises as the value falls towards zero.
//
// Thresholding an inverted signal by subtracting it and feeding the result to
// scale would compare a margin against a threshold and silently emit nothing,
// so the two directions are deliberately given separate helpers.
func scaleBelow(value, threshold float64) float64 {
if threshold <= 0 || value >= threshold {
return 0
}
// ratio runs 0 at the threshold to 1 at zero.
ratio := (threshold - value) / threshold
if ratio <= 0 {
return 0
}
return 0.4 + 0.5*ratio
}
func ruleSky(s Signals, _ Metadata) float64 {
// Sky sits in the upper band and is blue with little detail.
blue := scale(s.Top.HueWeights.Blue, skyBlueShare)
smooth := 1 - scale(s.Top.EdgeDensity, 0.10)
return 0.75*blue + 0.25*smooth*blue
}
func ruleVegetation(s Signals, _ Metadata) float64 {
// Vegetation is green and can appear in any band, weighted towards the
// lower half where ground and trees are.
overall := scale(s.HueWeights.Green, greenShare)
lower := scale(s.Bottom.HueWeights.Green, greenShare)
return 0.5*overall + 0.5*lower
}
func ruleNature(s Signals, _ Metadata) float64 {
green := s.HueWeights.Green
blue := s.HueWeights.Blue
// Nature means the frame is dominated by vegetation or sky and is not a
// document-like composition.
if s.EdgeDensity > screenshotEdges {
return 0
}
return 0.6*scale(green, greenShare) + 0.4*scale(blue, skyBlueShare)
}
func ruleWater(s Signals, _ Metadata) float64 {
// Water is blue in the lower band and has little structure, unlike a sky
// which occupies the upper band.
if s.Top.HueWeights.Blue > s.Bottom.HueWeights.Blue {
return 0
}
blue := scale(s.Bottom.HueWeights.Blue, waterBlueShare)
smooth := 1 - scale(s.Bottom.EdgeDensity, waterBottomSmooth)
return 0.8*blue + 0.2*smooth*blue
}
func ruleBeach(s Signals, _ Metadata) float64 {
// A beach is sky above and warm sand below.
sky := scale(s.Top.HueWeights.Blue, skyBlueShare)
sand := scale(s.Bottom.HueWeights.Warm, beachSandShare)
return 0.5 * sky * sand
}
func ruleSunset(s Signals, _ Metadata) float64 {
warm := scale(s.Middle.HueWeights.Warm, sunsetWarmShare)
// Sunset keeps some light: a pitch-dark frame is night, not sunset.
bright := 1 - scale(1-s.Brightness, 0.25)
return warm * bright
}
func ruleSnow(s Signals, _ Metadata) float64 {
// Snow is very bright and almost colourless.
bright := scale(s.Brightness, snowBrightness)
flat := 1 - scale(s.Saturation, snowLowSaturation)
return 0.7*bright + 0.3*flat*bright
}
func ruleIndoor(s Signals, _ Metadata) float64 {
// Indoor shots are shot close with plenty of detail and no sky.
if s.Top.HueWeights.Blue > skyBlueShare/2 {
return 0
}
detail := scale(s.EdgeDensity, closeUpEdges)
return detail
}
func rulePortrait(s Signals, _ Metadata) float64 {
return orientationConfidence(s, 0.5, 0.95)
}
func ruleLandscape(s Signals, _ Metadata) float64 {
return orientationConfidence(s, 1.06, 2.2)
}
func rulePanorama(s Signals, _ Metadata) float64 {
return orientationConfidence(s, 2.5, 6.0)
}
// orientationConfidence scores how strongly the aspect ratio sits inside the
// band [min,max]. It returns zero outside the band, one at its centre, and
// falls off towards the edges.
func orientationConfidence(s Signals, min, max float64) float64 {
if s.Width <= 0 || s.Height <= 0 {
return 0
}
ratio := float64(s.Width) / float64(s.Height)
if ratio < min || ratio > max {
return 0
}
// The comparison happens in log space so a wide band and a tall band are
// treated symmetrically.
halfSpan := math.Log(max/min) / 2
if halfSpan <= 0 {
return 0
}
drift := abs(math.Log(ratio/math.Sqrt(min*max))) / halfSpan
return 1 - clamp01(drift)
}
// ruleNight prefers the clock time from EXIF and falls back to brightness alone
// when the picture carries no timestamp.
func ruleNight(s Signals, md Metadata) float64 {
if md.TakenAt != nil {
if h := md.TakenAt.UTC().Hour(); h >= 21 || h < 5 {
return 0.9
}
// Daytime capture means the frame is not a night scene even if it is
// dark, which usually means a window or an interior.
return 0
}
return scaleBelow(s.Brightness, nightBrightness)
}
func ruleLowLight(s Signals, _ Metadata) float64 {
return scaleBelow(s.Brightness, lowLightBrightness)
}
func ruleOverexposed(s Signals, _ Metadata) float64 {
return scale(s.Brightness, overexposed)
}
func ruleMonochrome(s Signals, _ Metadata) float64 {
return scaleBelow(s.Saturation, monochromeSat)
}
func ruleVivid(s Signals, _ Metadata) float64 {
return scale(s.Saturation, vividSat)
}
func ruleCloseUp(s Signals, _ Metadata) float64 {
return scale(s.EdgeDensity, closeUpEdges)
}
func ruleGraphic(s Signals, _ Metadata) float64 {
// Graphics and illustrations have heavy outlines but muted colour.
edges := scale(s.EdgeDensity, graphicEdges)
muted := 1 - scale(s.Saturation, graphicSaturation)
return 0.6*edges + 0.4*muted*edges
}
func ruleScreenshot(s Signals, _ Metadata) float64 {
// Screenshots and scans are flat, bright and edge-dense because text
// produces many small high-contrast strokes.
if s.Brightness < screenshotBright {
return 0
}
edges := scale(s.EdgeDensity, screenshotEdges)
return edges
}
// Combine merges heuristic badges with model badges. Where both sources report
// the same label the model wins, because a trained detector is more reliable
// than a colour heuristic, but the heuristic confidence is retained as a floor
// so a confident measurement is not discarded.
func Combine(local, model []domain.Badge) []domain.Badge {
byLabel := map[string]domain.Badge{}
order := []string{}
add := func(b domain.Badge) {
if b.Label == "" {
return
}
if existing, ok := byLabel[b.Label]; ok {
existing.Confidence = maxf(existing.Confidence, b.Confidence)
// A model label is what actually identified the object, so it wins
// the display name and keeps its own source.
if b.Source != SourceLocal {
existing.Display = b.Display
existing.Source = b.Source
}
byLabel[b.Label] = existing
return
}
byLabel[b.Label] = b
order = append(order, b.Label)
}
for _, b := range model {
add(b)
}
for _, b := range local {
add(b)
}
out := make([]domain.Badge, 0, len(order))
for _, label := range order {
b := byLabel[label]
if b.Display == "" {
b.Display = Humanize(label)
}
b.Confidence = round2(clamp01(b.Confidence))
out = append(out, b)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Confidence != out[j].Confidence {
return out[i].Confidence > out[j].Confidence
}
return out[i].Label < out[j].Label
})
return out
}
// Humanize turns a kebab-case label such as "low-light" into "Low light".
func Humanize(label string) string {
parts := strings.FieldsFunc(label, func(r rune) bool {
return r == '-' || r == '_' || r == ' '
})
if len(parts) == 0 {
return label
}
for i, p := range parts {
p = strings.ToLower(p)
// Only the first word is capitalised, so "low-light" reads "Low light"
// rather than "Low Light".
if i == 0 && p != "" {
p = strings.ToUpper(p[:1]) + p[1:]
}
parts[i] = p
}
return strings.Join(parts, " ")
}
func round2(v float64) float64 { return math.Round(v*100) / 100 }
func abs(v float64) float64 { return math.Abs(v) }
func maxf(a, b float64) float64 { return math.Max(a, b) }

View file

@ -0,0 +1,99 @@
package analysis
import (
"context"
"fmt"
"go-places-api/internal/domain"
)
// Composite runs the local analyzer first and then, when a remote service is
// configured, feeds the locally derived scene labels to it as hints. Running
// the local pass first is what makes the hints available, and it also means the
// picture still gets badges if the remote service is unreachable.
type Composite struct {
local Analyzer
remote Analyzer
}
var _ Analyzer = (*Composite)(nil)
// NewComposite chains two analyzers. Either may be nil, in which case it is
// skipped.
func NewComposite(local, remote Analyzer) *Composite {
return &Composite{local: local, remote: remote}
}
// Name implements Analyzer.
func (c *Composite) Name() string {
if c.remote == nil {
return SourceLocal
}
return SourceLocal + "+" + SourceRemote
}
// Analyze measures locally, then augments with model labels when available.
//
// A failure in the remote service is reported as a non-fatal note rather than
// an error: the picture was still understood, just less precisely. Callers can
// inspect Note to surface the degradation.
func (c *Composite) Analyze(ctx context.Context, in Input) (*Result, error) {
var (
base *Result
local []domain.Badge
note string
)
if c.local != nil {
res, err := c.local.Analyze(ctx, in)
if err != nil {
return nil, err
}
base = res
local = res.Badges
} else {
base = &Result{}
}
if c.remote != nil {
hinted := in
hinted.Hints = labelsOf(local)
res, err := c.remote.Analyze(ctx, hinted)
switch {
case err != nil:
note = fmt.Sprintf("%s analyzer unavailable: %v", SourceRemote, err)
if ctx.Err() != nil {
return nil, ctx.Err()
}
default:
base.Width, base.Height = pickDimension(base.Width, res.Width), pickDimension(base.Height, res.Height)
base.Badges = Combine(local, res.Badges)
}
} else {
base.Badges = local
}
base.Note = note
return base, nil
}
// labelsOf extracts badge labels to use as upstream hints.
func labelsOf(badges []domain.Badge) []string {
if len(badges) == 0 {
return nil
}
out := make([]string, 0, len(badges))
for _, b := range badges {
if b.Confidence >= 0.5 {
out = append(out, b.Label)
}
}
return out
}
func pickDimension(current, candidate int) int {
if candidate > 0 {
return candidate
}
return current
}

View file

@ -0,0 +1,384 @@
package analysis
import (
"encoding/binary"
"errors"
"math"
"strings"
"time"
)
// This file implements a deliberately small EXIF reader. Only the fields the
// application actually uses are decoded:
//
// Make, Model IFD0, tags 0x010F / 0x0110
// DateTimeOriginal Exif SubIFD, tag 0x9003
// GPSLatitude / GPSLongitude GPS SubIFD, tags 0x0002 / 0x0004
//
// EXIF is a TIFF structure embedded in a file, so parsing it means walking an
// image file directory. Every offset read from the data is treated as untrusted
// and bounds-checked, because the input is a user upload.
// errNoEXIF is returned when the payload carries no EXIF block at all. That is
// the normal case for pictures exported from messaging apps and screenshots.
var errNoEXIF = errors.New("no EXIF block found")
// exifHeaderLen is the length of the "Exif\x00\x00" identifier that introduces
// the APP1 payload.
const exifHeaderLen = 6
// searchWindow bounds how far into the file the EXIF identifier is searched.
// APP1 comes near the front of a JPEG; a small window avoids scanning a
// multi-megabyte payload.
const searchWindow = 128 << 10
// TIFF field types from the TIFF 6.0 specification.
const (
tiffByte = 1
tiffASCII = 2
tiffShort = 3
tiffLong = 4
tiffRational = 5
tiffUndefined = 7
)
type tiffTag uint16
const (
tagMake tiffTag = 0x010F
tagModel tiffTag = 0x0110
tagExifIFDPointer tiffTag = 0x8769
tagGPSIFDPointer tiffTag = 0x8825
tagDateTimeOriginal tiffTag = 0x9003
tagGPSLatitudeRef tiffTag = 0x0001
tagGPSLatitude tiffTag = 0x0002
tagGPSLongitudeRef tiffTag = 0x0003
tagGPSLongitude tiffTag = 0x0004
)
// exifData holds the decoded fields. The zero value means "not present".
type exifData struct {
Make string
Model string
// TakenAt is the capture time. EXIF records no time zone, so it is
// interpreted as UTC.
TakenAt *time.Time
// Latitude and Longitude are signed decimal degrees.
Latitude float64
Longitude float64
HasGPS bool
}
// readEXIF extracts EXIF from a JPEG or PNG payload. A payload without EXIF
// yields errNoEXIF, which callers treat as a normal outcome.
func readEXIF(data []byte) (exifData, error) {
var out exifData
block, err := findExifBlock(data)
if err != nil {
return out, err
}
if err := parseTIFF(block, &out); err != nil {
return exifData{}, err
}
return out, nil
}
// findExifBlock locates the TIFF structure inside the payload. JPEG stores it
// in an APP1 segment and PNG in an "eXIf" chunk; both are preceded by the
// "Exif\x00\x00" identifier in practice, so scanning for that identifier keeps
// the reader format-agnostic.
func findExifBlock(data []byte) ([]byte, error) {
window := data
if len(window) > searchWindow {
window = window[:searchWindow]
}
const marker = "Exif\x00\x00"
for i := 0; i+exifHeaderLen <= len(window); i++ {
if string(window[i:i+exifHeaderLen]) != marker {
continue
}
start := i + exifHeaderLen
if start >= len(data) {
return nil, errNoEXIF
}
return data[start:], nil
}
return nil, errNoEXIF
}
// parseTIFF walks the directory structure starting at the beginning of block.
func parseTIFF(block []byte, out *exifData) error {
if len(block) < 8 {
return errors.New("EXIF: TIFF header is truncated")
}
var order binary.ByteOrder
switch {
case block[0] == 'I' && block[1] == 'I':
order = binary.LittleEndian
case block[0] == 'M' && block[1] == 'M':
order = binary.BigEndian
default:
return errors.New("EXIF: unknown byte order marker")
}
if magic := order.Uint16(block[2:4]); magic != 42 {
return errors.New("EXIF: bad TIFF magic number")
}
ifd0 := order.Uint32(block[4:8])
entries, next, err := readIFD(block, order, ifd0)
if err != nil {
return err
}
for _, e := range entries {
switch e.tag {
case tagMake:
if s, ok := e.asASCII(block, order); ok {
out.Make = s
}
case tagModel:
if s, ok := e.asASCII(block, order); ok {
out.Model = s
}
case tagExifIFDPointer:
if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil {
readExifSubIFD(block, order, sub, out)
}
case tagGPSIFDPointer:
if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil {
readGPSIFD(block, order, sub, out)
}
}
}
_ = next
return nil
}
// entry is one directory entry.
type entry struct {
tag tiffTag
typ uint16
count uint32
// inline is the 4-byte value field as read, used when the payload fits.
inline [4]byte
}
func readExifSubIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) {
for _, e := range entries {
if e.tag != tagDateTimeOriginal {
continue
}
if s, ok := e.asASCII(block, order); ok {
if t, err := time.Parse(exifTimeLayout, s); err == nil {
utc := t.UTC()
out.TakenAt = &utc
}
}
}
}
func readGPSIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) {
var (
lat, lon float64
latOK, lonOK bool
latRef, lonRef string
)
for _, e := range entries {
switch e.tag {
case tagGPSLatitudeRef:
if s, ok := e.asASCII(block, order); ok {
latRef = s
}
case tagGPSLongitudeRef:
if s, ok := e.asASCII(block, order); ok {
lonRef = s
}
case tagGPSLatitude:
if v, ok := e.asRationals(block, order); ok && len(v) >= 3 {
lat, latOK = dmsToDecimal(v[0], v[1], v[2])
}
case tagGPSLongitude:
if v, ok := e.asRationals(block, order); ok && len(v) >= 3 {
lon, lonOK = dmsToDecimal(v[0], v[1], v[2])
}
}
}
if latOK && lonOK {
// Southern latitudes and western longitudes are stored as magnitudes
// with a separate hemisphere reference.
if strings.EqualFold(latRef, "S") {
lat = -lat
}
if strings.EqualFold(lonRef, "W") {
lon = -lon
}
if lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 {
out.Latitude = lat
out.Longitude = lon
out.HasGPS = true
}
}
}
// readIFD reads the directory at the given offset and returns its entries plus
// the offset of the next directory, which is unused in practice.
func readIFD(block []byte, order binary.ByteOrder, offset uint32) ([]entry, uint32, error) {
// Each entry is 12 bytes and a directory of more than a few hundred
// entries indicates a malformed or hostile file.
const maxEntries = 512
if !inRange(block, offset, 2) {
return nil, 0, errors.New("EXIF: directory offset is out of range")
}
count := order.Uint16(block[offset : offset+2])
if int(count) > maxEntries {
return nil, 0, errors.New("EXIF: implausible directory entry count")
}
entries := make([]entry, 0, count)
base := int(offset) + 2
for i := 0; i < int(count); i++ {
at := base + i*12
if at < 0 || !inRange(block, uint32(at), 12) {
return nil, 0, errors.New("EXIF: directory entry is out of range")
}
rec := block[at : at+12]
e := entry{
tag: tiffTag(order.Uint16(rec[0:2])),
typ: order.Uint16(rec[2:4]),
count: order.Uint32(rec[4:8]),
}
copy(e.inline[:], rec[8:12])
entries = append(entries, e)
}
nextAt := base + int(count)*12
var next uint32
if nextAt >= 0 && inRange(block, uint32(nextAt), 4) {
next = order.Uint32(block[nextAt : nextAt+4])
}
return entries, next, nil
}
// exifTimeLayout is the non-conforming timestamp format used by EXIF.
const exifTimeLayout = "2006:01:02 15:04:05"
// typeSize returns the byte width of one element of a TIFF type.
//
// RATIONAL and SRATIONAL are two LONGs, so they are eight bytes rather than
// four. Getting this wrong makes every subsequent offset in the directory wrong,
// which is why it is worth stating explicitly.
func typeSize(typ uint16) (int, bool) {
switch typ {
case tiffByte, tiffASCII, tiffUndefined:
return 1, true
case tiffShort:
return 2, true
case tiffLong:
return 4, true
case tiffRational:
return 8, true
default:
return 0, false
}
}
// payload locates the entry's value, which is either inline in the 4-byte value
// field or at a pointer stored in that field.
func (e entry) payload(block []byte, order binary.ByteOrder) ([]byte, bool) {
size, ok := typeSize(e.typ)
if !ok {
return nil, false
}
total := uint64(e.count) * uint64(size)
if total == 0 {
return nil, false
}
// More than a few kilobytes of "value" means a malformed file; the fields
// this application reads are all short.
if total > 4096 {
return nil, false
}
if total <= 4 {
return e.inline[:total], true
}
offset := e.firstValue(order)
if !inRange(block, offset, uint32(total)) {
return nil, false
}
return block[int(offset) : int(offset)+int(total)], true
}
func (e entry) firstValue(order binary.ByteOrder) uint32 {
return order.Uint32(e.inline[:4])
}
func (e entry) asASCII(block []byte, order binary.ByteOrder) (string, bool) {
if e.typ != tiffASCII {
return "", false
}
raw, ok := e.payload(block, order)
if !ok {
return "", false
}
s := string(raw)
if i := strings.IndexByte(s, 0); i >= 0 {
s = s[:i]
}
return strings.TrimSpace(s), true
}
// asRationals decodes an array of unsigned rationals. Each is a numerator and
// denominator pair, and a zero denominator makes the entry unusable.
func (e entry) asRationals(block []byte, order binary.ByteOrder) ([]float64, bool) {
if e.typ != tiffRational {
return nil, false
}
raw, ok := e.payload(block, order)
if !ok || len(raw)%8 != 0 {
return nil, false
}
n := len(raw) / 8
out := make([]float64, 0, n)
for i := 0; i < n; i++ {
num := order.Uint32(raw[i*8 : i*8+4])
den := order.Uint32(raw[i*8+4 : i*8+8])
if den == 0 {
return nil, false
}
out = append(out, float64(num)/float64(den))
}
return out, true
}
// dmsToDecimal converts EXIF's degrees/minutes/seconds triple to decimal
// degrees. It reports false for out-of-range or non-finite values.
func dmsToDecimal(degrees, minutes, seconds float64) (float64, bool) {
if math.IsNaN(degrees) || math.IsNaN(minutes) || math.IsNaN(seconds) {
return 0, false
}
if degrees < 0 || degrees > 180 {
return 0, false
}
if minutes < 0 || minutes >= 60 {
return 0, false
}
if seconds < 0 || seconds >= 60 {
return 0, false
}
v := degrees + minutes/60 + seconds/3600
return v, true
}
func inRange(block []byte, offset, length uint32) bool {
end := uint64(offset) + uint64(length)
return end <= uint64(len(block))
}
func math_isNaN(v float64) bool { return v != v }

View file

@ -0,0 +1,423 @@
package analysis
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
)
// The tests build real TIFF/EXIF structures byte by byte and check the reader
// recovers the values, including for inputs a hostile client could supply.
// buildTIFF assembles a TIFF block from the given directories. The layout is
// header, IFD0, Exif SubIFD, GPS SubIFD, then a value area holding any entry
// payload larger than the four inline bytes.
//
// Pointer entries for the two sub-directories are filled in automatically, so a
// caller only has to list the tags it cares about.
func buildTIFF(t *testing.T, order binary.ByteOrder, ifd0, exifSub, gpsSub []tiffEntry) []byte {
t.Helper()
const headerLen = 8
// IFD0 is always emitted, even when empty, because readIFD reads the
// next-directory pointer that follows the entries.
ifdLen := func(n int) int { return 2 + n*12 + 4 }
areaLen := func(entries []tiffEntry) int {
total := 0
for _, e := range entries {
if len(e.payload) > 4 {
total += len(e.payload)
}
}
return total
}
ifd0Offset := headerLen
// Sub-directories are only emitted when the caller supplies entries, but
// IFD0 always is, so it always occupies space.
exifOffset := ifd0Offset + ifdLen(len(ifd0))
gpsOffset := exifOffset
if len(exifSub) > 0 {
gpsOffset += ifdLen(len(exifSub))
}
dataOffset := gpsOffset
if len(gpsSub) > 0 {
dataOffset += ifdLen(len(gpsSub))
}
out := make([]byte, dataOffset+areaLen(ifd0)+areaLen(exifSub)+areaLen(gpsSub))
if order == binary.LittleEndian {
copy(out[0:2], "II")
} else {
copy(out[0:2], "MM")
}
order.PutUint16(out[2:4], 42)
order.PutUint32(out[4:8], uint32(ifd0Offset))
// TIFF records the element count, not the byte count, so a 24-byte
// rational triple is count 3.
elementCount := func(typ uint16, payload []byte) uint32 {
var size uint32 = 1
switch typ {
case tiffShort:
size = 2
case tiffLong:
size = 4
case tiffRational:
size = 8 // two LONGs
}
return uint32(len(payload)) / size
}
areaCursor := dataOffset
writeIFD := func(entries []tiffEntry, offset uint32) {
order.PutUint16(out[offset:offset+2], uint16(len(entries)))
at := offset + 2
for _, e := range entries {
order.PutUint16(out[at:at+2], uint16(e.tag))
order.PutUint16(out[at+2:at+4], e.typ)
order.PutUint32(out[at+4:at+8], elementCount(e.typ, e.payload))
// Sub-IFD pointers are always four inline bytes and are resolved
// from the layout rather than supplied by the caller.
switch e.tag {
case tagExifIFDPointer:
order.PutUint32(out[at+8:at+12], uint32(exifOffset))
case tagGPSIFDPointer:
order.PutUint32(out[at+8:at+12], uint32(gpsOffset))
default:
if len(e.payload) <= 4 {
copy(out[at+8:at+12], e.payload)
} else {
order.PutUint32(out[at+8:at+12], uint32(areaCursor))
copy(out[areaCursor:areaCursor+len(e.payload)], e.payload)
areaCursor += len(e.payload)
}
}
at += 12
}
// No second directory follows.
}
writeIFD(ifd0, uint32(ifd0Offset))
if len(exifSub) > 0 {
writeIFD(exifSub, uint32(exifOffset))
}
if len(gpsSub) > 0 {
writeIFD(gpsSub, uint32(gpsOffset))
}
return out
}
type tiffEntry struct {
tag tiffTag
typ uint16
payload []byte
}
func asciiEntry(tag tiffTag, value string) tiffEntry {
// EXIF ASCII values are NUL-terminated.
return tiffEntry{tag: tag, typ: tiffASCII, payload: append([]byte(value), 0)}
}
func rationalEntry(order binary.ByteOrder, tag tiffTag, values ...[2]uint32) tiffEntry {
payload := make([]byte, 0, len(values)*8)
for _, v := range values {
var b [8]byte
order.PutUint32(b[0:4], v[0])
order.PutUint32(b[4:8], v[1])
payload = append(payload, b[:]...)
}
return tiffEntry{tag: tag, typ: tiffRational, payload: payload}
}
// jpegWithExif splices an APP1 segment carrying the TIFF block into a valid
// JPEG, which is how EXIF reaches a real JPEG file. The segment has to go
// immediately after the start-of-image marker, so the image is encoded first and
// the segment inserted into it.
func jpegWithExif(t *testing.T, tiffBlock []byte) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255})
}
}
var enc bytes.Buffer
if err := jpeg.Encode(&enc, img, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
encoded := enc.Bytes()
// APP1: marker, length, "Exif\0\0", then the TIFF block.
app1Payload := append([]byte("Exif\x00\x00"), tiffBlock...)
segment := make([]byte, 0, 4+len(app1Payload))
segment = append(segment, 0xFF, 0xE1)
lenBuf := make([]byte, 2)
binary.BigEndian.PutUint16(lenBuf, uint16(len(app1Payload)+2))
segment = append(segment, lenBuf...)
segment = append(segment, app1Payload...)
out := make([]byte, 0, len(encoded)+len(segment))
out = append(out, encoded[:2]...) // SOI
out = append(out, segment...)
out = append(out, encoded[2:]...)
return out
}
// pngWithExifChunk writes a PNG carrying an eXIf chunk, the other common
// container for EXIF.
func pngWithExifChunk(t *testing.T, tiffBlock []byte) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
out := buf.Bytes()
// Splice an eXIf chunk in right after the 8-byte PNG signature.
const sigLen = 8
var chunk bytes.Buffer
chunk.WriteString("eXIf")
b4 := make([]byte, 4)
binary.BigEndian.PutUint32(b4, uint32(len(tiffBlock)))
chunk.Write(b4)
chunk.Write(tiffBlock)
binary.BigEndian.PutUint32(b4, crc32IEEE(chunk.Bytes()))
chunk.Write(b4)
return append(append(append([]byte{}, out[:sigLen]...), chunk.Bytes()...), out[sigLen:]...)
}
// crc32IEEE computes the PNG chunk CRC.
func crc32IEEE(data []byte) uint32 {
var table [256]uint32
for i := range table {
c := uint32(i)
for k := 0; k < 8; k++ {
if c&1 != 0 {
c = 0xedb88320 ^ (c >> 1)
} else {
c >>= 1
}
}
table[i] = c
}
crc := uint32(0xffffffff)
for _, b := range data {
crc = table[(crc^uint32(b))&0xff] ^ (crc >> 8)
}
return crc ^ 0xffffffff
}
func TestReadEXIFExtractsMetadata(t *testing.T) {
t.Parallel()
for _, order := range []binary.ByteOrder{binary.LittleEndian, binary.BigEndian} {
name := "big-endian"
if order == binary.LittleEndian {
name = "little-endian"
}
t.Run(name, func(t *testing.T) {
t.Parallel()
block := buildTIFF(t, order,
[]tiffEntry{
asciiEntry(tagMake, "Canon"),
asciiEntry(tagModel, "EOS R6"),
// Empty payloads: buildTIFF fills the offsets in.
{tag: tagExifIFDPointer, typ: tiffLong},
{tag: tagGPSIFDPointer, typ: tiffLong},
},
[]tiffEntry{
asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45"),
},
[]tiffEntry{
asciiEntry(tagGPSLatitudeRef, "N"),
// 48 deg 51' 23.76" N
rationalEntry(order, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}),
asciiEntry(tagGPSLongitudeRef, "E"),
// 2 deg 17' 39.6" E
rationalEntry(order, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}),
},
)
got, err := readEXIF(withExifHeader(block))
if err != nil {
t.Fatalf("readEXIF: %v", err)
}
if got.Make != "Canon" {
t.Errorf("Make = %q, want %q", got.Make, "Canon")
}
if got.Model != "EOS R6" {
t.Errorf("Model = %q, want %q", got.Model, "EOS R6")
}
if got.TakenAt == nil {
t.Fatal("TakenAt was not decoded")
}
if got.TakenAt.Format("2006:01:02 15:04:05") != "2026:06:01 14:23:45" {
t.Errorf("TakenAt = %v, want 2026:06:01 14:23:45", got.TakenAt)
}
if !got.HasGPS {
t.Fatal("HasGPS = false, want true")
}
// 48 + 51/60 + 23.76/3600 = 48.8566
if diff := got.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 {
t.Errorf("Latitude = %v, want ~48.8566", got.Latitude)
}
// 2 + 17/60 + 39.6/3600 = 2.29433
if diff := got.Longitude - 2.29433; diff > 1e-3 || diff < -1e-3 {
t.Errorf("Longitude = %v, want ~2.29433", got.Longitude)
}
})
}
}
func TestReadEXIFHandlesSouthAndWest(t *testing.T) {
t.Parallel()
block := buildTIFF(t, binary.LittleEndian,
[]tiffEntry{
{tag: tagExifIFDPointer, typ: tiffLong},
{tag: tagGPSIFDPointer, typ: tiffLong},
},
nil,
[]tiffEntry{
asciiEntry(tagGPSLatitudeRef, "S"),
rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{33, 1}, [2]uint32{52, 1}, [2]uint32{0, 1}),
asciiEntry(tagGPSLongitudeRef, "W"),
rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{151, 1}, [2]uint32{12, 1}, [2]uint32{0, 1}),
},
)
got, err := readEXIF(withExifHeader(block))
if err != nil {
t.Fatalf("readEXIF: %v", err)
}
if got.Latitude >= 0 {
t.Errorf("Latitude = %v, want a negative value for a south ref", got.Latitude)
}
if got.Longitude >= 0 {
t.Errorf("Longitude = %v, want a negative value for a west ref", got.Longitude)
}
}
func TestReadEXIFWithoutMetadata(t *testing.T) {
t.Parallel()
block := buildTIFF(t, binary.LittleEndian, nil, nil, nil)
got, err := readEXIF(withExifHeader(block))
if err != nil {
t.Fatalf("readEXIF: %v", err)
}
if got.Make != "" || got.Model != "" || got.TakenAt != nil || got.HasGPS {
t.Errorf("expected an empty result, got %+v", got)
}
}
func TestReadEXIFRejectsMalformed(t *testing.T) {
t.Parallel()
cases := []struct {
name string
data []byte
}{
{"not exif at all", []byte("this is a text file, not a picture")},
{"truncated header", []byte("Exif\x00\x00II")},
{"unknown byte order", append([]byte("Exif\x00\x00"), []byte("XY\x00\x2a\x08\x00\x00\x00")...)},
{"bad magic", append([]byte("Exif\x00\x00"), []byte("II\x00\x00\x08\x00\x00\x00")...)},
// A directory offset pointing past the end of the block.
{"offset out of range", append([]byte("Exif\x00\x00"), []byte("II\x2a\x00\x08\x00\x00\xff\xff\xff\xff")...)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if _, err := readEXIF(tc.data); err == nil {
t.Error("expected an error, got nil")
}
})
}
}
// TestFindExifBlockIgnoresAbsentMetadata confirms a payload with no EXIF is
// reported as such rather than as a parse failure.
func TestFindExifBlockIgnoresAbsentMetadata(t *testing.T) {
t.Parallel()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
if _, err := readEXIF(buf.Bytes()); err == nil {
t.Error("expected an error for a PNG with no EXIF")
}
}
// TestReadEXIFRejectsAbsurdEntryCount guards the bound on directory size: a
// hostile file claiming a huge entry count must not cause a huge allocation.
func TestReadEXIFRejectsAbsurdEntryCount(t *testing.T) {
t.Parallel()
block := append([]byte("Exif\x00\x00"),
0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00,
0xff, 0xff, // 65535 entries
)
if _, err := readEXIF(block); err == nil {
t.Error("expected an error for an implausible entry count")
}
}
// TestAnalyzeUsesEXIFGPS verifies the metadata reaches the analysis result,
// which is what lets an upload be located without the client sending a
// coordinate.
func TestAnalyzeUsesEXIFGPS(t *testing.T) {
t.Parallel()
block := buildTIFF(t, binary.LittleEndian,
[]tiffEntry{
{tag: tagExifIFDPointer, typ: tiffLong},
{tag: tagGPSIFDPointer, typ: tiffLong},
},
[]tiffEntry{asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45")},
[]tiffEntry{
asciiEntry(tagGPSLatitudeRef, "N"),
rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}),
asciiEntry(tagGPSLongitudeRef, "E"),
rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}),
},
)
data := jpegWithExif(t, block)
a := NewLocal(DefaultMaxDimension)
res, err := a.Analyze(newTestContext(), Input{ContentType: "image/jpeg", Bytes: data})
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if res.Metadata.Coordinate == nil {
t.Fatal("expected the GPS coordinate to be recovered from EXIF")
}
if diff := res.Metadata.Coordinate.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 {
t.Errorf("latitude = %v, want ~48.8566", res.Metadata.Coordinate.Latitude)
}
if res.Metadata.TakenAt == nil {
t.Error("expected the capture timestamp to be recovered from EXIF")
}
}
// withExifHeader prefixes a raw TIFF block with the identifier that introduces
// an APP1 payload, which is what readEXIF scans for.
func withExifHeader(tiffBlock []byte) []byte {
return append([]byte("Exif\x00\x00"), tiffBlock...)
}

View file

@ -0,0 +1,427 @@
package analysis
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"math"
// Registering the decoders is what makes image.Decode understand the
// formats named in the configured allow-list.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"golang.org/x/image/draw"
"go-places-api/internal/domain"
)
// DefaultMaxDimension is the longest edge sampled when Input does not set one.
// 256px keeps the work per picture small while preserving the broad colour and
// edge statistics the badge rules rely on.
const DefaultMaxDimension = 256
// LocalAnalyzer measures a picture's own pixels and metadata. It requires no
// network access and always produces the same badges for the same bytes.
type LocalAnalyzer struct {
maxDimension int
}
var _ Analyzer = (*LocalAnalyzer)(nil)
// NewLocal returns a LocalAnalyzer sampling down to maxDimension on the longest
// edge. Values below 32 are raised to 32 so the statistics stay meaningful.
func NewLocal(maxDimension int) *LocalAnalyzer {
if maxDimension < 32 {
maxDimension = 32
}
return &LocalAnalyzer{maxDimension: maxDimension}
}
// Name implements Analyzer.
func (l *LocalAnalyzer) Name() string { return "local" }
// Analyze measures the picture and derives badges from the measurements.
func (l *LocalAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) {
if len(in.Bytes) == 0 {
return nil, errors.New("analysis: empty image payload")
}
if err := ctx.Err(); err != nil {
return nil, err
}
// DecodeConfig reads only the header, giving true dimensions cheaply and
// letting an unsupported format be rejected before the pixel work starts.
cfg, _, err := image.DecodeConfig(bytes.NewReader(in.Bytes))
if err != nil {
return nil, fmt.Errorf("analysis: read image header: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, fmt.Errorf("analysis: invalid image dimensions %dx%d", cfg.Width, cfg.Height)
}
sample, err := sample(in.Bytes, l.maxDimension)
if err != nil {
return nil, err
}
res := &Result{
Width: cfg.Width,
Height: cfg.Height,
Signals: measure(sample),
Metadata: readMetadata(in.Bytes),
}
res.Badges = DeriveBadges(res)
return res, nil
}
// sample decodes the picture and downscales it so the longest edge is at most
// maxDimension. Decoders in the standard library always materialise the full
// image, so the payload size limit enforced by the upload handler is what keeps
// this bounded.
func sample(data []byte, maxDimension int) (image.Image, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("analysis: decode image: %w", err)
}
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxDimension && h <= maxDimension {
return src, nil
}
// Preserve aspect ratio and never upscale.
scale := float64(maxDimension) / float64(max(w, h))
dstW := int(math.Round(float64(w) * scale))
dstH := int(math.Round(float64(h) * scale))
if dstW < 1 {
dstW = 1
}
if dstH < 1 {
dstH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst, nil
}
// measure converts the sampled pixels into the normalised signals used by the
// badge rules.
func measure(img image.Image) Signals {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w == 0 || h == 0 {
return Signals{}
}
s := Signals{Width: w, Height: h}
// Accumulate per-band so sky and ground can be told apart.
bandAcc := [3]*bandAccumulator{newBandAccumulator(), newBandAccumulator(), newBandAccumulator()}
hueAcc := newHueAccumulator()
rgbAcc := newRGBAccumulator()
// The sampled picture is at most maxDimension on its longest edge, so a
// single byte per pixel is a small amount of memory and lets the edge pass
// below see a proper 3x3 neighbourhood.
lumas := make([]luma, w*h)
brightnessSum := 0.0
saturationSum := 0.0
for y := 0; y < h; y++ {
rowBase := y * w
for x := 0; x < w; x++ {
c := colorAt(img, b.Min.X+x, b.Min.Y+y)
lu := lumaOf(c)
lumas[rowBase+x] = lu
brightnessSum += float64(lu) / 255
saturationSum += saturationOf(c)
rgbAcc.add(c)
hueAcc.add(c)
// Band index 0 top, 1 middle, 2 bottom.
bi := (y * 3) / h
bandAcc[bi].add(c, lu)
}
}
totalPixels := w * h
inv := 1 / float64(totalPixels)
s.Brightness = brightnessSum * inv
s.Saturation = saturationSum * inv
s.EdgeDensity = float64(countEdges(lumas, w, h)) * inv
s.HueWeights = hueAcc.weights(totalPixels)
s.Colorfulness = rgbAcc.colorfulness()
for i, acc := range bandAcc {
switch i {
case 0:
s.Top = acc.finish(totalPixels)
case 1:
s.Middle = acc.finish(totalPixels)
case 2:
s.Bottom = acc.finish(totalPixels)
}
}
return s
}
// countEdges applies a Sobel operator at every pixel with a full 3x3
// neighbourhood, returning how many exceed edgeThreshold. Border pixels are
// skipped because their neighbourhood would fall outside the image.
func countEdges(lumas []luma, w, h int) int {
if w < 3 || h < 3 {
return 0
}
edges := 0
for y := 1; y < h-1; y++ {
top := (y - 1) * w
mid := y * w
bot := (y + 1) * w
for x := 1; x < w-1; x++ {
if isEdge(
lumas[top+x-1], lumas[top+x], lumas[top+x+1],
lumas[mid+x-1], lumas[mid+x], lumas[mid+x+1],
lumas[bot+x-1], lumas[bot+x], lumas[bot+x+1],
) {
edges++
}
}
}
return edges
}
// edgeThreshold is the normalised gradient magnitude a pixel must exceed to
// count as an edge.
const edgeThreshold = 0.12
// isEdge applies a Sobel operator to the 3x3 neighbourhood around the centre
// pixel and reports whether the gradient magnitude exceeds edgeThreshold.
func isEdge(tl, tc, tr, ml, mc, mr, bl, bc, br luma) bool {
gx := -float64(tl) + float64(tr)
gx += -2*float64(ml) + 2*float64(mr)
gx += -float64(bl) + float64(br)
gy := -float64(tl) - 2*float64(tc) - float64(tr)
gy += float64(bl) + 2*float64(bc) + float64(br)
gx /= 4 * 255
gy /= 4 * 255
return gx*gx+gy*gy > edgeThreshold*edgeThreshold
}
type luma uint8
func lumaOf(c rgb) luma {
// Rec. 601 luma, which tracks perceived brightness closely enough here.
r, g, b := c.r, c.g, c.b
return luma((299*int(r) + 587*int(g) + 114*int(b)) / 1000)
}
// rgb is a plain 8-bit RGB triple, kept private so the sampling loop avoids
// repeated interface method calls.
type rgb struct{ r, g, b uint8 }
func colorAt(img image.Image, x, y int) rgb {
r, g, b, _ := img.At(x, y).RGBA()
// RGBA returns alpha-premultiplied 16-bit values.
return rgb{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}
}
// saturationOf returns the HSV saturation of a pixel in 0..1.
func saturationOf(c rgb) float64 {
maxV := max(c.r, max(c.g, c.b))
minV := min(c.r, min(c.g, c.b))
if maxV == 0 {
return 0
}
return float64(maxV-minV) / float64(maxV)
}
// hueClass buckets a pixel by hue, treating low-saturation pixels as
// achromatic since their hue is not meaningful.
func hueClass(c rgb) (blue, green, warm, purple, achromatic bool) {
const achromaticSat = 0.20
hsvSat := saturationOf(c)
if hsvSat < achromaticSat {
return false, false, false, false, true
}
maxV := float64(max(c.r, max(c.g, c.b)))
minV := float64(min(c.r, min(c.g, c.b)))
delta := maxV - minV
if delta == 0 {
return false, false, false, false, true
}
var hue float64
switch maxV {
case float64(c.r):
hue = math.Mod(60*(float64(c.g)-float64(c.b))/delta+360, 360)
case float64(c.g):
hue = 60*(float64(c.b)-float64(c.r))/delta + 120
default:
hue = 60*(float64(c.r)-float64(c.g))/delta + 240
}
// Boundaries are the conventional hue ranges: blue 180-260, green 60-170,
// purple 260-320, warm everything else including red and yellow.
switch {
case hue >= 180 && hue < 260:
blue = true
case hue >= 60 && hue < 170:
green = true
case hue >= 260 && hue < 320:
purple = true
default:
warm = true
}
return
}
type hueAccumulator struct {
n int
w HueWeights
}
func newHueAccumulator() *hueAccumulator { return &hueAccumulator{} }
func (a *hueAccumulator) add(c rgb) {
a.n++
blue, green, warm, purple, achromatic := hueClass(c)
if blue {
a.w.Blue++
}
if green {
a.w.Green++
}
if warm {
a.w.Warm++
}
if purple {
a.w.Purple++
}
if achromatic {
a.w.Achromatic++
}
}
func (a *hueAccumulator) weights(total int) HueWeights {
if total == 0 {
return HueWeights{}
}
inv := 1 / float64(total)
return HueWeights{
Blue: float64(a.w.Blue) * inv,
Green: float64(a.w.Green) * inv,
Warm: float64(a.w.Warm) * inv,
Purple: float64(a.w.Purple) * inv,
Achromatic: float64(a.w.Achromatic) * inv,
}
}
type bandAccumulator struct {
pixels int
lumaSum uint64
satSum float64
hue hueAccumulator
}
func newBandAccumulator() *bandAccumulator {
return &bandAccumulator{hue: *newHueAccumulator()}
}
func (a *bandAccumulator) add(c rgb, lu luma) {
a.pixels++
a.lumaSum += uint64(lu)
a.satSum += saturationOf(c)
a.hue.add(c)
}
func (a *bandAccumulator) finish(totalPixels int) Band {
if a.pixels == 0 {
return Band{}
}
return Band{
Brightness: float64(a.lumaSum) / float64(a.pixels) / 255,
Saturation: a.satSum / float64(a.pixels),
HueWeights: a.hue.weights(a.pixels),
PixelShare: float64(a.pixels) / float64(totalPixels),
PixelCount: a.pixels,
}
}
// rgbAccumulator tracks per-channel means and spreads for the colorfulness
// measure of Hasler and Suesstrunk, a standard perceptual metric.
type rgbAccumulator struct {
rgSum, rgSumSq float64
ybSum, ybSumSq float64
n int
}
func newRGBAccumulator() *rgbAccumulator { return &rgbAccumulator{} }
func (a *rgbAccumulator) add(c rgb) {
rg := float64(c.r) - float64(c.g)
yb := 0.5*(float64(c.r)+float64(c.g)) - float64(c.b)
a.n++
a.rgSum += rg
a.rgSumSq += rg * rg
a.ybSum += yb
a.ybSumSq += yb * yb
}
func (a *rgbAccumulator) colorfulness() float64 {
if a.n == 0 {
return 0
}
n := float64(a.n)
rgMean, rgStd := a.rgSum/n, stddev(a.rgSumSq/n, (a.rgSum/n)*(a.rgSum/n))
ybMean, ybStd := a.ybSum/n, stddev(a.ybSumSq/n, (a.ybSum/n)*(a.ybSum/n))
magnitude := math.Hypot(rgMean, ybMean)
spread := math.Hypot(rgStd, ybStd)
// Normalised against a saturation and contrast of 0.3, which is the point
// where the metric is conventionally considered "highly colourful".
return clamp01((magnitude + spread) / 105)
}
func stddev(meanOfSquares, squareOfMean float64) float64 {
v := meanOfSquares - squareOfMean
if v <= 0 {
return 0
}
return math.Sqrt(v)
}
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
// readMetadata extracts what EXIF knows about the capture. A picture without
// usable EXIF is normal, not an error.
func readMetadata(data []byte) Metadata {
var md Metadata
parsed, err := readEXIF(data)
if err != nil {
return md
}
md.Make = parsed.Make
md.Model = parsed.Model
md.TakenAt = parsed.TakenAt
if parsed.HasGPS {
c := domain.Coordinate{Latitude: parsed.Latitude, Longitude: parsed.Longitude}
if c.Valid() {
md.Coordinate = &c
}
}
return md
}

View file

@ -0,0 +1,139 @@
package analysis
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"go-places-api/internal/domain"
)
// SourceRemote identifies badges produced by an external inference service.
const SourceRemote = "remote"
// RemoteAnalyzer forwards a picture to an external inference service that runs
// a trained object or scene classifier, and turns the labels it returns into
// badges. This is the path that recognises specific objects; the local analyzer
// only produces scene-level categories.
type RemoteAnalyzer struct {
baseURL string
apiKey string
client *http.Client
// maxBytes bounds the payload sent upstream so a large upload cannot be
// forwarded without limit.
maxBytes int64
}
var _ Analyzer = (*RemoteAnalyzer)(nil)
// NewRemote returns a RemoteAnalyzer calling baseURL. The timeout bounds the
// whole request including reading the response.
func NewRemote(baseURL, apiKey string, timeout time.Duration) *RemoteAnalyzer {
if timeout <= 0 {
timeout = 10 * time.Second
}
return &RemoteAnalyzer{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
client: &http.Client{Timeout: timeout},
maxBytes: 12 << 20,
}
}
// remoteRequest is the payload sent to the inference service.
type remoteRequest struct {
ContentType string `json:"contentType"`
ImageBase64 string `json:"imageBase64"`
// Hint carries locally derived scene badges so the service can disambiguate
// using the signals we already computed.
Hint []string `json:"hint,omitempty"`
}
// remoteResponse is the expected reply. Width and height are optional because
// the service may not report them.
type remoteResponse struct {
Width int `json:"width"`
Height int `json:"height"`
Labels []struct {
Label string `json:"label"`
Display string `json:"display"`
Confidence float64 `json:"confidence"`
} `json:"labels"`
}
// Analyze sends the picture upstream and converts the reply into badges.
func (r *RemoteAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) {
if len(in.Bytes) == 0 {
return nil, fmt.Errorf("analysis: empty image payload")
}
if int64(len(in.Bytes)) > r.maxBytes {
return nil, fmt.Errorf("analysis: payload of %d bytes exceeds remote limit of %d",
len(in.Bytes), r.maxBytes)
}
body, err := json.Marshal(remoteRequest{
ContentType: in.ContentType,
ImageBase64: base64.StdEncoding.EncodeToString(in.Bytes),
})
if err != nil {
return nil, fmt.Errorf("analysis: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/v1/analyze", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("analysis: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if r.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+r.apiKey)
}
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("analysis: call inference service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Read a bounded amount of the error body to aid debugging without
// letting an error response stream unbounded data.
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("analysis: inference service returned %s: %s",
resp.Status, strings.TrimSpace(string(snippet)))
}
// Bound the response so a misbehaving service cannot exhaust memory.
var decoded remoteResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&decoded); err != nil {
return nil, fmt.Errorf("analysis: decode inference response: %w", err)
}
res := &Result{Width: decoded.Width, Height: decoded.Height}
for _, l := range decoded.Labels {
label := strings.TrimSpace(strings.ToLower(l.Label))
if label == "" {
continue
}
display := strings.TrimSpace(l.Display)
if display == "" {
display = Humanize(label)
}
res.Badges = append(res.Badges, domain.Badge{
Label: label,
Display: display,
Confidence: round2(clamp01(l.Confidence)),
Source: SourceRemote,
})
}
return res, nil
}
// Name implements Analyzer.
func (r *RemoteAnalyzer) Name() string { return SourceRemote }

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

@ -0,0 +1,200 @@
// Package auth validates the bearer tokens issued by the go-loose SSO service
// and identifies the caller.
package auth
import (
"context"
"errors"
"fmt"
"strings"
"github.com/golang-jwt/jwt/v5"
"go-places-api/internal/domain"
)
// Errors returned by verifiers. They are deliberately coarse: a caller learns
// that a token is unacceptable, not why, so probing cannot distinguish an
// expired token from a forged one.
var (
// ErrMissingToken means no credential was presented.
ErrMissingToken = errors.New("auth: no token presented")
// ErrInvalidToken means the token failed verification for any reason.
ErrInvalidToken = errors.New("auth: invalid token")
)
// Claims are the fields the application reads from a go-loose token.
type Claims struct {
jwt.RegisteredClaims
// Username and Email are copied from the identity provider. They are
// optional because a token may only assert the subject.
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
}
// User is the identity resolved from a token.
type User struct {
domain.User
}
// Verifier turns a bearer token into an identity.
type Verifier interface {
// Verify returns the identity the token asserts. It returns
// ErrMissingToken or ErrInvalidToken for unusable credentials.
Verify(ctx context.Context, token string) (User, error)
// Name identifies the verification strategy, for logging.
Name() string
}
// JWTVerifier verifies HS256-signed tokens issued by go-loose.
//
// Only HS256 is accepted. The algorithm is pinned rather than read from the
// token header so that a token cannot select "none" or an asymmetric algorithm
// and slip past verification.
type JWTVerifier struct {
secret []byte
audience string
issuer string
}
var _ Verifier = (*JWTVerifier)(nil)
// NewJWTVerifier returns a verifier for tokens signed with secret. audience may
// be empty to skip the audience check; issuer likewise.
func NewJWTVerifier(secret, audience, issuer string) *JWTVerifier {
return &JWTVerifier{secret: []byte(secret), audience: audience, issuer: issuer}
}
// Name implements Verifier.
func (v *JWTVerifier) Name() string { return "jwt" }
// Verify parses and validates the token, then extracts the identity.
func (v *JWTVerifier) Verify(ctx context.Context, token string) (User, error) {
if err := ctx.Err(); err != nil {
return User{}, err
}
if token == "" {
return User{}, ErrMissingToken
}
if len(v.secret) == 0 {
return User{}, fmt.Errorf("%w: no signing secret configured", ErrInvalidToken)
}
opts := []jwt.ParserOption{
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithExpirationRequired(),
}
if v.audience != "" {
opts = append(opts, jwt.WithAudience(v.audience))
}
if v.issuer != "" {
opts = append(opts, jwt.WithIssuer(v.issuer))
}
var claims Claims
parsed, err := jwt.ParseWithClaims(token, &claims, keyFunc(v.secret), opts...)
if err != nil {
// The underlying reason stays unexported so the caller cannot use the
// error text to learn why a token was rejected.
return User{}, ErrInvalidToken
}
if !parsed.Valid {
return User{}, ErrInvalidToken
}
if claims.Subject == "" {
// Without a subject there is nobody to attribute the request to.
return User{}, ErrInvalidToken
}
return User{domain.User{
ID: claims.Subject,
Username: firstNonEmpty(claims.Username, claims.Subject),
Email: claims.Email,
}}, nil
}
// keyFunc supplies the verification key. The token's own "alg" header is
// deliberately ignored because the parser is already pinned to HS256.
func keyFunc(secret []byte) jwt.Keyfunc {
return func(t *jwt.Token) (any, error) {
return secret, nil
}
}
// DevVerifier accepts a small set of fixed tokens for local development. It is
// only wired up when AUTH_MODE=dev, and refuses to start in production.
type DevVerifier struct {
tokens map[string]domain.User
}
var _ Verifier = (*DevVerifier)(nil)
// DefaultDevUser is the identity assumed by the "valid_token" dev token.
var DefaultDevUser = domain.User{
ID: "dev-user",
Username: "dev",
Email: "dev@localhost",
}
// NewDevVerifier returns a verifier accepting the given token-to-user mapping.
func NewDevVerifier(tokens map[string]domain.User) *DevVerifier {
if tokens == nil {
tokens = map[string]domain.User{"valid_token": DefaultDevUser}
}
return &DevVerifier{tokens: tokens}
}
// Name implements Verifier.
func (v *DevVerifier) Name() string { return "dev" }
// Verify looks the token up in the fixed table.
func (v *DevVerifier) Verify(ctx context.Context, token string) (User, error) {
if err := ctx.Err(); err != nil {
return User{}, err
}
if token == "" {
return User{}, ErrMissingToken
}
u, ok := v.tokens[token]
if !ok {
return User{}, ErrInvalidToken
}
return User{u}, nil
}
// BearerToken extracts the credential from an Authorization header value.
// Both "Bearer <token>" and a bare token are accepted, since some clients send
// the latter.
func BearerToken(header string) (string, error) {
if strings.TrimSpace(header) == "" {
return "", ErrMissingToken
}
// The scheme is split before trimming the credential, so "Bearer " with
// nothing after it is reported as a missing token rather than read as a
// bare token whose value is the word "Bearer".
scheme, rest, hasSpace := strings.Cut(header, " ")
if strings.EqualFold(scheme, "bearer") {
token := strings.TrimSpace(rest)
if !hasSpace || token == "" {
return "", ErrMissingToken
}
return token, nil
}
header = strings.TrimSpace(header)
// A bare token is accepted, but reject anything shaped like another scheme.
if i := strings.IndexByte(header, ' '); i > 0 {
return "", ErrInvalidToken
}
return header, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}

View file

@ -0,0 +1,360 @@
package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const testSecret = "test-signing-secret"
// sign builds a token with the given claims and method.
func sign(t *testing.T, method jwt.SigningMethod, claims jwt.Claims) string {
t.Helper()
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(testSecret))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}
// validClaims returns an unexpired claim set for the verifier under test.
func validClaims() Claims {
return Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
Username: "ada",
Email: "ada@example.com",
}
}
func TestJWTVerifierAcceptsValidToken(t *testing.T) {
t.Parallel()
v := NewJWTVerifier(testSecret, "", "")
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.ID != "user-42" {
t.Errorf("ID = %q, want user-42", got.ID)
}
if got.Username != "ada" {
t.Errorf("Username = %q, want ada", got.Username)
}
if got.Email != "ada@example.com" {
t.Errorf("Email = %q", got.Email)
}
if v.Name() != "jwt" {
t.Errorf("Name = %q, want jwt", v.Name())
}
}
func TestJWTVerifierFallsBackToSubjectForUsername(t *testing.T) {
t.Parallel()
// A token that only asserts a subject still has to yield a usable identity,
// so the username defaults to the subject.
claims := Claims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-7",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
v := NewJWTVerifier(testSecret, "", "")
got, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, claims))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.Username != "user-7" {
t.Errorf("Username = %q, want the subject as fallback", got.Username)
}
}
func TestJWTVerifierRejections(t *testing.T) {
t.Parallel()
expired := Claims{RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
}}
noExp := Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-42"}}
noSub := Claims{RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
tests := []struct {
name string
token string
}{
{"empty", ""},
{"expired", sign(t, jwt.SigningMethodHS256, expired)},
{"missing expiration", sign(t, jwt.SigningMethodHS256, noExp)},
{"missing subject", sign(t, jwt.SigningMethodHS256, noSub)},
{"wrong secret", signWithSecret(t, jwt.SigningMethodHS256, validClaims(), "other-secret")},
// The algorithm is pinned, so a token cannot pick "none" and skip
// signature verification entirely.
{"alg none", unsignedToken(t, validClaims())},
{"garbage", "not-a-token"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
v := NewJWTVerifier(testSecret, "", "")
if _, err := v.Verify(context.Background(), tc.token); err == nil {
t.Error("Verify accepted a token it should have rejected")
}
})
}
}
func TestJWTVerifierReportsMissingTokenDistinctly(t *testing.T) {
t.Parallel()
// The middleware needs to tell "you sent nothing" from "what you sent is
// bad" so it can word the challenge properly.
v := NewJWTVerifier(testSecret, "", "")
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
t.Errorf("err = %v, want ErrMissingToken", err)
}
if _, err := v.Verify(context.Background(), "garbage"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
}
func TestJWTVerifierRequiresConfiguredSecret(t *testing.T) {
t.Parallel()
// An empty secret must reject everything rather than accept a token signed
// with an empty key.
v := NewJWTVerifier("", "", "")
_, err := v.Verify(context.Background(), sign(t, jwt.SigningMethodHS256, validClaims()))
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
}
func TestJWTVerifierEnforcesAudienceAndIssuer(t *testing.T) {
t.Parallel()
claims := validClaims()
claims.Audience = jwt.ClaimStrings{"go-places"}
claims.Issuer = "go-loose"
token := sign(t, jwt.SigningMethodHS256, claims)
matching := NewJWTVerifier(testSecret, "go-places", "go-loose")
if _, err := matching.Verify(context.Background(), token); err != nil {
t.Errorf("Verify with matching aud/iss: %v", err)
}
// A token minted for a different client must not be accepted, or a token
// issued for another service in the same cluster would grant access.
wrongAudience := NewJWTVerifier(testSecret, "some-other-app", "go-loose")
if _, err := wrongAudience.Verify(context.Background(), token); err == nil {
t.Error("Verify accepted a token with the wrong audience")
}
wrongIssuer := NewJWTVerifier(testSecret, "go-places", "some-other-issuer")
if _, err := wrongIssuer.Verify(context.Background(), token); err == nil {
t.Error("Verify accepted a token from the wrong issuer")
}
// Skipping the checks is the documented behaviour of the empty arguments.
unpinned := NewJWTVerifier(testSecret, "", "")
if _, err := unpinned.Verify(context.Background(), token); err != nil {
t.Errorf("Verify with no aud/iss configured: %v", err)
}
}
func TestJWTVerifierHonoursContextCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
v := NewJWTVerifier(testSecret, "", "")
_, err := v.Verify(ctx, sign(t, jwt.SigningMethodHS256, validClaims()))
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDevVerifierAcceptsOnlyConfiguredTokens(t *testing.T) {
t.Parallel()
v := NewDevVerifier(nil)
got, err := v.Verify(context.Background(), "valid_token")
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.ID != DefaultDevUser.ID {
t.Errorf("ID = %q, want %q", got.ID, DefaultDevUser.ID)
}
if v.Name() != "dev" {
t.Errorf("Name = %q, want dev", v.Name())
}
if _, err := v.Verify(context.Background(), "anything-else"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want ErrInvalidToken", err)
}
if _, err := v.Verify(context.Background(), ""); !errors.Is(err, ErrMissingToken) {
t.Errorf("err = %v, want ErrMissingToken", err)
}
}
func TestBearerToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
header string
want string
wantErr error
}{
{name: "standard", header: "Bearer abc.def.ghi", want: "abc.def.ghi"},
{name: "lowercase scheme", header: "bearer abc", want: "abc"},
{name: "mixed case scheme", header: "BeArEr abc", want: "abc"},
{name: "extra spaces", header: "Bearer abc ", want: "abc"},
{name: "bare token", header: "abc.def.ghi", want: "abc.def.ghi"},
{name: "empty", header: "", wantErr: ErrMissingToken},
{name: "whitespace only", header: " ", wantErr: ErrMissingToken},
{name: "scheme without token", header: "Bearer ", wantErr: ErrMissingToken},
// A header naming some other scheme must not be silently read as a
// bearer token.
{name: "other scheme", header: "Basic dXNlcjpwYXNz", wantErr: ErrInvalidToken},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := BearerToken(tc.header)
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, want %v", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("token = %q, want %q", got, tc.want)
}
})
}
}
func TestMiddlewareAttachesUserToContext(t *testing.T) {
t.Parallel()
m := NewMiddleware(NewDevVerifier(nil))
var seen User
var ok bool
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
seen, ok = FromContext(r.Context())
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/places", nil)
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if !ok {
t.Fatal("the handler could not read the identity from the context")
}
if seen.ID != DefaultDevUser.ID {
t.Errorf("user = %q, want %q", seen.ID, DefaultDevUser.ID)
}
}
func TestMiddlewareRejectsUnusableCredentials(t *testing.T) {
t.Parallel()
m := NewMiddleware(NewDevVerifier(nil))
tests := []struct {
name string
header string
body string
}{
{name: "no header", body: "a bearer token is required"},
{name: "bad token", header: "Bearer nope", body: "the token was rejected"},
{name: "other scheme", header: "Basic dXNlcjpwYXNz", body: "the token was rejected"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Local to the subtest: the subtests run in parallel, so a shared
// flag would be a data race.
called := false
h := m.WrapFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
req := httptest.NewRequest(http.MethodGet, "/places", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if called {
t.Error("the protected handler ran without a valid credential")
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(got, "Bearer") {
t.Errorf("WWW-Authenticate = %q, want a Bearer challenge", got)
}
if body := rec.Body.String(); !strings.Contains(body, tc.body) {
t.Errorf("body = %q, want it to mention %q", body, tc.body)
}
})
}
}
func TestFromContextReportsAbsentIdentity(t *testing.T) {
t.Parallel()
if _, ok := FromContext(context.Background()); ok {
t.Error("FromContext found an identity on a bare context")
}
}
// signWithSecret signs with a different key to model a forged token.
func signWithSecret(t *testing.T, method jwt.SigningMethod, claims jwt.Claims, secret string) string {
t.Helper()
s, err := jwt.NewWithClaims(method, claims).SignedString([]byte(secret))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}
// unsignedToken builds an alg=none token, the classic JWT downgrade attack.
func unsignedToken(t *testing.T, claims jwt.Claims) string {
t.Helper()
s, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).
SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
return s
}

View file

@ -0,0 +1,63 @@
package auth
import (
"context"
"errors"
"net/http"
)
type ctxKey struct{}
// WithUser stores the verified identity on the request context.
func WithUser(ctx context.Context, u User) context.Context {
return context.WithValue(ctx, ctxKey{}, u)
}
// FromContext returns the identity attached by the middleware.
func FromContext(ctx context.Context) (User, bool) {
u, ok := ctx.Value(ctxKey{}).(User)
return u, ok
}
// Middleware verifies the Authorization header and rejects requests that carry
// no usable credential.
type Middleware struct {
verifier Verifier
}
// NewMiddleware returns middleware backed by the verifier.
func NewMiddleware(v Verifier) *Middleware {
return &Middleware{verifier: v}
}
// Wrap protects h, making the verified identity available to it.
func (m *Middleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := BearerToken(r.Header.Get("Authorization"))
if err != nil {
unauthorized(w, err)
return
}
user, err := m.verifier.Verify(r.Context(), token)
if err != nil {
unauthorized(w, err)
return
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user)))
})
}
// WrapFunc protects a handler function.
func (m *Middleware) WrapFunc(next http.HandlerFunc) http.HandlerFunc {
return m.Wrap(next).ServeHTTP
}
func unauthorized(w http.ResponseWriter, err error) {
w.Header().Set("WWW-Authenticate", `Bearer realm="go-places"`)
// The response deliberately does not echo the reason.
if errors.Is(err, ErrMissingToken) {
http.Error(w, "unauthorized: a bearer token is required", http.StatusUnauthorized)
return
}
http.Error(w, "unauthorized: the token was rejected", http.StatusUnauthorized)
}

View file

@ -0,0 +1,29 @@
// Package blobstore stores the bytes of uploaded pictures. Keeping it behind an
// interface means the local filesystem can later be replaced by object storage
// without changing the upload handler.
package blobstore
import (
"context"
"errors"
"io"
)
var (
// ErrNotFound is returned when a key has no stored object.
ErrNotFound = errors.New("blob not found")
// ErrTooLarge is returned when the payload exceeds the configured limit.
ErrTooLarge = errors.New("blob exceeds maximum size")
)
// BlobStore saves and serves opaque byte payloads addressed by key.
type BlobStore interface {
// Put stores the contents of r under key and returns the number of bytes
// written. It must not read more than maxBytes from r.
Put(ctx context.Context, key string, r io.Reader, maxBytes int64) (int64, error)
// Get opens the object stored under key. The caller closes the reader.
Get(ctx context.Context, key string) (io.ReadCloser, error)
// Delete removes the object stored under key. Deleting a missing key is not
// an error.
Delete(ctx context.Context, key string) error
}

View file

@ -0,0 +1,132 @@
package blobstore
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// LocalFS stores blobs as files beneath a root directory.
type LocalFS struct {
root string
}
var _ BlobStore = (*LocalFS)(nil)
// NewLocalFS returns a blob store rooted at dir, creating it if necessary.
func NewLocalFS(dir string) (*LocalFS, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("blobstore: resolve root %q: %w", dir, err)
}
if err := os.MkdirAll(abs, 0o750); err != nil {
return nil, fmt.Errorf("blobstore: create root %q: %w", abs, err)
}
return &LocalFS{root: abs}, nil
}
// Root returns the absolute directory holding the blobs.
func (l *LocalFS) Root() string { return l.root }
// Put writes the stream to a temporary file and renames it into place, so a
// failed or oversized upload never leaves a partial object visible.
func (l *LocalFS) Put(ctx context.Context, key string, r io.Reader, maxBytes int64) (int64, error) {
path, err := l.resolve(key)
if err != nil {
return 0, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return 0, fmt.Errorf("blobstore: create parent of %q: %w", key, err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".upload-*")
if err != nil {
return 0, fmt.Errorf("blobstore: create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() {
tmp.Close()
os.Remove(tmpName) // no-op once the rename has succeeded
}()
// Copy one byte past the limit so an oversized payload is detectable.
written, err := io.Copy(tmp, io.LimitReader(r, maxBytes+1))
if err != nil {
return 0, fmt.Errorf("blobstore: write %q: %w", key, err)
}
if written > maxBytes {
return written, fmt.Errorf("%w: limit is %d bytes", ErrTooLarge, maxBytes)
}
if err := tmp.Sync(); err != nil {
return 0, fmt.Errorf("blobstore: sync %q: %w", key, err)
}
if err := tmp.Close(); err != nil {
return 0, fmt.Errorf("blobstore: close %q: %w", key, err)
}
if err := ctx.Err(); err != nil {
return written, err
}
if err := os.Rename(tmpName, path); err != nil {
return written, fmt.Errorf("blobstore: commit %q: %w", key, err)
}
return written, nil
}
// Get opens the stored file.
func (l *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) {
path, err := l.resolve(key)
if err != nil {
return nil, err
}
f, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, key)
}
if err != nil {
return nil, fmt.Errorf("blobstore: open %q: %w", key, err)
}
return f, nil
}
// Delete removes the stored file, ignoring a missing one.
func (l *LocalFS) Delete(ctx context.Context, key string) error {
path, err := l.resolve(key)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("blobstore: delete %q: %w", key, err)
}
return nil
}
// resolve maps a key to a path inside the root, rejecting any key that escapes
// it. Keys reach this code from upload filenames, so traversal has to fail
// closed rather than being normalised away.
func (l *LocalFS) resolve(key string) (string, error) {
if key == "" {
return "", errors.New("blobstore: empty key")
}
if filepath.IsAbs(key) {
return "", fmt.Errorf("blobstore: key %q must be relative", key)
}
clean := filepath.Clean("/" + key)
// filepath.Clean on an absolute path collapses any ".." segments.
rel := strings.TrimPrefix(clean, "/")
if rel == "" {
return "", fmt.Errorf("blobstore: key %q resolves to the root", key)
}
path := filepath.Join(l.root, rel)
// Defence in depth: confirm the result really is under the root.
if path != l.root && !strings.HasPrefix(path, l.root+string(os.PathSeparator)) {
return "", fmt.Errorf("blobstore: key %q escapes the store root", key)
}
return path, nil
}

View file

@ -0,0 +1,260 @@
// Package config loads runtime settings from the environment. Every setting has
// a working default so the service starts without configuration in development.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config is the fully resolved runtime configuration.
type Config struct {
// Addr is the listen address for the HTTP server.
Addr string
// DatabaseURL points at the SQLite file backing the store.
DatabaseURL string
// MediaDir is the root directory that uploaded pictures are written to.
MediaDir string
// PublicBaseURL is prefixed to stored media paths to build absolute URLs.
PublicBaseURL string
// AuthMode selects the token validator: "jwt" or "dev".
AuthMode string
// JWTSecret is the shared secret used to verify go-loose issued tokens.
JWTSecret string
// JWTAudience is the expected "aud" claim; empty disables the check.
JWTAudience string
// DefaultLinkRadiusMeters is how close two pictures must be to be linked.
DefaultLinkRadiusMeters float64
// MaxLinkRadiusMeters caps what a client may request per place.
MaxLinkRadiusMeters float64
// MaxUploadBytes is the largest accepted upload request.
MaxUploadBytes int64
// AllowedImageTypes are the content types accepted for pictures.
AllowedImageTypes []string
// AnalyzerMode is "local" for the built-in heuristics or "remote" to
// delegate to an external inference service.
AnalyzerMode string
// AnalyzerURL is the base URL of the external inference service.
AnalyzerURL string
// AnalyzerKey authenticates requests to the inference service.
AnalyzerKey string
// AnalyzerTimeout bounds a single analysis request.
AnalyzerTimeout time.Duration
// AMQPURL enables the event publisher when non-empty.
AMQPURL string
// EventsExchange is the exchange domain events are published to.
EventsExchange string
// EnableREST if true, adds REST endpoints alongside GraphQL.
EnableREST bool
}
// Default returns the configuration used when nothing is set.
func Default() Config {
return Config{
Addr: ":8080",
DatabaseURL: "file:go-places.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
MediaDir: "./data/media",
PublicBaseURL: "",
AuthMode: "dev",
JWTSecret: "",
JWTAudience: "",
DefaultLinkRadiusMeters: 150,
MaxLinkRadiusMeters: 5000,
MaxUploadBytes: 20 << 20,
AllowedImageTypes: []string{"image/jpeg", "image/png", "image/webp"},
AnalyzerMode: "local",
AnalyzerURL: "",
AnalyzerKey: "",
AnalyzerTimeout: 10 * time.Second,
AMQPURL: "",
EventsExchange: "go-places.events",
EnableREST: false,
}
}
// Load builds a Config from the environment on top of the defaults.
func Load() (Config, error) {
c := Default()
var err error
if c.Addr, err = envString("ADDR", c.Addr); err != nil {
return c, err
}
if c.DatabaseURL, err = envString("DATABASE_URL", c.DatabaseURL); err != nil {
return c, err
}
if c.MediaDir, err = envString("MEDIA_DIR", c.MediaDir); err != nil {
return c, err
}
if c.PublicBaseURL, err = envString("PUBLIC_BASE_URL", c.PublicBaseURL); err != nil {
return c, err
}
if c.AuthMode, err = envString("AUTH_MODE", c.AuthMode); err != nil {
return c, err
}
if c.JWTSecret, err = envString("JWT_SECRET", c.JWTSecret); err != nil {
return c, err
}
if c.JWTAudience, err = envString("JWT_AUDIENCE", c.JWTAudience); err != nil {
return c, err
}
if c.DefaultLinkRadiusMeters, err = envFloat("DEFAULT_LINK_RADIUS_METERS", c.DefaultLinkRadiusMeters); err != nil {
return c, err
}
if c.MaxLinkRadiusMeters, err = envFloat("MAX_LINK_RADIUS_METERS", c.MaxLinkRadiusMeters); err != nil {
return c, err
}
if c.MaxUploadBytes, err = envInt64("MAX_UPLOAD_BYTES", c.MaxUploadBytes); err != nil {
return c, err
}
if c.AnalyzerMode, err = envString("ANALYZER_MODE", c.AnalyzerMode); err != nil {
return c, err
}
if c.AnalyzerURL, err = envString("ANALYZER_URL", c.AnalyzerURL); err != nil {
return c, err
}
if c.AnalyzerKey, err = envString("ANALYZER_KEY", c.AnalyzerKey); err != nil {
return c, err
}
if an, err := envInt("ANALYZER_TIMEOUT_SECONDS", int(c.AnalyzerTimeout/time.Second)); err == nil {
c.AnalyzerTimeout = time.Duration(an) * time.Second
} else {
return c, err
}
if c.AMQPURL, err = envString("AMQP_URL", c.AMQPURL); err != nil {
return c, err
}
if c.EventsExchange, err = envString("EVENTS_EXCHANGE", c.EventsExchange); err != nil {
return c, err
}
if v := os.Getenv("ENABLE_REST"); v != "" {
c.EnableREST, err = strconv.ParseBool(v)
if err != nil {
return c, fmt.Errorf("config: ENABLE_REST must be true or false: %w", err)
}
}
if v := os.Getenv("ALLOWED_IMAGE_TYPES"); v != "" {
c.AllowedImageTypes = splitAndTrim(v)
}
if err := c.Validate(); err != nil {
return c, err
}
return c, nil
}
// Validate rejects configurations that cannot work, so misconfiguration is
// caught at startup instead of on the first request.
func (c Config) Validate() error {
switch c.AuthMode {
case "jwt":
if c.JWTSecret == "" {
return fmt.Errorf("config: AUTH_MODE=jwt requires JWT_SECRET to be set")
}
case "dev":
default:
return fmt.Errorf("config: AUTH_MODE must be %q or %q, got %q", "jwt", "dev", c.AuthMode)
}
switch c.AnalyzerMode {
case "local":
case "remote":
if c.AnalyzerURL == "" {
return fmt.Errorf("config: ANALYZER_MODE=remote requires ANALYZER_URL to be set")
}
default:
return fmt.Errorf("config: ANALYZER_MODE must be %q or %q, got %q", "local", "remote", c.AnalyzerMode)
}
if c.DefaultLinkRadiusMeters <= 0 {
return fmt.Errorf("config: DEFAULT_LINK_RADIUS_METERS must be positive")
}
if c.MaxLinkRadiusMeters < c.DefaultLinkRadiusMeters {
return fmt.Errorf("config: MAX_LINK_RADIUS_METERS must not be smaller than DEFAULT_LINK_RADIUS_METERS")
}
if c.MaxUploadBytes <= 0 {
return fmt.Errorf("config: MAX_UPLOAD_BYTES must be positive")
}
if len(c.AllowedImageTypes) == 0 {
return fmt.Errorf("config: at least one image content type must be allowed")
}
return nil
}
// AllowsContentType reports whether a content type may be uploaded.
func (c Config) AllowsContentType(contentType string) bool {
// Ignore any parameters such as "; charset=utf-8".
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = contentType[:i]
}
contentType = strings.ToLower(strings.TrimSpace(contentType))
for _, allowed := range c.AllowedImageTypes {
if allowed == contentType {
return true
}
}
return false
}
func envString(key, def string) (string, error) {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v, nil
}
return def, nil
}
func envInt(key string, def int) (int, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return def, fmt.Errorf("config: %s must be an integer: %w", key, err)
}
return n, nil
}
func envInt64(key string, def int64) (int64, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return def, fmt.Errorf("config: %s must be an integer: %w", key, err)
}
return n, nil
}
func envFloat(key string, def float64) (float64, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def, nil
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return def, fmt.Errorf("config: %s must be a number: %w", key, err)
}
return f, nil
}
func splitAndTrim(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

View file

@ -0,0 +1,166 @@
// Package domain contains the core entities of the application. It has no
// dependencies on transport, storage or database layers so that the business
// rules can be reasoned about in isolation.
package domain
import "time"
// User represents a logged-in user. Identity is owned by the external go-loose
// SSO service; this service only stores the identifier it asserts.
type User struct {
ID string `json:"id" gql:"id"`
Username string `json:"username" gql:"username"`
Email string `json:"email" gql:"email"`
}
// Coordinate is a WGS-84 geographic point.
type Coordinate struct {
Latitude float64 `json:"latitude" gql:"latitude"`
Longitude float64 `json:"longitude" gql:"longitude"`
}
// Valid reports whether the coordinate is a real point on Earth.
func (c Coordinate) Valid() bool {
if c.Latitude < -90 || c.Latitude > 90 {
return false
}
return c.Longitude >= -180 && c.Longitude <= 180
}
// AnalysisStatus tracks the progress of the image analysis pipeline. Analysis
// runs out of band, so a picture is visible to the API before its badges exist.
type AnalysisStatus string
const (
AnalysisPending AnalysisStatus = "pending"
AnalysisCompleted AnalysisStatus = "completed"
AnalysisFailed AnalysisStatus = "failed"
)
// Badge is a recognisable attribute detected in a picture, for example an
// object ("dog"), a scene ("beach") or a property ("low-light").
type Badge struct {
// Label is the stable machine-readable name, e.g. "dog" or "beach".
Label string `json:"label" gql:"label"`
// Display is a human-friendly rendering of the label, e.g. "Dog".
Display string `json:"display" gql:"display"`
// Confidence is a 0..1 score. Heuristic badges tend to be lower confidence
// than those returned by a trained model.
Confidence float64 `json:"confidence" gql:"confidence"`
// Source records which analyzer produced the badge, which lets the UI
// distinguish machine-derived facts from heuristics.
Source string `json:"source" gql:"source"`
}
// Image is a picture attached to a Place.
type Image struct {
ID string `json:"id" gql:"id"`
PlaceID string `json:"placeId" gql:"placeId"`
// StorageKey is the location of the bytes inside the blob store. It is not
// exposed to clients; URL is the public rendering of this key.
StorageKey string `json:"-" gql:"-"`
URL string `json:"url" gql:"url"`
Caption string `json:"caption" gql:"caption"`
ContentType string `json:"contentType" gql:"contentType"`
ByteSize int64 `json:"byteSize" gql:"byteSize"`
Width int `json:"width" gql:"width"`
Height int `json:"height" gql:"height"`
// Coordinate is where the picture was taken. It is taken from the EXIF GPS
// tag when present, and falls back to the parent Place otherwise.
Coordinate *Coordinate `json:"coordinate" gql:"coordinate"`
// TakenAt is when the picture was captured, from the EXIF timestamp.
TakenAt *time.Time `json:"takenAt" gql:"takenAt"`
// CameraMake and CameraModel come from EXIF and identify the capture device.
CameraMake string `json:"cameraMake" gql:"cameraMake"`
CameraModel string `json:"cameraModel" gql:"cameraModel"`
Badges []Badge `json:"badges" gql:"badges"`
AnalysisStatus AnalysisStatus `json:"analysisStatus" gql:"analysisStatus"`
AnalysisError string `json:"analysisError" gql:"analysisError"`
// LinkedImages are other pictures taken at the same location, resolved by
// the linking pass rather than stored.
LinkedImages []Image `json:"linkedImages" gql:"linkedImages"`
CreatedAt time.Time `json:"createdAt" gql:"createdAt"`
}
// Place is a location the user visited and shared. Pictures and a free-text
// description hang off it, and it is the anchor for geographic linking.
type Place struct {
ID string `json:"id" gql:"id"`
Title string `json:"title" gql:"title"`
Description string `json:"description" gql:"description"`
// Coordinate is the location of the place. Required for linking to work;
// when a picture carries its own EXIF GPS tag that value takes precedence.
Coordinate *Coordinate `json:"coordinate" gql:"coordinate"`
// LinkRadiusMeters is how close two pictures must be to count as having
// been taken at the same place.
LinkRadiusMeters float64 `json:"linkRadiusMeters" gql:"linkRadiusMeters"`
Creator User `json:"creator" gql:"creator"`
Images []Image `json:"images" gql:"images"`
Comments []Comment `json:"comments" gql:"comments"`
// RelatedPlaces are other Places whose pictures overlap this one.
RelatedPlaces []RelatedPlace `json:"relatedPlaces" gql:"relatedPlaces"`
CreatedAt time.Time `json:"createdAt" gql:"createdAt"`
}
// RelatedPlace records the link between two Places discovered by the linking
// pass, along with the evidence for it.
type RelatedPlace struct {
Place Place `json:"place" gql:"place"`
DistanceMeters float64 `json:"distanceMeters" gql:"distanceMeters"`
SharedImageIDs []string `json:"sharedImageIds" gql:"sharedImageIds"`
}
// Link is a single edge produced by the linking pass, connecting one picture to
// another that was taken at the same place.
type Link struct {
ImageID string `json:"imageId" gql:"imageId"`
RelatedImageID string `json:"relatedImageId" gql:"relatedImageId"`
// DistanceMeters is the great-circle distance between the two capture points.
DistanceMeters float64 `json:"distanceMeters" gql:"distanceMeters"`
// SamePlace is true when the two pictures belong to the same Place record.
SamePlace bool `json:"samePlace" gql:"samePlace"`
// Reason explains the evidence, e.g. "same-place" or "within-radius".
Reason string `json:"reason" gql:"reason"`
}
// Comment is a user reaction on a Place.
type Comment struct {
ID string `json:"id" gql:"id"`
PlaceID string `json:"placeId" gql:"placeId"`
Content string `json:"content" gql:"content"`
Creator User `json:"creator" gql:"creator"`
CreatedAt time.Time `json:"createdAt" gql:"createdAt"`
}
// PlaceInput is the payload for creating a Place. Latitude and Longitude are
// pointers so that an omitted coordinate can be told apart from the origin,
// which is a legitimate coordinate.
type PlaceInput struct {
Title string `json:"title" gql:"title"`
Description string `json:"description" gql:"description"`
Latitude *float64 `json:"latitude" gql:"latitude"`
Longitude *float64 `json:"longitude" gql:"longitude"`
LinkRadiusMeters *float64 `json:"linkRadiusMeters" gql:"linkRadiusMeters"`
// Caption is the description of the first picture in the upload, if any.
Caption string `json:"caption" gql:"caption"`
}
// Coordinate builds a Coordinate from the optional latitude and longitude,
// returning nil when they are absent or do not form a valid point.
func (p PlaceInput) Coordinate() *Coordinate {
if p.Latitude == nil || p.Longitude == nil {
return nil
}
c := Coordinate{Latitude: *p.Latitude, Longitude: *p.Longitude}
if !c.Valid() {
return nil
}
return &c
}

View file

@ -0,0 +1,192 @@
// Package events publishes domain events so other services can react to them
// without being called synchronously.
package events
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// Topic names the routing key an event is published under.
type Topic string
const (
// TopicPlaceCreated fires once a place exists, before its pictures are
// attached.
TopicPlaceCreated Topic = "place.created"
// TopicImageAnalyzed fires when a picture's badges are available.
TopicImageAnalyzed Topic = "image.analyzed"
// TopicImageLinked fires when a picture is linked to another taken at the
// same place.
TopicImageLinked Topic = "image.linked"
// TopicCommentAdded fires when a comment is posted.
TopicCommentAdded Topic = "comment.added"
)
// Event is the envelope every published message shares. Payload stays raw so
// consumers can evolve their own schema without the publisher changing.
type Event struct {
// ID uniquely identifies this event, letting consumers deduplicate.
ID string `json:"id"`
// Topic is the routing key.
Topic Topic `json:"topic"`
// OccurredAt is when the event was produced.
OccurredAt time.Time `json:"occurredAt"`
// ActorID is the user who caused it, empty for system events.
ActorID string `json:"actorId,omitempty"`
// Payload is topic-specific JSON.
Payload json.RawMessage `json:"payload"`
}
// Publisher sends events. Implementations must not block the request path for
// long; a slow broker should not stall an upload.
type Publisher interface {
// Publish sends one event. Implementations should treat delivery as
// best-effort and report failure to the caller rather than panicking.
Publish(ctx context.Context, ev Event) error
// Close releases resources.
Close() error
}
// LogPublisher writes events to the structured log. It is the default so the
// service runs without a broker, and it keeps the event stream inspectable in
// development.
type LogPublisher struct {
logger *slog.Logger
}
var _ Publisher = (*LogPublisher)(nil)
// NewLogPublisher returns a publisher that logs events.
func NewLogPublisher(logger *slog.Logger) *LogPublisher {
if logger == nil {
logger = slog.Default()
}
return &LogPublisher{logger: logger}
}
// Publish implements Publisher.
func (p *LogPublisher) Publish(ctx context.Context, ev Event) error {
p.logger.InfoContext(ctx, "event published",
"topic", string(ev.Topic),
"eventId", ev.ID,
"actorId", ev.ActorID,
"payload", string(ev.Payload),
)
return nil
}
// Close implements Publisher.
func (p *LogPublisher) Close() error { return nil }
// AMQPPublisher publishes to a RabbitMQ topic exchange.
type AMQPPublisher struct {
conn *amqp.Connection
channel *amqp.Channel
exchange string
logger *slog.Logger
// confirms receives publisher acknowledgements, so a broker-side rejection
// is noticed instead of being silently dropped.
confirms <-chan amqp.Confirmation
}
var _ Publisher = (*AMQPPublisher)(nil)
// NewAMQP dials the broker, declares the exchange and enables confirms.
func NewAMQP(url, exchange string, logger *slog.Logger) (*AMQPPublisher, error) {
if logger == nil {
logger = slog.Default()
}
conn, err := amqp.Dial(url)
if err != nil {
return nil, fmt.Errorf("events: dial amqp: %w", err)
}
channel, err := conn.Channel()
if err != nil {
conn.Close()
return nil, fmt.Errorf("events: open channel: %w", err)
}
// A topic exchange lets consumers bind to "image.*" or "place.created".
if err := channel.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil {
channel.Close()
conn.Close()
return nil, fmt.Errorf("events: declare exchange %q: %w", exchange, err)
}
if err := channel.Confirm(false); err != nil {
channel.Close()
conn.Close()
return nil, fmt.Errorf("events: enable confirms: %w", err)
}
return &AMQPPublisher{
conn: conn,
channel: channel,
exchange: exchange,
logger: logger,
confirms: channel.NotifyPublish(make(chan amqp.Confirmation, 1)),
}, nil
}
// Publish implements Publisher. It waits briefly for the broker's
// acknowledgement so that a failed publish is reported to the caller.
func (p *AMQPPublisher) Publish(ctx context.Context, ev Event) error {
body, err := json.Marshal(ev)
if err != nil {
return fmt.Errorf("events: marshal %s: %w", ev.Topic, err)
}
// Delivery mode 2 marks the message persistent.
pub := amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
MessageId: ev.ID,
Timestamp: ev.OccurredAt,
Type: string(ev.Topic),
Body: body,
}
if err := p.channel.PublishWithContext(ctx, p.exchange, string(ev.Topic), false, false, pub); err != nil {
return fmt.Errorf("events: publish %s: %w", ev.Topic, err)
}
select {
case confirm, ok := <-p.confirms:
if !ok {
return fmt.Errorf("events: confirmation channel closed for %s", ev.Topic)
}
if !confirm.Ack {
return fmt.Errorf("events: broker rejected %s", ev.Topic)
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-time.After(confirmTimeout):
return fmt.Errorf("events: no confirmation for %s within %s", ev.Topic, confirmTimeout)
}
}
// confirmTimeout bounds the wait for a broker acknowledgement.
const confirmTimeout = 5 * time.Second
// Close implements Publisher.
func (p *AMQPPublisher) Close() error {
var firstErr error
if p.channel != nil {
if err := p.channel.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if p.conn != nil {
if err := p.conn.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}

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

@ -0,0 +1,92 @@
// Package geo provides the geographic distance calculations used to decide
// whether two pictures were taken at the same place.
package geo
import (
"errors"
"math"
"go-places-api/internal/domain"
)
// EarthRadiusMeters is the mean radius of the WGS-84 ellipsoid.
const EarthRadiusMeters = 6371008.8
var ErrInvalidCoordinate = errors.New("invalid coordinate")
// DistanceMeters returns the great-circle distance between two points using the
// haversine formula. It is accurate to roughly 0.5% which is far finer than the
// radii this application compares against.
func DistanceMeters(a, b domain.Coordinate) float64 {
lat1 := degreesToRadians(a.Latitude)
lat2 := degreesToRadians(b.Latitude)
dLat := lat2 - lat1
dLon := degreesToRadians(b.Longitude - a.Longitude)
sinLat := math.Sin(dLat / 2)
sinLon := math.Sin(dLon / 2)
h := sinLat*sinLat + math.Cos(lat1)*math.Cos(lat2)*sinLon*sinLon
return 2 * EarthRadiusMeters * math.Asin(math.Sqrt(math.Min(1, h)))
}
// WithinRadius reports whether two points are no further apart than radiusMeters.
func WithinRadius(a, b domain.Coordinate, radiusMeters float64) bool {
return DistanceMeters(a, b) <= radiusMeters
}
// BoundingBox is a coarse lat/lon window that fully contains a circle of the
// given radius around a centre. It exists to let the store pre-filter rows in
// SQL before the exact haversine test is applied.
type BoundingBox struct {
MinLat float64
MaxLat float64
MinLon float64
MaxLon float64
}
// Around returns the bounding box of a circle of radiusMeters around center.
func Around(center domain.Coordinate, radiusMeters float64) BoundingBox {
// Latitude degrees are a constant distance apart, so the latitude delta is
// a simple division. Longitude degrees shrink towards the poles, so the
// delta is scaled by 1/cos(latitude) and clamped near the poles.
latDelta := radiusMeters / EarthRadiusMeters * 180 / math.Pi
cos := math.Cos(degreesToRadians(center.Latitude))
var lonDelta float64
if math.Abs(cos) > 1e-9 {
lonDelta = latDelta / math.Abs(cos)
} else {
// At the poles every longitude is the same meridian; use everything.
lonDelta = 180
}
// Clamp so the box stays within valid longitude range.
if lonDelta > 180 {
lonDelta = 180
}
return BoundingBox{
MinLat: clamp(center.Latitude-latDelta, -90, 90),
MaxLat: clamp(center.Latitude+latDelta, -90, 90),
MinLon: clamp(center.Longitude-lonDelta, -180, 180),
MaxLon: clamp(center.Longitude+lonDelta, -180, 180),
}
}
// Contains reports whether the point falls inside the box.
func (b BoundingBox) Contains(c domain.Coordinate) bool {
return c.Latitude >= b.MinLat && c.Latitude <= b.MaxLat &&
c.Longitude >= b.MinLon && c.Longitude <= b.MaxLon
}
// Round rounds a distance to the nearest metre so that JSON output and equality
// assertions are stable.
func Round(meters float64) float64 {
return math.Round(meters)
}
func degreesToRadians(deg float64) float64 { return deg * math.Pi / 180 }
func clamp(v, lo, hi float64) float64 {
return math.Max(lo, math.Min(hi, v))
}

View file

@ -0,0 +1,219 @@
package geo
import (
"math"
"testing"
"go-places-api/internal/domain"
)
// TestDistanceKnownSeparations checks the haversine result against separations
// whose expected value is well established.
func TestDistanceKnownSeparations(t *testing.T) {
t.Parallel()
cases := []struct {
name string
a, b domain.Coordinate
wantMetres float64
tolerance float64
}{
{
// One degree of latitude is about 111.2 km anywhere.
name: "one degree of latitude",
a: domain.Coordinate{Latitude: 0, Longitude: 0},
b: domain.Coordinate{Latitude: 1, Longitude: 0},
wantMetres: 111195,
tolerance: 100,
},
{
// A degree of longitude is the same at the equator.
name: "one degree of longitude at the equator",
a: domain.Coordinate{Latitude: 0, Longitude: 0},
b: domain.Coordinate{Latitude: 0, Longitude: 1},
wantMetres: 111195,
tolerance: 100,
},
{
// Paris to London, roughly 344 km.
name: "Paris to London",
a: domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522},
b: domain.Coordinate{Latitude: 51.5074, Longitude: -0.1278},
wantMetres: 343500,
tolerance: 3000,
},
{
// A short walk: the same spot is zero distance away.
name: "identical points",
a: domain.Coordinate{Latitude: 45.0, Longitude: 9.0},
b: domain.Coordinate{Latitude: 45.0, Longitude: 9.0},
wantMetres: 0,
tolerance: 0.001,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := DistanceMeters(tc.a, tc.b)
if math.Abs(got-tc.wantMetres) > tc.tolerance {
t.Errorf("DistanceMeters = %.1f m, want %.1f m (±%.1f)",
got, tc.wantMetres, tc.tolerance)
}
})
}
}
// TestDistanceIsSymmetric guards against an asymmetry bug in the formula.
func TestDistanceIsSymmetric(t *testing.T) {
t.Parallel()
a := domain.Coordinate{Latitude: 52.5200, Longitude: 13.4050}
b := domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522}
forward := DistanceMeters(a, b)
backward := DistanceMeters(b, a)
if math.Abs(forward-backward) > 1e-6 {
t.Errorf("distance is not symmetric: %.6f vs %.6f", forward, backward)
}
}
// TestLongitudeDegreeShrinksTowardsPoles confirms the latitude dependency that
// the bounding box calculation relies on.
func TestLongitudeDegreeShrinksTowardsPoles(t *testing.T) {
t.Parallel()
atEquator := DistanceMeters(
domain.Coordinate{Latitude: 0, Longitude: 0},
domain.Coordinate{Latitude: 0, Longitude: 1},
)
atSixty := DistanceMeters(
domain.Coordinate{Latitude: 60, Longitude: 0},
domain.Coordinate{Latitude: 60, Longitude: 1},
)
// A degree of longitude at 60 degrees is half the equatorial distance.
if ratio := atSixty / atEquator; math.Abs(ratio-0.5) > 0.01 {
t.Errorf("longitude degree at 60 lat = %.3f of equatorial, want 0.500", ratio)
}
}
func TestWithinRadius(t *testing.T) {
t.Parallel()
// Two points about 22 m apart.
origin := domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}
// 0.0002 degrees of latitude is roughly 22 m.
nearby := domain.Coordinate{Latitude: 48.8586, Longitude: 2.2945}
far := domain.Coordinate{Latitude: 48.8684, Longitude: 2.2945}
if !WithinRadius(origin, nearby, 50) {
t.Errorf("expected points ~22 m apart to be within 50 m, got %.1f m",
DistanceMeters(origin, nearby))
}
if WithinRadius(origin, far, 50) {
t.Errorf("expected points ~1112 m apart to be outside 50 m")
}
// The boundary itself is inclusive.
if !WithinRadius(origin, nearby, DistanceMeters(origin, nearby)) {
t.Errorf("expected the radius boundary to be inclusive")
}
}
// TestBoundingBoxContainsCircle is the property the SQL pre-filter relies on:
// every point within the radius must fall inside the box, so the box is only
// ever a superset and never loses a candidate.
func TestBoundingBoxContainsCircle(t *testing.T) {
t.Parallel()
centres := []domain.Coordinate{
{Latitude: 0, Longitude: 0},
{Latitude: 48.8566, Longitude: 2.3522},
{Latitude: -33.8688, Longitude: 151.2093},
{Latitude: 64.1466, Longitude: -21.9426},
}
radii := []float64{50, 150, 5000}
for _, c := range centres {
for _, radius := range radii {
box := Around(c, radius)
for _, bearing := range []float64{0, 45, 90, 135, 180, 225, 270, 315} {
p := offset(c, bearing, radius)
if !box.Contains(p) {
t.Errorf("box around %+v r=%v excludes point at bearing %v (%+v): %+v",
c, radius, bearing, p, box)
}
}
}
}
}
// TestBoundingBoxHandlesPoles ensures a circle at the pole does not divide by
// the vanishing cosine of the latitude.
func TestBoundingBoxHandlesPoles(t *testing.T) {
t.Parallel()
box := Around(domain.Coordinate{Latitude: 90, Longitude: 0}, 1000)
if box.MinLon != -180 || box.MaxLon != 180 {
t.Errorf("at the pole the box should span all longitudes, got lon %v..%v",
box.MinLon, box.MaxLon)
}
if box.MaxLat > 90 || box.MinLat < -90 {
t.Errorf("latitude bounds must stay in range, got %v..%v", box.MinLat, box.MaxLat)
}
}
func TestRound(t *testing.T) {
t.Parallel()
cases := []struct {
in, want float64
}{
{0, 0},
{0.4, 0},
{0.5, 1},
{1.4, 1},
{1.5, 2},
{123.456, 123},
{-0.6, -1},
}
for _, tc := range cases {
if got := Round(tc.in); got != tc.want {
t.Errorf("Round(%v) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestCoordinateValid(t *testing.T) {
t.Parallel()
cases := []struct {
coord domain.Coordinate
want bool
}{
{domain.Coordinate{Latitude: 0, Longitude: 0}, true},
{domain.Coordinate{Latitude: 48.85, Longitude: 2.35}, true},
{domain.Coordinate{Latitude: 90, Longitude: 180}, true},
{domain.Coordinate{Latitude: -90, Longitude: -180}, true},
{domain.Coordinate{Latitude: 90.1, Longitude: 0}, false},
{domain.Coordinate{Latitude: -90.1, Longitude: 0}, false},
{domain.Coordinate{Latitude: 0, Longitude: 180.1}, false},
{domain.Coordinate{Latitude: 0, Longitude: -180.1}, false},
}
for _, tc := range cases {
if got := tc.coord.Valid(); got != tc.want {
t.Errorf("%+v.Valid() = %v, want %v", tc.coord, got, tc.want)
}
}
}
// offset moves a point by bearing degrees and distance metres using a simple
// equirectangular approximation, which is accurate enough over the small
// distances these tests use.
func offset(origin domain.Coordinate, bearingDeg, metres float64) domain.Coordinate {
const degToRad = math.Pi / 180
lat := origin.Latitude + (metres*math.Cos(bearingDeg*degToRad))/EarthRadiusMeters*180/math.Pi
lon := origin.Longitude + (metres*math.Sin(bearingDeg*degToRad))/
(EarthRadiusMeters*math.Cos(origin.Latitude*degToRad))*180/math.Pi
return domain.Coordinate{Latitude: lat, Longitude: lon}
}

View file

@ -0,0 +1,29 @@
package service
import (
"encoding/json"
"fmt"
"sort"
"go-places-api/internal/domain"
)
// marshalPayload encodes an event payload as JSON.
func marshalPayload(payload map[string]any) (json.RawMessage, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("service: marshal event payload: %w", err)
}
return json.RawMessage(body), nil
}
// sortRelated orders related places by distance, then by identifier so the
// result is stable for equal distances.
func sortRelated(in []domain.RelatedPlace) {
sort.SliceStable(in, func(i, j int) bool {
if in[i].DistanceMeters != in[j].DistanceMeters {
return in[i].DistanceMeters < in[j].DistanceMeters
}
return in[i].Place.ID < in[j].Place.ID
})
}

View file

@ -0,0 +1,357 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/google/uuid"
"go-places-api/internal/analysis"
"go-places-api/internal/blobstore"
"go-places-api/internal/domain"
"go-places-api/internal/events"
)
// ImageUpload is one picture submitted by a client.
type ImageUpload struct {
// Filename is the client-supplied name, used only to pick a storage
// extension. It never becomes part of the storage path.
Filename string
// ContentType is the declared MIME type.
ContentType string
// Caption is the description the user attached to this picture.
Caption string
// Body streams the encoded picture.
Body io.Reader
// Coordinate overrides the location. When nil the EXIF GPS tag is used,
// and failing that the place's own coordinate applies.
Coordinate *domain.Coordinate
}
// UploadResult reports what happened to one picture when a batch is submitted.
// Pictures are processed independently so one bad file does not discard the
// good ones.
type UploadResult struct {
Image domain.Image
// AnalysisNote explains a degraded analysis, if any.
AnalysisNote string
// Err is the reason this picture was rejected.
Err error
}
// AddImage stores one picture against a place, analyses it to produce badges,
// and links it to other pictures taken at the same place.
//
// The picture is analysed before its bytes are committed: if the payload is not
// a decodable image there is no reason to keep it. Analysis failures are
// recorded on the record rather than discarding an otherwise valid upload,
// because the picture is the valuable part and the badges can be recomputed
// with Reanalyze.
func (s *Service) AddImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, error) {
img, _, err := s.addImage(ctx, author, placeID, up)
return img, err
}
// UploadPictures attaches several pictures to a place in one call. Each picture
// is independent, so failures are reported per picture instead of failing the
// whole batch.
func (s *Service) UploadPictures(ctx context.Context, author domain.User, placeID string, uploads []ImageUpload) ([]UploadResult, error) {
if strings.TrimSpace(placeID) == "" {
return nil, fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if len(uploads) == 0 {
return nil, fmt.Errorf("%w: no pictures were supplied", ErrInvalidInput)
}
if author.ID == "" {
return nil, fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput)
}
results := make([]UploadResult, 0, len(uploads))
for _, up := range uploads {
img, note, err := s.addImage(ctx, author, placeID, up)
results = append(results, UploadResult{Image: img, AnalysisNote: note, Err: err})
if err != nil {
s.logger.Warn("rejected upload", "placeId", placeID, "filename", up.Filename, "error", err)
}
}
return results, nil
}
// addImage is the shared implementation behind AddImage and UploadPictures.
func (s *Service) addImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, string, error) {
if strings.TrimSpace(placeID) == "" {
return domain.Image{}, "", fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if author.ID == "" {
return domain.Image{}, "", fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput)
}
if up.Body == nil {
return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput)
}
if !s.cfg.AllowsContentType(up.ContentType) {
return domain.Image{}, "", fmt.Errorf("%w: %q is not an accepted image type",
ErrUnsupportedType, up.ContentType)
}
// Buffer one byte past the limit so an oversized payload is detectable
// without trusting the client's Content-Length.
var buf bytes.Buffer
read, err := io.Copy(&buf, io.LimitReader(up.Body, s.cfg.MaxUploadBytes+1))
if err != nil {
return domain.Image{}, "", fmt.Errorf("%w: the picture could not be read: %v", ErrInvalidInput, err)
}
if read == 0 {
return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput)
}
if read > s.cfg.MaxUploadBytes {
return domain.Image{}, "", fmt.Errorf("%w: the picture exceeds the %d byte limit",
ErrTooLarge, s.cfg.MaxUploadBytes)
}
data := buf.Bytes()
// Analysing first means an undecodable payload never reaches the store.
result, err := s.analyze.Analyze(ctx, analysis.Input{
ContentType: up.ContentType,
Bytes: data,
})
if err != nil {
return domain.Image{}, "", fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
place, err := s.store.GetPlace(ctx, placeID)
if err != nil {
return domain.Image{}, "", mapStoreError(err, "place")
}
if err := s.store.UpsertUser(ctx, author); err != nil {
return domain.Image{}, "", mapStoreError(err, "user")
}
ext := extensionFor(up.Filename, up.ContentType)
key := path.Join(author.ID, time.Now().UTC().Format("2006/01"), uuid.NewString()+ext)
written, err := s.blobs.Put(ctx, key, bytes.NewReader(data), s.cfg.MaxUploadBytes)
if err != nil {
if isTooLarge(err) {
return domain.Image{}, "", fmt.Errorf("%w: %v", ErrTooLarge, err)
}
return domain.Image{}, "", fmt.Errorf("service: store picture: %w", err)
}
// Precedence for a picture's location: an explicit override, then the EXIF
// GPS tag, then the place's own coordinate.
coordinate := effectiveCoordinate(domain.Image{Coordinate: up.Coordinate}, place.Coordinate)
if up.Coordinate == nil && result.Metadata.Coordinate != nil {
coordinate = result.Metadata.Coordinate
}
img := domain.Image{
PlaceID: place.ID,
StorageKey: key,
Caption: strings.TrimSpace(up.Caption),
ContentType: up.ContentType,
ByteSize: written,
Width: result.Width,
Height: result.Height,
Coordinate: coordinate,
TakenAt: result.Metadata.TakenAt,
CameraMake: result.Metadata.Make,
CameraModel: result.Metadata.Model,
Badges: result.Badges,
AnalysisStatus: domain.AnalysisCompleted,
AnalysisError: result.Note,
}
saved, err := s.store.CreateImage(ctx, img)
if err != nil {
// Never leave an orphaned blob behind a failed insert.
if delErr := s.blobs.Delete(ctx, key); delErr != nil {
s.logger.Warn("failed to roll back image blob", "key", key, "error", delErr)
}
return domain.Image{}, "", mapStoreError(err, "image")
}
saved.URL = s.ImageURL(saved.StorageKey)
s.publish(ctx, author, events.TopicImageAnalyzed, map[string]any{
"imageId": saved.ID,
"placeId": place.ID,
"badges": badgeLabels(saved.Badges),
"degraded": result.Note != "",
})
// Linking is separate from accepting the upload, so a failure here is logged
// rather than reported as an upload error.
if err := s.linkAfterUpload(ctx, saved); err != nil {
s.logger.Warn("failed to link new picture", "imageId", saved.ID, "error", err)
}
return saved, result.Note, nil
}
// Reanalyze runs the analysis pipeline again over a stored picture. This is how
// badges are recovered after a failure or refreshed after the analyzer changes.
func (s *Service) Reanalyze(ctx context.Context, imageID string) (domain.Image, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
if img.StorageKey == "" {
return domain.Image{}, fmt.Errorf("%w: the picture has no stored bytes", ErrInvalidInput)
}
rc, err := s.blobs.Get(ctx, img.StorageKey)
if err != nil {
return domain.Image{}, fmt.Errorf("service: read stored picture: %w", err)
}
defer rc.Close()
// Bound the read by the configured limit even though the blob was written
// under it, so a corrupted store cannot exhaust memory.
var buf bytes.Buffer
if _, err := io.Copy(&buf, io.LimitReader(rc, s.cfg.MaxUploadBytes)); err != nil {
return domain.Image{}, fmt.Errorf("service: buffer stored picture: %w", err)
}
result, err := s.analyze.Analyze(ctx, analysis.Input{
ContentType: img.ContentType,
Bytes: buf.Bytes(),
})
if err != nil {
if setErr := s.store.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, err.Error()); setErr != nil {
s.logger.Warn("failed to record analysis failure", "imageId", img.ID, "error", setErr)
}
return domain.Image{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if err := s.store.ReplaceBadges(ctx, img.ID, result.Badges); err != nil {
return domain.Image{}, mapStoreError(err, "badge")
}
status := domain.AnalysisCompleted
if result.Note != "" {
// Understood, but only partially, which is worth recording so the UI
// can say the badge set may be incomplete.
status = domain.AnalysisFailed
}
if err := s.store.SetAnalysisState(ctx, img.ID, status, result.Note); err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
updated, err := s.store.GetImage(ctx, img.ID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
updated.URL = s.ImageURL(updated.StorageKey)
return updated, nil
}
// OpenImage returns a reader over a picture's stored bytes for the media route.
func (s *Service) OpenImage(ctx context.Context, imageID string) (domain.Image, io.ReadCloser, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, nil, mapStoreError(err, "image")
}
rc, err := s.blobs.Get(ctx, img.StorageKey)
if err != nil {
return domain.Image{}, nil, fmt.Errorf("service: open picture: %w", err)
}
return img, rc, nil
}
// ImageByID loads one picture, with its public URL filled in.
func (s *Service) ImageByID(ctx context.Context, id string) (domain.Image, error) {
img, err := s.store.GetImage(ctx, id)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.URL = s.ImageURL(img.StorageKey)
return img, nil
}
// ImageByStorageKey looks a picture up by its storage key, which is what the
// media route uses to recover the declared content type and size.
func (s *Service) ImageByStorageKey(ctx context.Context, key string) (domain.Image, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.URL = s.ImageURL(img.StorageKey)
return img, nil
}
// ContentTypeByStorageKey returns the content type a picture was stored as, or
// an empty string when the key is unknown.
func (s *Service) ContentTypeByStorageKey(ctx context.Context, key string) (string, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return "", mapStoreError(err, "image")
}
return img.ContentType, nil
}
// ImageSize returns the stored byte size for a storage key, or zero when the
// key is unknown.
func (s *Service) ImageSize(ctx context.Context, key string) (int64, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return 0, mapStoreError(err, "image")
}
return img.ByteSize, nil
}
// extensionFor picks a storage extension from the filename, falling back to one
// implied by the content type.
func extensionFor(filename, contentType string) string {
if ext := strings.ToLower(path.Ext(filename)); validExtension(ext) {
return ext
}
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/gif":
return ".gif"
default:
return ".img"
}
}
// validExtension keeps a client-supplied extension from becoming a path
// fragment or an unexpected suffix.
func validExtension(ext string) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
return true
default:
return false
}
}
// badgeLabels reduces a badge set to a distinct list of labels for the event
// payload. The local and remote analyzers can each contribute a label for the
// same subject, so duplicates are collapsed rather than published twice.
func badgeLabels(badges []domain.Badge) []string {
out := make([]string, 0, len(badges))
seen := make(map[string]struct{}, len(badges))
for _, b := range badges {
label := strings.TrimSpace(b.Label)
if label == "" {
continue
}
if _, dup := seen[label]; dup {
continue
}
seen[label] = struct{}{}
out = append(out, label)
}
return out
}
func isTooLarge(err error) bool {
return err != nil && strings.Contains(err.Error(), blobstore.ErrTooLarge.Error())
}

View file

@ -0,0 +1,286 @@
package service
import (
"context"
"sort"
"go-places-api/internal/domain"
"go-places-api/internal/events"
"go-places-api/internal/geo"
"go-places-api/internal/store"
)
// maxLinkedImagesPerImage bounds how many links a single picture is given, so
// a dense cluster of pictures taken from the same spot does not produce a
// quadratic blow-up in the response.
const maxLinkedImagesPerImage = 50
// LinkReasons explains why two pictures were linked.
const (
// ReasonSamePlace means both pictures belong to the same place record.
ReasonSamePlace = "same-place"
// ReasonWithinRadius means the pictures were taken within the place's
// linking radius of each other, even though they belong to different
// place records. This is what catches the same spot catalogued twice.
ReasonWithinRadius = "within-radius"
)
// linkAfterUpload links a freshly stored picture to the others taken at the
// same place. Failures are the caller's to log rather than surface, because the
// picture itself is already safely stored.
func (s *Service) linkAfterUpload(ctx context.Context, img domain.Image) error {
place, err := s.store.GetPlace(ctx, img.PlaceID)
if err != nil {
return mapStoreError(err, "place")
}
center := effectiveCoordinate(img, place.Coordinate)
if center == nil {
// Without a location there is nothing to compare against. This is
// common and not an error.
return nil
}
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return mapStoreError(err, "image")
}
links := LinksFor(img, candidates, place, radius)
if len(links) == 0 {
return nil
}
// Persist only the cross-place relationships, since a picture inside its own
// place is already known from the place record. Storing the evidence makes
// the link discoverable without re-running the spatial query.
for _, link := range links {
if link.SamePlace {
continue
}
if err := s.store.CreateImageLink(ctx, img.ID, link.RelatedImageID, link.DistanceMeters, link.Reason); err != nil {
s.logger.Warn("failed to record image link",
"imageId", img.ID, "relatedImageId", link.RelatedImageID, "error", err)
}
}
s.publish(ctx, domain.User{}, events.TopicImageLinked, map[string]any{
"imageId": img.ID,
"placeId": img.PlaceID,
"links": len(links),
"distance": nearestDistance(links),
})
return nil
}
// LinksFor computes the links for one picture against a candidate set. It is
// exported through the service so the GraphQL layer can reuse exactly the rules
// the upload path applies.
//
// A link is created when the two pictures are within radius. Pictures in the
// same place are linked unconditionally, since sharing a place record is itself
// the evidence; pictures in different places are linked only when their capture
// points are genuinely close.
func LinksFor(img domain.Image, candidates []domain.Image, place domain.Place, radius float64) []domain.Link {
if radius <= 0 {
return nil
}
center := effectiveCoordinate(img, place.Coordinate)
if center == nil {
return nil
}
var links []domain.Link
for _, other := range candidates {
if other.ID == img.ID {
continue
}
samePlace := other.PlaceID == img.PlaceID
if samePlace {
links = append(links, domain.Link{
ImageID: img.ID,
RelatedImageID: other.ID,
DistanceMeters: 0,
SamePlace: true,
Reason: ReasonSamePlace,
})
continue
}
otherCenter := effectiveCoordinate(other, nil)
if otherCenter == nil {
continue
}
distance := geo.DistanceMeters(*center, *otherCenter)
if distance > radius {
continue
}
links = append(links, domain.Link{
ImageID: img.ID,
RelatedImageID: other.ID,
DistanceMeters: geo.Round(distance),
SamePlace: false,
Reason: ReasonWithinRadius,
})
}
// Nearest first, then by identifier so the ordering is stable.
sort.SliceStable(links, func(i, j int) bool {
if links[i].DistanceMeters != links[j].DistanceMeters {
return links[i].DistanceMeters < links[j].DistanceMeters
}
return links[i].RelatedImageID < links[j].RelatedImageID
})
if len(links) > maxLinkedImagesPerImage {
links = links[:maxLinkedImagesPerImage]
}
return links
}
// LinkedImages returns the other pictures taken at the same place as img.
//
// Pictures within the same place record are always included. Pictures from
// other places are included when their recorded capture point lies inside the
// radius, which is what surfaces pictures catalogued under separate places at
// the same coordinates.
func (s *Service) LinkedImages(ctx context.Context, img domain.Image) ([]domain.Image, error) {
place, err := s.store.GetPlace(ctx, img.PlaceID)
if err != nil {
return nil, mapStoreError(err, "place")
}
center := effectiveCoordinate(img, place.Coordinate)
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
if center == nil || radius <= 0 {
// Without a coordinate there is nothing to search with, but the
// pictures sharing this place are still linked by definition.
return siblingsOnly(img, place)
}
seen := map[string]bool{img.ID: true}
out := make([]domain.Image, 0, 8)
for _, sibling := range place.Images {
if seen[sibling.ID] {
continue
}
seen[sibling.ID] = true
out = append(out, decorate(sibling, s.ImageURL(sibling.StorageKey)))
}
candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return nil, mapStoreError(err, "image")
}
for _, other := range candidates {
if seen[other.ID] {
continue
}
otherCenter := effectiveCoordinate(other, nil)
if otherCenter == nil || !geo.WithinRadius(*center, *otherCenter, radius) {
continue
}
seen[other.ID] = true
out = append(out, decorate(other, s.ImageURL(other.StorageKey)))
if len(out) >= maxLinkedImagesPerImage {
break
}
}
return out, nil
}
// siblingsOnly returns the other pictures in the same place, used when no
// coordinate is available to search with.
func siblingsOnly(img domain.Image, place domain.Place) ([]domain.Image, error) {
out := make([]domain.Image, 0, len(place.Images))
for _, sibling := range place.Images {
if sibling.ID == img.ID {
continue
}
out = append(out, sibling)
}
return out, nil
}
// LinksForImage returns the computed links for one picture, which is what the
// GraphQL "links" field exposes.
func (s *Service) LinksForImage(ctx context.Context, imageID string) ([]domain.Link, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return nil, mapStoreError(err, "image")
}
place, err := s.store.GetPlace(ctx, img.PlaceID)
if err != nil {
return nil, mapStoreError(err, "place")
}
center := effectiveCoordinate(img, place.Coordinate)
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
if center == nil {
links := make([]domain.Link, 0, len(place.Images))
for _, sibling := range place.Images {
if sibling.ID == img.ID {
continue
}
links = append(links, domain.Link{
ImageID: img.ID,
RelatedImageID: sibling.ID,
SamePlace: true,
Reason: ReasonSamePlace,
})
}
return links, nil
}
candidates, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return nil, mapStoreError(err, "image")
}
return LinksFor(img, candidates, place, radius), nil
}
// ImageLinks reads back the links recorded for a picture.
func (s *Service) ImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) {
links, err := s.store.ListImageLinks(ctx, imageID)
if err != nil {
return nil, mapStoreError(err, "image link")
}
return links, nil
}
func nearestDistance(links []domain.Link) float64 {
nearest := 0.0
for i, l := range links {
if i == 0 || l.DistanceMeters < nearest {
nearest = l.DistanceMeters
}
}
return nearest
}
// decorate copies an image and fills in the public URL, which is derived from
// the storage key and therefore not stored.
func decorate(img domain.Image, url string) domain.Image {
img.URL = url
return img
}

View file

@ -0,0 +1,392 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go-places-api/internal/domain"
"go-places-api/internal/events"
"go-places-api/internal/geo"
"go-places-api/internal/store"
)
// maxTitleLength and maxDescriptionLength bound what a client can store, which
// keeps a single request from writing an unbounded blob of text.
const (
maxTitleLength = 200
maxDescriptionLength = 5000
maxCommentLength = 2000
)
// CreatePlace stores a new place and publishes an event for it.
func (s *Service) CreatePlace(ctx context.Context, author domain.User, in domain.PlaceInput) (domain.Place, error) {
in.Title = strings.TrimSpace(in.Title)
in.Description = strings.TrimSpace(in.Description)
if in.Title == "" {
return domain.Place{}, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if len(in.Title) > maxTitleLength {
return domain.Place{}, fmt.Errorf("%w: title exceeds %d characters", ErrInvalidInput, maxTitleLength)
}
if len(in.Description) > maxDescriptionLength {
return domain.Place{}, fmt.Errorf("%w: description exceeds %d characters", ErrInvalidInput, maxDescriptionLength)
}
radius := s.cfg.DefaultLinkRadiusMeters
if in.LinkRadiusMeters != nil {
radius = *in.LinkRadiusMeters
}
if radius <= 0 {
return domain.Place{}, fmt.Errorf("%w: link radius must be positive", ErrInvalidInput)
}
if radius > s.cfg.MaxLinkRadiusMeters {
return domain.Place{}, fmt.Errorf("%w: link radius must not exceed %g metres",
ErrInvalidInput, s.cfg.MaxLinkRadiusMeters)
}
place := domain.Place{
Title: in.Title,
Description: in.Description,
Coordinate: in.Coordinate(),
LinkRadiusMeters: radius,
Creator: author,
}
if err := s.store.UpsertUser(ctx, author); err != nil {
return domain.Place{}, mapStoreError(err, "user")
}
created, err := s.store.CreatePlace(ctx, place)
if err != nil {
return domain.Place{}, mapStoreError(err, "place")
}
s.publish(ctx, author, events.TopicPlaceCreated, map[string]any{
"placeId": created.ID,
"title": created.Title,
"creator": author.ID,
})
return created, nil
}
// GetPlace returns one place with its pictures and comments.
//
// The pictures' public URLs are filled in here because the URL is derived from
// the storage key rather than stored, and a caller rendering this place should
// not have to know that.
func (s *Service) GetPlace(ctx context.Context, id string) (domain.Place, error) {
if strings.TrimSpace(id) == "" {
return domain.Place{}, fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
place, err := s.store.GetPlace(ctx, id)
if err != nil {
return domain.Place{}, mapStoreError(err, "place")
}
place.Images = s.withImageURLs(place.Images)
// The collections are serialised to JSON, where a nil slice becomes null.
// Callers iterate them directly, so an empty result is normalised to an
// empty slice here rather than at each read site.
if place.Images == nil {
place.Images = []domain.Image{}
}
if place.Comments == nil {
place.Comments = []domain.Comment{}
}
if place.RelatedPlaces == nil {
place.RelatedPlaces = []domain.RelatedPlace{}
}
return place, nil
}
// withImageURLs returns a copy of images with each public URL filled in.
func (s *Service) withImageURLs(images []domain.Image) []domain.Image {
if len(images) == 0 {
return images
}
out := make([]domain.Image, 0, len(images))
for _, img := range images {
out = append(out, decorate(img, s.ImageURL(img.StorageKey)))
}
return out
}
// ListPlaces returns places newest first. The listing is a summary, so its
// pictures and comments are left empty; a client that needs them reads the place
// individually.
func (s *Service) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) {
limit, offset = clampPage(limit, offset)
places, err := s.store.ListPlaces(ctx, limit, offset)
if err != nil {
return nil, mapStoreError(err, "place")
}
// An empty result is returned as an empty slice rather than nil, so it
// serialises as [] and clients can iterate without a null check.
if places == nil {
return []domain.Place{}, nil
}
for i := range places {
places[i].Images = []domain.Image{}
places[i].Comments = []domain.Comment{}
places[i].RelatedPlaces = []domain.RelatedPlace{}
}
return places, nil
}
// DeletePlace removes a place together with its pictures, deleting their blobs
// so storage does not leak.
func (s *Service) DeletePlace(ctx context.Context, id string) error {
place, err := s.store.GetPlace(ctx, id)
if err != nil {
return mapStoreError(err, "place")
}
if err := s.store.DeletePlace(ctx, id); err != nil {
return mapStoreError(err, "place")
}
for _, img := range place.Images {
if img.StorageKey == "" {
continue
}
if err := s.blobs.Delete(ctx, img.StorageKey); err != nil {
// The row is already gone; leaking the blob is worth a warning but
// not worth failing the request.
s.logger.Warn("failed to delete image blob", "imageId", img.ID, "key", img.StorageKey, "error", err)
}
}
return nil
}
// AddComment posts a comment on a place.
func (s *Service) AddComment(ctx context.Context, author domain.User, placeID, content string) (domain.Comment, error) {
content = strings.TrimSpace(content)
if placeID == "" {
return domain.Comment{}, fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if content == "" {
return domain.Comment{}, fmt.Errorf("%w: comment content is required", ErrInvalidInput)
}
if len(content) > maxCommentLength {
return domain.Comment{}, fmt.Errorf("%w: comment exceeds %d characters", ErrInvalidInput, maxCommentLength)
}
// Confirm the place exists so a comment cannot dangle.
if _, err := s.store.GetPlace(ctx, placeID); err != nil {
return domain.Comment{}, mapStoreError(err, "place")
}
if err := s.store.UpsertUser(ctx, author); err != nil {
return domain.Comment{}, mapStoreError(err, "user")
}
comment, err := s.store.AddComment(ctx, domain.Comment{
PlaceID: placeID,
Content: content,
Creator: author,
})
if err != nil {
return domain.Comment{}, mapStoreError(err, "comment")
}
// Find the place owner so the notification carries a real recipient rather
// than the placeholder the original scaffold used.
recipient := ""
if place, err := s.store.GetPlace(ctx, placeID); err == nil {
recipient = place.Creator.ID
}
s.publish(ctx, author, events.TopicCommentAdded, map[string]any{
"commentId": comment.ID,
"placeId": placeID,
"commenterId": author.ID,
"placeOwnerId": recipient,
"notifyOwner": recipient != "" && recipient != author.ID,
})
return comment, nil
}
// RelatedPlaces finds other places whose pictures overlap the given one, which
// is the place-level view of the same-place linking.
func (s *Service) RelatedPlaces(ctx context.Context, placeID string) ([]domain.RelatedPlace, error) {
place, err := s.store.GetPlace(ctx, placeID)
if err != nil {
return nil, mapStoreError(err, "place")
}
if len(place.Images) == 0 {
return nil, nil
}
radius := place.LinkRadiusMeters
if radius <= 0 {
radius = s.cfg.DefaultLinkRadiusMeters
}
// Anchor the search on each picture's own coordinates when it has them,
// otherwise on the place coordinate.
var (
related = map[string]*domain.RelatedPlace{}
ownImages = map[string]bool{}
)
for _, img := range place.Images {
ownImages[img.ID] = true
}
for _, img := range place.Images {
center := effectiveCoordinate(img, place.Coordinate)
if center == nil {
continue
}
neighbours, err := s.store.ImagesWithin(ctx, store.NearbyQuery{
Center: *center,
RadiusMeters: radius,
})
if err != nil {
return nil, mapStoreError(err, "image")
}
for _, other := range neighbours {
otherCenter := effectiveCoordinate(other, nil)
if otherCenter == nil {
continue
}
distance := geo.DistanceMeters(*center, *otherCenter)
if !geo.WithinRadius(*center, *otherCenter, radius) {
continue
}
if ownImages[other.ID] || other.ID == img.ID {
continue
}
entry, ok := related[other.PlaceID]
if !ok {
entry = &domain.RelatedPlace{DistanceMeters: distance}
related[other.PlaceID] = entry
}
if distance < entry.DistanceMeters {
entry.DistanceMeters = distance
}
entry.SharedImageIDs = append(entry.SharedImageIDs, other.ID)
}
}
// Load the full related places so the caller receives usable records.
out := make([]domain.RelatedPlace, 0, len(related))
for relPlaceID, rel := range related {
loaded, err := s.GetPlace(ctx, relPlaceID)
if err != nil {
s.logger.Warn("failed to load related place", "placeId", relPlaceID, "error", err)
continue
}
// A related place listing its own related places would recurse without
// end, so the nested copy carries pictures but no further relations.
loaded.RelatedPlaces = nil
rel.Place = loaded
out = append(out, *rel)
}
sortRelated(out)
return out, nil
}
// UpdateImageCaption changes the description attached to a picture.
func (s *Service) UpdateImageCaption(ctx context.Context, imageID, caption string) (domain.Image, error) {
caption = strings.TrimSpace(caption)
if len(caption) > maxDescriptionLength {
return domain.Image{}, fmt.Errorf("%w: caption exceeds %d characters", ErrInvalidInput, maxDescriptionLength)
}
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.Caption = caption
updated, err := s.store.UpdateImage(ctx, img)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
return updated, nil
}
// ListImages returns pictures matching a filter, with each public URL filled in.
func (s *Service) ListImages(ctx context.Context, f store.ImageFilter) ([]domain.Image, error) {
f.Limit, f.Offset = clampPage(f.Limit, f.Offset)
images, err := s.store.ListImages(ctx, f)
if err != nil {
return nil, mapStoreError(err, "image")
}
return s.withImageURLs(images), nil
}
// DeleteImage removes a picture and its stored bytes.
func (s *Service) DeleteImage(ctx context.Context, id string) error {
img, err := s.store.GetImage(ctx, id)
if err != nil {
return mapStoreError(err, "image")
}
if err := s.store.DeleteImage(ctx, id); err != nil {
return mapStoreError(err, "image")
}
if img.StorageKey != "" {
if err := s.blobs.Delete(ctx, img.StorageKey); err != nil {
s.logger.Warn("failed to delete image blob", "imageId", id, "error", err)
}
}
return nil
}
// ImageURL builds the public URL for a stored picture.
func (s *Service) ImageURL(storageKey string) string {
if storageKey == "" {
return ""
}
if s.cfg.PublicBaseURL != "" {
return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/media/" + storageKey
}
return "/media/" + storageKey
}
// publish sends an event, treating delivery as best effort so a broker problem
// never fails the request that produced it.
func (s *Service) publish(ctx context.Context, actor domain.User, topic events.Topic, payload map[string]any) {
body, err := marshalPayload(payload)
if err != nil {
s.logger.Warn("failed to encode event", "topic", string(topic), "error", err)
return
}
ev := events.Event{
ID: uuid.NewString(),
Topic: topic,
OccurredAt: time.Now().UTC(),
ActorID: actor.ID,
Payload: body,
}
if err := s.events.Publish(ctx, ev); err != nil {
s.logger.Warn("failed to publish event", "topic", string(topic), "error", err)
}
}
// effectiveCoordinate returns the picture's own capture point when it has one,
// falling back to the place it belongs to.
func effectiveCoordinate(img domain.Image, place *domain.Coordinate) *domain.Coordinate {
if img.Coordinate != nil && img.Coordinate.Valid() {
return img.Coordinate
}
if place != nil && place.Valid() {
return place
}
return nil
}
func clampPage(limit, offset int) (int, int) {
if limit < 0 {
limit = 0
}
if offset < 0 {
offset = 0
}
return limit, offset
}
var errNoCoordinate = errors.New("no coordinate")

View file

@ -0,0 +1,113 @@
// Package service holds the business logic. It coordinates the store, the blob
// store, the analyzer and the event publisher, and it is the only layer that
// knows how the application's features fit together.
package service
import (
"context"
"errors"
"fmt"
"go-places-api/internal/analysis"
"go-places-api/internal/blobstore"
"go-places-api/internal/config"
"go-places-api/internal/events"
"go-places-api/internal/store"
)
// Errors surfaced to the transport layer. The HTTP and GraphQL layers map these
// onto status codes and GraphQL error extensions.
var (
// ErrNotFound means the addressed entity does not exist.
ErrNotFound = errors.New("not found")
// ErrInvalidInput means the request was malformed or violated a rule.
ErrInvalidInput = errors.New("invalid input")
// ErrTooLarge means the upload exceeded the configured limit.
ErrTooLarge = errors.New("upload too large")
// ErrUnsupportedType means the upload's content type is not allowed.
ErrUnsupportedType = errors.New("unsupported media type")
)
// Service is the application entry point used by the transport layers.
type Service struct {
store store.Store
blobs blobstore.BlobStore
analyze analysis.Analyzer
events events.Publisher
cfg config.Config
logger Logger
}
// Logger is the small logging surface the service needs. Keeping it narrow
// means tests can pass a no-op without pulling in slog.
type Logger interface {
Warn(msg string, args ...any)
Info(msg string, args ...any)
}
// Options configures a Service.
type Options struct {
Store store.Store
Blobs blobstore.BlobStore
Analyzer analysis.Analyzer
Events events.Publisher
Config config.Config
Logger Logger
}
// New builds a Service, filling in no-op collaborators where none are supplied
// so a partially wired service still works in tests.
func New(opts Options) *Service {
s := &Service{
store: opts.Store,
blobs: opts.Blobs,
analyze: opts.Analyzer,
events: opts.Events,
cfg: opts.Config,
logger: opts.Logger,
}
if s.analyze == nil {
s.analyze = analysis.NewLocal(analysis.DefaultMaxDimension)
}
if s.events == nil {
s.events = events.NewLogPublisher(nil)
}
if s.logger == nil {
s.logger = nopLogger{}
}
return s
}
// Pinger is implemented by stores that can report whether they are reachable.
// It is checked by the readiness probe, and is optional so an in-memory fake
// still satisfies the contract without implementing it.
type Pinger interface {
PingContext(ctx context.Context) error
}
// Ping reports whether the service's dependencies can serve traffic. A store
// that cannot report its health is treated as healthy.
func (s *Service) Ping(ctx context.Context) error {
p, ok := s.store.(Pinger)
if !ok {
return nil
}
return p.PingContext(ctx)
}
// mapStoreError translates store sentinels into service sentinels.
func mapStoreError(err error, entity string) error {
switch {
case errors.Is(err, store.ErrNotFound):
return fmt.Errorf("%w: no such %s", ErrNotFound, entity)
case errors.Is(err, store.ErrConflict):
return fmt.Errorf("%w: conflicting %s", ErrInvalidInput, entity)
default:
return err
}
}
type nopLogger struct{}
func (nopLogger) Warn(string, ...any) {}
func (nopLogger) Info(string, ...any) {}

View file

@ -0,0 +1,836 @@
package service
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
"io"
"log/slog"
"path/filepath"
"testing"
"go-places-api/internal/analysis"
"go-places-api/internal/blobstore"
"go-places-api/internal/config"
"go-places-api/internal/domain"
"go-places-api/internal/events"
"go-places-api/internal/store"
)
// harness bundles a service with the real collaborators it needs, backed by a
// throwaway SQLite database and a temp directory.
type harness struct {
svc *Service
store *store.SQLStore
blobs *blobstore.LocalFS
user domain.User
}
// newHarness wires a service the way main does, minus the network.
func newHarness(t *testing.T) *harness {
t.Helper()
return newHarnessWith(t, nil)
}
// newHarnessWith is newHarness with a hook to adjust the configuration, for
// tests that need a different limit or default.
func newHarnessWith(t *testing.T, tweak func(*config.Config)) *harness {
t.Helper()
st, err := store.OpenSQL("file:" + filepath.Join(t.TempDir(), "test.db") +
"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("store.OpenSQL: %v", err)
}
t.Cleanup(func() { st.Close() })
blobs, err := blobstore.NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("blobstore.NewLocalFS: %v", err)
}
cfg := config.Default()
if tweak != nil {
tweak(&cfg)
}
user := domain.User{ID: "user-1", Username: "ada", Email: "ada@example.com"}
svc := New(Options{
Store: st,
Blobs: blobs,
Analyzer: analysis.NewLocal(analysis.DefaultMaxDimension),
Events: events.NewLogPublisher(slog.New(slog.DiscardHandler)),
Config: cfg,
})
return &harness{svc: svc, store: st, blobs: blobs, user: user}
}
// createPlace adds a place owned by the harness user.
func (h *harness) createPlace(t *testing.T, title string, lat, lon float64) domain.Place {
t.Helper()
in := domain.PlaceInput{Title: title, Description: "notes"}
if lat != 0 || lon != 0 {
in.Latitude, in.Longitude = &lat, &lon
}
p, err := h.svc.CreatePlace(context.Background(), h.user, in)
if err != nil {
t.Fatalf("CreatePlace(%q): %v", title, err)
}
return p
}
// jpegBytes encodes a solid-colour JPEG of the given size.
func jpegBytes(t *testing.T, w, h int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.SetRGBA(x, y, c.(color.RGBA))
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatalf("jpeg.Encode: %v", err)
}
return buf.Bytes()
}
// skyBlue is easy for the local analyzer to recognise as sky.
var skyBlue = color.RGBA{R: 40, G: 120, B: 220, A: 255}
// upload adds one encoded picture to a place.
func (h *harness) upload(t *testing.T, placeID string, name string, data []byte, c *domain.Coordinate) domain.Image {
t.Helper()
img, err := h.svc.AddImage(context.Background(), h.user, placeID, ImageUpload{
Filename: name,
ContentType: "image/jpeg",
Caption: "a caption",
Body: bytes.NewReader(data),
Coordinate: c,
})
if err != nil {
t.Fatalf("AddImage(%q): %v", name, err)
}
return img
}
func TestCreatePlaceAppliesDefaultsAndPersists(t *testing.T) {
t.Parallel()
h := newHarness(t)
lat, lon := 48.8584, 2.2945
p, err := h.svc.CreatePlace(context.Background(), h.user, domain.PlaceInput{
Title: " Eiffel Tower ",
Description: " went up it ",
Latitude: &lat,
Longitude: &lon,
})
if err != nil {
t.Fatalf("CreatePlace: %v", err)
}
// Whitespace is trimmed so a client that pads its input gets clean output.
if p.Title != "Eiffel Tower" {
t.Errorf("Title = %q, want %q", p.Title, "Eiffel Tower")
}
if p.Description != "went up it" {
t.Errorf("Description = %q", p.Description)
}
if p.LinkRadiusMeters != config.Default().DefaultLinkRadiusMeters {
t.Errorf("LinkRadiusMeters = %v, want the default %v",
p.LinkRadiusMeters, config.Default().DefaultLinkRadiusMeters)
}
if p.Coordinate == nil || p.Coordinate.Latitude != lat {
t.Errorf("Coordinate = %+v, want %v", p.Coordinate, lat)
}
if p.Creator.ID != h.user.ID {
t.Errorf("Creator = %+v, want %q", p.Creator, h.user.ID)
}
if p.ID == "" {
t.Error("CreatePlace returned no ID")
}
}
func TestCreatePlaceRejectsBadInput(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
cfg := config.Default()
tests := []struct {
name string
in domain.PlaceInput
}{
{name: "no title", in: domain.PlaceInput{}},
{name: "blank title", in: domain.PlaceInput{Title: " "}},
{name: "title too long", in: domain.PlaceInput{Title: string(make([]byte, maxTitleLength+1))}},
{name: "description too long", in: domain.PlaceInput{
Title: "ok",
Description: string(make([]byte, maxDescriptionLength+1)),
}},
{name: "zero radius", in: domain.PlaceInput{Title: "ok", LinkRadiusMeters: ptr(0.0)}},
{name: "negative radius", in: domain.PlaceInput{Title: "ok", LinkRadiusMeters: ptr(-10.0)}},
// A client must not be able to opt out of the radius cap.
{name: "radius above cap", in: domain.PlaceInput{
Title: "ok",
LinkRadiusMeters: ptr(cfg.MaxLinkRadiusMeters + 1),
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := h.svc.CreatePlace(ctx, h.user, tc.in)
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput", err)
}
})
}
}
func TestCreatePlaceHonoursExplicitRadius(t *testing.T) {
t.Parallel()
h := newHarness(t)
radius := 750.0
p, err := h.svc.CreatePlace(context.Background(), h.user, domain.PlaceInput{
Title: "Wide", LinkRadiusMeters: &radius,
})
if err != nil {
t.Fatalf("CreatePlace: %v", err)
}
if p.LinkRadiusMeters != radius {
t.Errorf("LinkRadiusMeters = %v, want %v", p.LinkRadiusMeters, radius)
}
}
func TestAddImageStoresAnalysesAndLinks(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Eiffel", 48.8584, 2.2945)
data := jpegBytes(t, 64, 48, skyBlue)
img := h.upload(t, place.ID, "sky.jpg", data, nil)
if img.ID == "" {
t.Error("no image ID assigned")
}
if img.ByteSize != int64(len(data)) {
t.Errorf("ByteSize = %d, want %d", img.ByteSize, len(data))
}
if img.Width != 64 || img.Height != 48 {
t.Errorf("dimensions = %dx%d, want 64x48", img.Width, img.Height)
}
if img.AnalysisStatus != domain.AnalysisCompleted {
t.Errorf("AnalysisStatus = %q, want %q", img.AnalysisStatus, domain.AnalysisCompleted)
}
if len(img.Badges) == 0 {
t.Error("a plain blue picture should produce at least one badge")
}
if img.Caption != "a caption" {
t.Errorf("Caption = %q", img.Caption)
}
// The blob must be readable under a key derived from the author, and the
// client-facing URL must not leak the storage key.
if img.URL == "" {
t.Error("URL was not set")
}
if img.URL == img.StorageKey {
t.Error("URL should not be the raw storage key")
}
rc, err := h.blobs.Get(ctx, img.StorageKey)
if err != nil {
t.Fatalf("blobs.Get(%q): %v", img.StorageKey, err)
}
defer rc.Close()
stored, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read blob: %v", err)
}
if !bytes.Equal(stored, data) {
t.Errorf("stored %d bytes, want the %d uploaded", len(stored), len(data))
}
}
func TestAddImageRejectsUnusableUploads(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Guarded", 48.85, 2.29)
good := jpegBytes(t, 8, 8, skyBlue)
tests := []struct {
name string
up ImageUpload
want error
}{
{name: "no place", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(good)}, want: ErrInvalidInput},
{name: "no body", up: ImageUpload{ContentType: "image/jpeg"}, want: ErrInvalidInput},
{name: "empty body", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(nil)}, want: ErrInvalidInput},
{name: "no author", up: ImageUpload{ContentType: "image/jpeg", Body: bytes.NewReader(good)}, want: ErrInvalidInput},
{name: "bad content type", up: ImageUpload{ContentType: "text/plain", Body: bytes.NewReader(good)}, want: ErrUnsupportedType},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
author := h.user
placeID := place.ID
if tc.name == "no author" {
author = domain.User{}
}
if tc.name == "no place" {
placeID = ""
}
_, err := h.svc.AddImage(ctx, author, placeID, tc.up)
if !errors.Is(err, tc.want) {
t.Errorf("err = %v, want %v", err, tc.want)
}
})
}
}
func TestAddImageRejectsUnknownPlace(t *testing.T) {
t.Parallel()
h := newHarness(t)
_, err := h.svc.AddImage(context.Background(), h.user, "no-such-place", ImageUpload{
ContentType: "image/jpeg",
Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue)),
})
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestAddImageRejectsUndecodablePayload(t *testing.T) {
t.Parallel()
// A payload with a valid content type but corrupt bytes must not reach
// storage, because analysis runs first.
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Corrupt", 10, 10)
_, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{
Filename: "fake.jpg",
ContentType: "image/jpeg",
Body: bytes.NewReader([]byte("this is definitely not a JPEG")),
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput", err)
}
images, err := h.store.ListImages(ctx, store.ImageFilter{PlaceID: place.ID})
if err != nil {
t.Fatalf("ListImages: %v", err)
}
if len(images) != 0 {
t.Errorf("%d images were stored for an undecodable upload", len(images))
}
}
func TestAddImageEnforcesMaxUploadBytes(t *testing.T) {
t.Parallel()
// The limit must be enforced by reading the stream, not by trusting a
// declared Content-Length, so a body over the limit is rejected even though
// the JPEG itself decodes fine.
h := newHarnessWith(t, func(c *config.Config) { c.MaxUploadBytes = 2048 })
ctx := context.Background()
place := h.createPlace(t, "Sized", 10, 10)
valid := jpegBytes(t, 32, 32, skyBlue)
oversized := append(append([]byte(nil), valid...), make([]byte, 8<<10)...)
if _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{
Filename: "huge.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(oversized),
}); !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
// A body at or under the limit is still accepted, so the check is not simply
// rejecting anything that does not fit in a fixed buffer.
small := jpegBytes(t, 8, 8, skyBlue)
if int64(len(small)) >= 2048 {
t.Skipf("the encoder produced %d bytes, over the 2048 byte test limit", len(small))
}
if _, err := h.svc.AddImage(ctx, h.user, place.ID, ImageUpload{
Filename: "small.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(small),
}); err != nil {
t.Errorf("a body under the limit was rejected: %v", err)
}
}
func TestAddImagePrefersExplicitCoordinateThenFallsBackToPlace(t *testing.T) {
t.Parallel()
h := newHarness(t)
place := h.createPlace(t, "Located", 48.8584, 2.2945)
data := jpegBytes(t, 16, 16, skyBlue)
// No override: the picture inherits the place's location so it can be linked.
inherited := h.upload(t, place.ID, "inherited.jpg", data, nil)
if inherited.Coordinate == nil {
t.Fatal("picture with no override has no coordinate")
}
if d := inherited.Coordinate.Latitude - 48.8584; d > 1e-6 || d < -1e-6 {
t.Errorf("Latitude = %v, want the place's 48.8584", inherited.Coordinate.Latitude)
}
// An explicit override wins over the place.
override := domain.Coordinate{Latitude: 10, Longitude: 20}
explicit := h.upload(t, place.ID, "explicit.jpg", data, &override)
if explicit.Coordinate == nil || explicit.Coordinate.Latitude != 10 {
t.Errorf("Coordinate = %+v, want the override 10/20", explicit.Coordinate)
}
}
func TestPicturesInOnePlaceAreLinkedToEachOther(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Same Place", 48.8584, 2.2945)
data := jpegBytes(t, 16, 16, skyBlue)
first := h.upload(t, place.ID, "a.jpg", data, nil)
second := h.upload(t, place.ID, "b.jpg", data, nil)
links, err := h.svc.LinksForImage(ctx, second.ID)
if err != nil {
t.Fatalf("LinksForImage: %v", err)
}
if len(links) != 1 {
t.Fatalf("got %d links, want 1", len(links))
}
if links[0].RelatedImageID != first.ID {
t.Errorf("linked to %q, want %q", links[0].RelatedImageID, first.ID)
}
if !links[0].SamePlace {
t.Error("pictures sharing a place record should report SamePlace")
}
if links[0].Reason != ReasonSamePlace {
t.Errorf("Reason = %q, want %q", links[0].Reason, ReasonSamePlace)
}
}
func TestPicturesAtTheSameCoordinatesAcrossPlacesAreLinked(t *testing.T) {
t.Parallel()
// The headline feature: the same spot catalogued under two separate places
// should still be recognised as the same location.
h := newHarness(t)
ctx := context.Background()
data := jpegBytes(t, 16, 16, skyBlue)
firstPlace := h.createPlace(t, "Eiffel (visit 1)", 48.8584, 2.2945)
secondPlace := h.createPlace(t, "Eiffel (visit 2)", 48.8585, 2.2946)
a := h.upload(t, firstPlace.ID, "a.jpg", data, nil)
b := h.upload(t, secondPlace.ID, "b.jpg", data, nil)
links, err := h.svc.LinksForImage(ctx, b.ID)
if err != nil {
t.Fatalf("LinksForImage: %v", err)
}
if len(links) != 1 {
t.Fatalf("got %d links, want 1", len(links))
}
if links[0].RelatedImageID != a.ID {
t.Errorf("linked to %q, want %q", links[0].RelatedImageID, a.ID)
}
if links[0].SamePlace {
t.Error("pictures in different places should not report SamePlace")
}
if links[0].Reason != ReasonWithinRadius {
t.Errorf("Reason = %q, want %q", links[0].Reason, ReasonWithinRadius)
}
// A few tens of metres apart, so the distance should be small but non-zero.
if links[0].DistanceMeters <= 0 || links[0].DistanceMeters > 150 {
t.Errorf("DistanceMeters = %v, want a small positive distance", links[0].DistanceMeters)
}
}
func TestPicturesBeyondRadiusAreNotLinked(t *testing.T) {
t.Parallel()
// Roughly 3 km apart with the default 150 m radius: not the same place.
h := newHarness(t)
ctx := context.Background()
data := jpegBytes(t, 16, 16, skyBlue)
near := h.createPlace(t, "Near Spot", 48.8584, 2.2945)
far := h.createPlace(t, "Far Spot", 48.8850, 2.3000)
h.upload(t, near.ID, "near.jpg", data, nil)
other := h.upload(t, far.ID, "far.jpg", data, nil)
links, err := h.svc.LinksForImage(ctx, other.ID)
if err != nil {
t.Fatalf("LinksForImage: %v", err)
}
if len(links) != 0 {
t.Errorf("got %d links between points 3 km apart, want 0: %+v", len(links), links)
}
}
func TestWideRadiusLinksDistantPictures(t *testing.T) {
t.Parallel()
// A place that opts into a 5 km radius should match a spot ~3 km away.
h := newHarness(t)
ctx := context.Background()
data := jpegBytes(t, 16, 16, skyBlue)
radius := 5000.0
in := domain.PlaceInput{Title: "Wide Radius", LinkRadiusMeters: &radius}
latA, lonA := 48.8584, 2.2945
in.Latitude, in.Longitude = &latA, &lonA
wide, err := h.svc.CreatePlace(ctx, h.user, in)
if err != nil {
t.Fatalf("CreatePlace: %v", err)
}
near := h.createPlace(t, "Nearby", 48.8584, 2.2945)
far := h.createPlace(t, "Distant", 48.8850, 2.3000)
h.upload(t, near.ID, "near.jpg", data, nil)
other := h.upload(t, far.ID, "far.jpg", data, nil)
// The link is evaluated from the wide place's perspective.
wideImg := h.upload(t, wide.ID, "wide.jpg", data, nil)
links, err := h.svc.LinksForImage(ctx, wideImg.ID)
if err != nil {
t.Fatalf("LinksForImage: %v", err)
}
if len(links) == 0 {
t.Fatal("a 5 km radius should link pictures ~3 km apart")
}
if other.ID == "" {
t.Fatal("test setup did not produce the distant picture")
}
}
func TestLinksForIgnoresSelfAndNeedsACentre(t *testing.T) {
t.Parallel()
img := domain.Image{ID: "a", PlaceID: "p"}
same := domain.Image{ID: "a", PlaceID: "p"}
place := domain.Place{ID: "p", LinkRadiusMeters: 150}
// No coordinate anywhere: there is nothing to compare.
if links := LinksFor(img, []domain.Image{same}, place, 150); len(links) != 0 {
t.Errorf("got %d links without a centre, want 0", len(links))
}
// A non-positive radius disables linking.
if links := LinksFor(img, nil, place, 0); len(links) != 0 {
t.Errorf("got %d links with radius 0, want 0", len(links))
}
}
func TestUploadPicturesReportsFailuresPerPicture(t *testing.T) {
t.Parallel()
// One bad file in a batch must not discard the good ones.
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Batch", 10, 10)
good := jpegBytes(t, 16, 16, skyBlue)
results, err := h.svc.UploadPictures(ctx, h.user, place.ID, []ImageUpload{
{Filename: "ok.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(good)},
{Filename: "bad.jpg", ContentType: "image/jpeg", Body: bytes.NewReader([]byte("nope"))},
{Filename: "ok2.jpg", ContentType: "image/jpeg", Body: bytes.NewReader(good)},
})
if err != nil {
t.Fatalf("UploadPictures returned a batch error: %v", err)
}
if len(results) != 3 {
t.Fatalf("got %d results, want 3", len(results))
}
if results[0].Err != nil {
t.Errorf("result 0: %v", results[0].Err)
}
if !errors.Is(results[1].Err, ErrInvalidInput) {
t.Errorf("result 1 err = %v, want ErrInvalidInput", results[1].Err)
}
if results[2].Err != nil {
t.Errorf("result 2: %v", results[2].Err)
}
images, err := h.store.ListImages(ctx, store.ImageFilter{PlaceID: place.ID})
if err != nil {
t.Fatalf("ListImages: %v", err)
}
if len(images) != 2 {
t.Errorf("%d images stored, want the 2 valid ones", len(images))
}
}
func TestUploadPicturesValidatesTheBatch(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Batch Guards", 10, 10)
if _, err := h.svc.UploadPictures(ctx, h.user, "", []ImageUpload{
{ContentType: "image/jpeg", Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue))},
}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty place id: err = %v, want ErrInvalidInput", err)
}
if _, err := h.svc.UploadPictures(ctx, h.user, place.ID, nil); !errors.Is(err, ErrInvalidInput) {
t.Errorf("no uploads: err = %v, want ErrInvalidInput", err)
}
if _, err := h.svc.UploadPictures(ctx, domain.User{}, place.ID, []ImageUpload{
{ContentType: "image/jpeg", Body: bytes.NewReader(jpegBytes(t, 8, 8, skyBlue))},
}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("no author: err = %v, want ErrInvalidInput", err)
}
}
func TestReanalyzeRecoversBadges(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Reanalyzed", 10, 10)
data := jpegBytes(t, 32, 32, skyBlue)
img := h.upload(t, place.ID, "sky.jpg", data, nil)
// Simulate an earlier failure that left the record without badges.
if err := h.store.ReplaceBadges(ctx, img.ID, nil); err != nil {
t.Fatalf("ReplaceBadges: %v", err)
}
got, err := h.svc.Reanalyze(ctx, img.ID)
if err != nil {
t.Fatalf("Reanalyze: %v", err)
}
if got.AnalysisStatus != domain.AnalysisCompleted {
t.Errorf("AnalysisStatus = %q", got.AnalysisStatus)
}
if len(got.Badges) == 0 {
t.Error("Reanalyze did not produce badges")
}
}
func TestReanalyzeMissingImage(t *testing.T) {
t.Parallel()
h := newHarness(t)
if _, err := h.svc.Reanalyze(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestOpenImageReturnsStoredBytes(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Served", 10, 10)
data := jpegBytes(t, 16, 16, skyBlue)
img := h.upload(t, place.ID, "served.jpg", data, nil)
served, rc, err := h.svc.OpenImage(ctx, img.ID)
if err != nil {
t.Fatalf("OpenImage: %v", err)
}
if served.ID != img.ID {
t.Errorf("OpenImage returned %q, want %q", served.ID, img.ID)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, data) {
t.Errorf("got %d bytes, want %d", len(got), len(data))
}
}
func TestDeletePlaceRemovesBlobs(t *testing.T) {
t.Parallel()
// Deleting a place has to delete its blobs too, or storage grows forever.
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Temporary", 10, 10)
data := jpegBytes(t, 16, 16, skyBlue)
img := h.upload(t, place.ID, "temp.jpg", data, nil)
if err := h.svc.DeletePlace(ctx, place.ID); err != nil {
t.Fatalf("DeletePlace: %v", err)
}
if _, err := h.blobs.Get(ctx, img.StorageKey); err == nil {
t.Error("the blob survived the place deletion")
}
if _, err := h.svc.GetPlace(ctx, place.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestDeleteImageRemovesBlobAndRecord(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Disposable", 10, 10)
data := jpegBytes(t, 16, 16, skyBlue)
img := h.upload(t, place.ID, "one.jpg", data, nil)
if err := h.svc.DeleteImage(ctx, img.ID); err != nil {
t.Fatalf("DeleteImage: %v", err)
}
if _, err := h.blobs.Get(ctx, img.StorageKey); err == nil {
t.Error("the blob survived the image deletion")
}
if _, err := h.svc.ImageByID(ctx, img.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestPingSucceeds(t *testing.T) {
t.Parallel()
h := newHarness(t)
if err := h.svc.Ping(context.Background()); err != nil {
t.Errorf("Ping: %v", err)
}
}
func TestStorageKeyIsScopedToAuthorAndDate(t *testing.T) {
t.Parallel()
// The key layout must not let a client choose where bytes land.
h := newHarness(t)
ctx := context.Background()
place := h.createPlace(t, "Keyed", 10, 10)
img := h.upload(t, place.ID, "../../../etc/passwd.jpg", jpegBytes(t, 8, 8, skyBlue), nil)
if !hasPathPrefix(img.StorageKey, h.user.ID) {
t.Errorf("StorageKey = %q, want it under the author %q", img.StorageKey, h.user.ID)
}
if containsDotDot(img.StorageKey) {
t.Errorf("StorageKey = %q contains a traversal segment", img.StorageKey)
}
_, err := h.svc.GetPlace(ctx, place.ID)
if err != nil {
t.Fatalf("GetPlace: %v", err)
}
}
func TestExtensionComesFromTypeNotFilename(t *testing.T) {
t.Parallel()
// A client-supplied name must not be able to choose the stored extension.
h := newHarness(t)
place := h.createPlace(t, "Extensions", 10, 10)
img := h.upload(t, place.ID, "payload.php", jpegBytes(t, 8, 8, skyBlue), nil)
if got := img.StorageKey; !hasSuffix(got, ".jpg") {
t.Errorf("StorageKey = %q, want a .jpg extension", got)
}
}
func TestBadgeLabelsAreDistinct(t *testing.T) {
t.Parallel()
got := badgeLabels([]domain.Badge{
{Label: "sky"}, {Label: "water"}, {Label: "sky"}, {Label: ""},
})
seen := map[string]bool{}
for _, l := range got {
if l == "" {
t.Error("an empty label was returned")
}
if seen[l] {
t.Errorf("label %q appeared twice", l)
}
seen[l] = true
}
if len(got) != 2 {
t.Errorf("got %v, want 2 distinct labels", got)
}
}
func TestImageURLDoesNotEscapeBase(t *testing.T) {
t.Parallel()
h := newHarness(t)
place := h.createPlace(t, "URLs", 10, 10)
img := h.upload(t, place.ID, "u.jpg", jpegBytes(t, 8, 8, skyBlue), nil)
if img.URL == "" {
t.Fatal("URL is empty")
}
if containsDotDot(img.URL) {
t.Errorf("URL = %q contains a traversal segment", img.URL)
}
}
func TestRelatedPlacesFindsOverlap(t *testing.T) {
t.Parallel()
// Two places whose pictures sit at the same spot should be reported as
// related, which is how the UI suggests merging duplicate places.
h := newHarness(t)
ctx := context.Background()
data := jpegBytes(t, 16, 16, skyBlue)
first := h.createPlace(t, "Dupe A", 48.8584, 2.2945)
second := h.createPlace(t, "Dupe B", 48.8585, 2.2946)
h.upload(t, first.ID, "a.jpg", data, nil)
h.upload(t, second.ID, "b.jpg", data, nil)
related, err := h.svc.RelatedPlaces(ctx, first.ID)
if err != nil {
t.Fatalf("RelatedPlaces: %v", err)
}
if len(related) != 1 {
t.Fatalf("got %d related places, want 1", len(related))
}
if related[0].Place.ID != second.ID {
t.Errorf("related place = %q, want %q", related[0].Place.ID, second.ID)
}
if len(related[0].SharedImageIDs) == 0 {
t.Error("expected shared image evidence")
}
if related[0].DistanceMeters > 150 {
t.Errorf("DistanceMeters = %v, want a small distance", related[0].DistanceMeters)
}
}
// ptr returns a pointer to v, for the optional PlaceInput fields.
func ptr[T any](v T) *T { return &v }
func hasPathPrefix(s, prefix string) bool {
return len(s) > len(prefix) && s[:len(prefix)+1] == prefix+"/"
}
func hasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
func containsDotDot(s string) bool {
return bytes.Contains([]byte(s), []byte(".."))
}
var _ = fmt.Sprintf // keep fmt available for ad-hoc debugging in this file

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

@ -0,0 +1,795 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
_ "modernc.org/sqlite" // registers the "sqlite" driver, CGO-free
"go-places-api/internal/domain"
)
// SQLStore is the SQLite-backed implementation of Store.
type SQLStore struct {
db *sql.DB
}
var _ Store = (*SQLStore)(nil)
// OpenSQL opens the SQLite database at dsn and applies the schema. The dsn is a
// SQLite connection string such as "file:go-places.db?_pragma=foreign_keys(1)".
func OpenSQL(dsn string) (*SQLStore, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("store: open database: %w", err)
}
// SQLite serialises writers. A single connection avoids "database is
// locked" errors under concurrent uploads without needing a write mutex.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("store: ping database: %w", err)
}
s := &SQLStore{db: db}
if err := s.migrate(context.Background()); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close releases the underlying database handle.
func (s *SQLStore) Close() error { return s.db.Close() }
// DB exposes the handle for health checks.
func (s *SQLStore) DB() *sql.DB { return s.db }
const schema = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS places (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
latitude REAL,
longitude REAL,
link_radius_meters REAL NOT NULL DEFAULT 150,
creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_places_creator ON places(creator_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_places_geo ON places(latitude, longitude);
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL,
content_type TEXT NOT NULL,
byte_size INTEGER NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
caption TEXT NOT NULL DEFAULT '',
latitude REAL,
longitude REAL,
taken_at TEXT,
camera_make TEXT NOT NULL DEFAULT '',
camera_model TEXT NOT NULL DEFAULT '',
analysis_status TEXT NOT NULL DEFAULT 'pending',
analysis_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_images_place ON images(place_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_images_created ON images(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_images_geo ON images(latitude, longitude);
CREATE TABLE IF NOT EXISTS badges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
label TEXT NOT NULL,
display TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
UNIQUE(image_id, label, source)
);
CREATE INDEX IF NOT EXISTS idx_badges_image ON badges(image_id);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE,
content TEXT NOT NULL,
creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_comments_place ON comments(place_id, created_at);
-- Evidence that two pictures were taken at the same place. Both directions are
-- stored so a link is discoverable from either picture.
CREATE TABLE IF NOT EXISTS image_links (
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
related_image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
distance_meters REAL NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (image_id, related_image_id)
);
CREATE INDEX IF NOT EXISTS idx_image_links_related ON image_links(related_image_id);
`
func (s *SQLStore) migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("store: apply schema: %w", err)
}
return nil
}
// ---------------------------------------------------------------- places ---
func (s *SQLStore) CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error) {
if p.ID == "" {
p.ID = newID()
}
now := time.Now().UTC()
p.CreatedAt = now
// Timestamps are stored as RFC3339 so the schema stays portable to engines
// without a native timestamp type.
_, err := s.db.ExecContext(ctx, `
INSERT INTO places (id, title, description, latitude, longitude,
link_radius_meters, creator_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Title, p.Description,
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
p.LinkRadiusMeters, p.Creator.ID,
formatTime(p.CreatedAt), formatTime(now),
)
if err != nil {
return domain.Place{}, wrapWrite(err, "place")
}
return s.GetPlace(ctx, p.ID)
}
func (s *SQLStore) GetPlace(ctx context.Context, id string) (domain.Place, error) {
row := s.db.QueryRowContext(ctx, `
SELECT p.id, p.title, p.description, p.latitude, p.longitude,
p.link_radius_meters, p.created_at,
u.id, u.username, u.email
FROM places p
JOIN users u ON u.id = p.creator_id
WHERE p.id = ?`, id)
p, err := scanPlace(row)
if err != nil {
return domain.Place{}, err
}
images, err := s.ListImages(ctx, ImageFilter{PlaceID: id})
if err != nil {
return domain.Place{}, err
}
comments, err := s.ListComments(ctx, id)
if err != nil {
return domain.Place{}, err
}
p.Images = images
p.Comments = comments
return p, nil
}
func (s *SQLStore) UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE places
SET title = ?, description = ?, latitude = ?, longitude = ?,
link_radius_meters = ?, updated_at = ?
WHERE id = ?`,
p.Title, p.Description,
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
p.LinkRadiusMeters, formatTime(time.Now().UTC()), p.ID,
)
if err != nil {
return domain.Place{}, wrapWrite(err, "place")
}
if n, _ := res.RowsAffected(); n == 0 {
return domain.Place{}, ErrNotFound
}
return s.GetPlace(ctx, p.ID)
}
func (s *SQLStore) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) {
query := `
SELECT p.id, p.title, p.description, p.latitude, p.longitude,
p.link_radius_meters, p.created_at,
u.id, u.username, u.email
FROM places p
JOIN users u ON u.id = p.creator_id
ORDER BY p.created_at DESC`
args := []any{}
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
if offset > 0 {
query += " OFFSET ?"
args = append(args, offset)
}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list places: %w", err)
}
defer rows.Close()
var out []domain.Place
for rows.Next() {
p, err := scanPlace(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *SQLStore) DeletePlace(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete place: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ---------------------------------------------------------------- images ---
func (s *SQLStore) CreateImage(ctx context.Context, img domain.Image) (domain.Image, error) {
if img.ID == "" {
img.ID = newID()
}
if img.AnalysisStatus == "" {
img.AnalysisStatus = domain.AnalysisPending
}
if img.CreatedAt.IsZero() {
img.CreatedAt = time.Now().UTC()
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO images (id, place_id, storage_key, content_type, byte_size,
width, height, caption, latitude, longitude, taken_at,
camera_make, camera_model, analysis_status, analysis_error,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
img.ID, img.PlaceID, img.StorageKey, img.ContentType, img.ByteSize,
img.Width, img.Height, img.Caption,
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
nullableTime(img.TakenAt),
img.CameraMake, img.CameraModel, string(img.AnalysisStatus), img.AnalysisError,
formatTime(img.CreatedAt),
)
if err != nil {
return domain.Image{}, wrapWrite(err, "image")
}
if len(img.Badges) > 0 {
if err := s.ReplaceBadges(ctx, img.ID, img.Badges); err != nil {
return domain.Image{}, err
}
}
return s.GetImage(ctx, img.ID)
}
func (s *SQLStore) GetImage(ctx context.Context, id string) (domain.Image, error) {
row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.id = ?`, id)
img, err := scanImage(row)
if err != nil {
return domain.Image{}, err
}
badges, err := s.badgesFor(ctx, []string{id})
if err != nil {
return domain.Image{}, err
}
img.Badges = badges[id]
return img, nil
}
func (s *SQLStore) GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error) {
row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.storage_key = ? LIMIT 1`, key)
img, err := scanImage(row)
if err != nil {
return domain.Image{}, err
}
badges, err := s.badgesFor(ctx, []string{img.ID})
if err != nil {
return domain.Image{}, err
}
img.Badges = badges[img.ID]
return img, nil
}
// Ping verifies the database is reachable. It backs the readiness probe, which
// reports the service as unavailable without restarting it.
func (s *SQLStore) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
// PingContext is an alias for Ping kept for callers that name the method after
// database/sql's own convention.
func (s *SQLStore) PingContext(ctx context.Context) error { return s.db.PingContext(ctx) }
func (s *SQLStore) UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE images
SET caption = ?, latitude = ?, longitude = ?, taken_at = ?,
camera_make = ?, camera_model = ?,
analysis_status = ?, analysis_error = ?
WHERE id = ?`,
img.Caption,
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
nullableTime(img.TakenAt),
img.CameraMake, img.CameraModel,
string(img.AnalysisStatus), img.AnalysisError, img.ID,
)
if err != nil {
return domain.Image{}, wrapWrite(err, "image")
}
if n, _ := res.RowsAffected(); n == 0 {
return domain.Image{}, ErrNotFound
}
return s.GetImage(ctx, img.ID)
}
func (s *SQLStore) ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error) {
var (
where []string
args []any
)
if f.PlaceID != "" {
where = append(where, "i.place_id = ?")
args = append(args, f.PlaceID)
}
if f.CreatorID != "" {
where = append(where, "p.creator_id = ?")
args = append(args, f.CreatorID)
}
if f.Within != nil {
// Pre-filter with the bounding box; the exact haversine test is applied
// by the service layer afterwards.
b := f.Within.BoundingBox()
where = append(where,
"i.latitude IS NOT NULL", "i.longitude IS NOT NULL",
"i.latitude BETWEEN ? AND ?", "i.longitude BETWEEN ? AND ?")
args = append(args, b.MinLat, b.MaxLat, b.MinLon, b.MaxLon)
}
query := imageSelect
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
query += " ORDER BY i.created_at DESC"
if f.Limit > 0 {
query += " LIMIT ?"
args = append(args, f.Limit)
if f.Offset > 0 {
query += " OFFSET ?"
args = append(args, f.Offset)
}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list images: %w", err)
}
defer rows.Close()
var out []domain.Image
var ids []string
for rows.Next() {
img, err := scanImage(rows)
if err != nil {
return nil, err
}
out = append(out, img)
ids = append(ids, img.ID)
}
if err := rows.Err(); err != nil {
return nil, err
}
badges, err := s.badgesFor(ctx, ids)
if err != nil {
return nil, err
}
for i := range out {
out[i].Badges = badges[out[i].ID]
}
return out, nil
}
func (s *SQLStore) ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error) {
return s.ListImages(ctx, ImageFilter{Within: &q})
}
func (s *SQLStore) ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin badge transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM badges WHERE image_id = ?`, imageID); err != nil {
return fmt.Errorf("store: clear badges: %w", err)
}
for _, b := range badges {
if _, err := tx.ExecContext(ctx, `
INSERT OR REPLACE INTO badges (image_id, label, display, confidence, source)
VALUES (?, ?, ?, ?, ?)`,
imageID, b.Label, b.Display, b.Confidence, b.Source,
); err != nil {
return fmt.Errorf("store: insert badge: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit badges: %w", err)
}
return nil
}
func (s *SQLStore) SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE images SET analysis_status = ?, analysis_error = ? WHERE id = ?`,
string(status), failure, imageID)
if err != nil {
return fmt.Errorf("store: set analysis state: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLStore) DeleteImage(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete image: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// -------------------------------------------------------------- image links ---
func (s *SQLStore) CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error {
if imageID == relatedImageID {
return fmt.Errorf("%w: a picture cannot be linked to itself", ErrInvalidInput)
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO image_links (image_id, related_image_id, distance_meters, reason, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(image_id, related_image_id) DO UPDATE SET
distance_meters = excluded.distance_meters,
reason = excluded.reason`,
imageID, relatedImageID, distanceMeters, reason, formatTime(time.Now().UTC()))
if err != nil {
return wrapWrite(err, "image link")
}
return nil
}
func (s *SQLStore) ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) {
// The stored rows record the direction they were written in, so this query
// flips the orientation for rows where the picture is the related end.
rows, err := s.db.QueryContext(ctx, `
SELECT image_id, related_image_id, distance_meters, reason,
CASE WHEN image_id = ? THEN 0 ELSE 1 END AS direction
FROM image_links
WHERE image_id = ? OR related_image_id = ?
ORDER BY distance_meters ASC, related_image_id ASC`, imageID, imageID, imageID)
if err != nil {
return nil, fmt.Errorf("store: list image links: %w", err)
}
defer rows.Close()
var out []domain.Link
for rows.Next() {
var (
l domain.Link
storedA string
storedB string
direction int
)
if err := rows.Scan(&storedA, &storedB, &l.DistanceMeters, &l.Reason, &direction); err != nil {
return nil, fmt.Errorf("store: scan image link: %w", err)
}
if direction == 0 {
l.ImageID, l.RelatedImageID = storedA, storedB
} else {
l.ImageID, l.RelatedImageID = storedB, storedA
}
l.SamePlace = l.Reason == "same-place"
out = append(out, l)
}
return out, rows.Err()
}
// -------------------------------------------------------------- comments ---
func (s *SQLStore) AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error) {
if c.ID == "" {
c.ID = newID()
}
if c.CreatedAt.IsZero() {
c.CreatedAt = time.Now().UTC()
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO comments (id, place_id, content, creator_id, created_at)
VALUES (?, ?, ?, ?, ?)`,
c.ID, c.PlaceID, c.Content, c.Creator.ID, formatTime(c.CreatedAt))
if err != nil {
return domain.Comment{}, wrapWrite(err, "comment")
}
return c, nil
}
func (s *SQLStore) ListComments(ctx context.Context, placeID string) ([]domain.Comment, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT c.id, c.place_id, c.content, c.created_at,
u.id, u.username, u.email
FROM comments c
JOIN users u ON u.id = c.creator_id
WHERE c.place_id = ?
ORDER BY c.created_at`, placeID)
if err != nil {
return nil, fmt.Errorf("store: list comments: %w", err)
}
defer rows.Close()
var out []domain.Comment
for rows.Next() {
var (
c domain.Comment
created timeText
)
err := rows.Scan(&c.ID, &c.PlaceID, &c.Content, &created, &c.Creator.ID, &c.Creator.Username, &c.Creator.Email)
if err != nil {
return nil, fmt.Errorf("store: scan comment: %w", err)
}
c.CreatedAt = created.Time
out = append(out, c)
}
return out, rows.Err()
}
// ----------------------------------------------------------------- users ---
func (s *SQLStore) UpsertUser(ctx context.Context, u domain.User) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO users (id, username, email, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
username = CASE WHEN excluded.username = '' THEN users.username ELSE excluded.username END,
email = CASE WHEN excluded.email = '' THEN users.email ELSE excluded.email END`,
u.ID, u.Username, u.Email, formatTime(time.Now().UTC()))
if err != nil {
return wrapWrite(err, "user")
}
return nil
}
func (s *SQLStore) GetUser(ctx context.Context, id string) (domain.User, error) {
var u domain.User
err := s.db.QueryRowContext(ctx,
`SELECT id, username, email FROM users WHERE id = ?`, id).
Scan(&u.ID, &u.Username, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return domain.User{}, ErrNotFound
}
if err != nil {
return domain.User{}, fmt.Errorf("store: get user: %w", err)
}
return u, nil
}
// --------------------------------------------------------------- helpers ---
const imageSelect = `
SELECT i.id, i.place_id, i.storage_key, i.content_type, i.byte_size,
i.width, i.height, i.caption, i.latitude, i.longitude, i.taken_at,
i.camera_make, i.camera_model, i.analysis_status, i.analysis_error,
i.created_at, p.creator_id
FROM images i
JOIN places p ON p.id = i.place_id`
// scanner is satisfied by both *sql.Row and *sql.Rows.
type scanner interface{ Scan(dest ...any) error }
func scanPlace(sc scanner) (domain.Place, error) {
var (
p domain.Place
lat sql.NullFloat64
lon sql.NullFloat64
created timeText
)
err := sc.Scan(&p.ID, &p.Title, &p.Description, &lat, &lon,
&p.LinkRadiusMeters, &created,
&p.Creator.ID, &p.Creator.Username, &p.Creator.Email)
if errors.Is(err, sql.ErrNoRows) {
return domain.Place{}, ErrNotFound
}
if err != nil {
return domain.Place{}, fmt.Errorf("store: scan place: %w", err)
}
if lat.Valid && lon.Valid {
p.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64}
}
p.CreatedAt = created.Time
return p, nil
}
func scanImage(sc scanner) (domain.Image, error) {
var (
img domain.Image
lat sql.NullFloat64
lon sql.NullFloat64
takenAt nullTimeText
createdAt timeText
status string
creatorID string
)
err := sc.Scan(&img.ID, &img.PlaceID, &img.StorageKey, &img.ContentType, &img.ByteSize,
&img.Width, &img.Height, &img.Caption, &lat, &lon, &takenAt,
&img.CameraMake, &img.CameraModel, &status, &img.AnalysisError, &createdAt, &creatorID)
if errors.Is(err, sql.ErrNoRows) {
return domain.Image{}, ErrNotFound
}
if err != nil {
return domain.Image{}, fmt.Errorf("store: scan image: %w", err)
}
if lat.Valid && lon.Valid {
img.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64}
}
img.TakenAt = takenAt.Time
img.CreatedAt = createdAt.Time
img.AnalysisStatus = domain.AnalysisStatus(status)
return img, nil
}
// badgesFor loads badges for many pictures in one round trip.
func (s *SQLStore) badgesFor(ctx context.Context, imageIDs []string) (map[string][]domain.Badge, error) {
out := map[string][]domain.Badge{}
if len(imageIDs) == 0 {
return out, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(imageIDs)), ",")
args := make([]any, len(imageIDs))
for i, id := range imageIDs {
args[i] = id
}
rows, err := s.db.QueryContext(ctx, `
SELECT image_id, label, display, confidence, source
FROM badges
WHERE image_id IN (`+placeholders+`)
ORDER BY confidence DESC, label ASC`, args...)
if err != nil {
return nil, fmt.Errorf("store: load badges: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
imageID string
b domain.Badge
)
if err := rows.Scan(&imageID, &b.Label, &b.Display, &b.Confidence, &b.Source); err != nil {
return nil, fmt.Errorf("store: scan badge: %w", err)
}
out[imageID] = append(out[imageID], b)
}
return out, rows.Err()
}
func newID() string { return uuid.NewString() }
func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
func nullableFloat(c *domain.Coordinate, get func(domain.Coordinate) float64) any {
if c == nil {
return nil
}
return get(*c)
}
func nullableTime(t *time.Time) any {
if t == nil {
return nil
}
return formatTime(*t)
}
// timeText scans an RFC3339 timestamp column into a time.Time.
type timeText struct{ Time time.Time }
// Scan implements sql.Scanner.
func (t *timeText) Scan(src any) error {
var s string
switch v := src.(type) {
case string:
s = v
case []byte:
s = string(v)
case time.Time:
t.Time = v.UTC()
return nil
case nil:
return errors.New("store: unexpected NULL timestamp")
default:
return fmt.Errorf("store: cannot scan %T into time", src)
}
// Try RFC3339Nano first, then date-only format YYYY-MM-DD
parsedTime := time.Time{}
if parsed, err := time.Parse(time.RFC3339Nano, s); err == nil {
parsedTime = parsed
} else if parsed, err := time.Parse("2006-01-02", s); err == nil {
parsedTime = parsed
} else {
return fmt.Errorf("store: parse timestamp %q: %w", s, nil)
}
t.Time = parsedTime.UTC()
return nil
}
// nullTimeText scans a nullable RFC3339 timestamp column.
type nullTimeText struct{ Time *time.Time }
// Scan implements sql.Scanner.
func (t *nullTimeText) Scan(src any) error {
if src == nil {
t.Time = nil
return nil
}
var inner timeText
if err := inner.Scan(src); err != nil {
return err
}
v := inner.Time
t.Time = &v
return nil
}
// wrapWrite converts driver constraint failures into the package sentinel
// errors so callers can branch on them with errors.Is.
func wrapWrite(err error, entity string) error {
if err == nil {
return nil
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "unique constraint failed"),
strings.Contains(msg, "constraint failed"):
return fmt.Errorf("%w: duplicate %s: %v", ErrConflict, entity, err)
case errors.Is(err, sql.ErrNoRows):
return ErrNotFound
default:
return fmt.Errorf("store: write %s: %w", entity, err)
}
}

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

@ -0,0 +1,116 @@
// Package store defines persistence for the domain entities. The interface
// exists so that the query implementation can be swapped (a different SQL
// dialect, or an in-memory fake in tests) without touching the service layer.
package store
import (
"context"
"errors"
"go-places-api/internal/domain"
"go-places-api/internal/geo"
)
var (
// ErrNotFound is returned when a lookup matches no row.
ErrNotFound = errors.New("not found")
// ErrConflict is returned when a unique constraint would be violated.
ErrConflict = errors.New("conflict")
)
// ImageFilter narrows a picture search.
type ImageFilter struct {
// PlaceID restricts results to one place when non-empty.
PlaceID string
// CreatorID restricts results to one author when non-empty.
CreatorID string
// Within restricts results to pictures inside this box and radius. A nil
// Coordinate disables the geographic restriction.
Within *NearbyQuery
// Limit caps the number of rows. Zero means unlimited.
Limit int
// Offset skips rows for pagination.
Offset int
}
// NearbyQuery selects entities within a radius of a point.
type NearbyQuery struct {
Center domain.Coordinate
RadiusMeters float64
}
// Store is the persistence contract required by the service layer.
type Store interface {
// CreatePlace inserts a place and returns it as persisted.
CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error)
// GetPlace loads a place, its pictures, comments and author.
GetPlace(ctx context.Context, id string) (domain.Place, error)
// UpdatePlace replaces the mutable fields of a place.
UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error)
// ListPlaces returns places newest first.
ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error)
// DeletePlace removes a place and everything attached to it.
DeletePlace(ctx context.Context, id string) error
// CreateImage inserts a picture record.
CreateImage(ctx context.Context, img domain.Image) (domain.Image, error)
// GetImage loads one picture.
GetImage(ctx context.Context, id string) (domain.Image, error)
// GetImageByStorageKey loads the picture a blob key belongs to. The media
// route needs it to learn the declared content type and size of a stored
// object without trusting the file's own bytes.
GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error)
// UpdateImage replaces the mutable fields of a picture.
UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error)
// ListImages returns pictures matching the filter, newest first.
ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error)
// ImagesWithin returns pictures whose capture point lies inside the radius.
ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error)
// ReplaceBadges swaps the badge set for a picture.
ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error
// CreateImageLink records that two pictures were taken at the same place.
// Re-recording the same pair updates the measured distance.
CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error
// ListImageLinks returns the recorded links for a picture in both
// directions, so a link is visible from either end.
ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error)
// SetAnalysisState records the outcome of the analysis pipeline.
SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error
// DeleteImage removes a picture and its badges.
DeleteImage(ctx context.Context, id string) error
// AddComment inserts a comment and returns it as persisted.
AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error)
// ListComments returns the comments on a place, oldest first.
ListComments(ctx context.Context, placeID string) ([]domain.Comment, error)
// UpsertUser stores the identity asserted by the auth layer.
UpsertUser(ctx context.Context, u domain.User) error
// GetUser loads a user by identifier.
GetUser(ctx context.Context, id string) (domain.User, error)
// Close releases resources held by the store.
Close() error
}
// BoundingBox converts a nearby query into the coarse window used to pre-filter
// rows in SQL. Exposed here so implementations and tests agree on the filter.
func (q NearbyQuery) BoundingBox() geo.BoundingBox {
return geo.Around(q.Center, q.RadiusMeters)
}
// ErrInvalidInput is returned when a write would violate a domain rule, such as
// linking a picture to itself.
var ErrInvalidInput = errors.New("invalid input")
// ImageGetter is the optional extra surface the service uses for storage-key
// lookups. It is separate from Store so an implementation that cannot map a key
// back to a record simply does not satisfy it.
type ImageGetter interface {
GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error)
}
// Pinger is the optional extra surface used by the readiness probe.
type Pinger interface {
Ping(ctx context.Context) error
}

View file

@ -0,0 +1,667 @@
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"go-places-api/internal/domain"
)
// newTestStore opens a throwaway SQLite database. The foreign_keys pragma is
// requested explicitly because SQLite ignores declared constraints unless it is
// enabled per connection.
func newTestStore(t *testing.T) *SQLStore {
t.Helper()
dsn := "file:" + filepath.Join(t.TempDir(), "go-places.db") +
"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)"
s, err := OpenSQL(dsn)
if err != nil {
t.Fatalf("OpenSQL: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
// seedUser inserts the author every place and comment must reference.
func seedUser(t *testing.T, s *SQLStore, id string) domain.User {
t.Helper()
u := domain.User{ID: id, Username: "ada-" + id, Email: id + "@example.com"}
if err := s.UpsertUser(context.Background(), u); err != nil {
t.Fatalf("UpsertUser: %v", err)
}
return u
}
// seedPlace creates a place at an optional coordinate.
func seedPlace(t *testing.T, s *SQLStore, creator, title string, coord *domain.Coordinate) domain.Place {
t.Helper()
p, err := s.CreatePlace(context.Background(), domain.Place{
Title: title,
Description: "notes for " + title,
Coordinate: coord,
LinkRadiusMeters: 150,
Creator: domain.User{ID: creator},
})
if err != nil {
t.Fatalf("CreatePlace(%q): %v", title, err)
}
return p
}
// seedImage creates a picture belonging to a place.
func seedImage(t *testing.T, s *SQLStore, placeID, key string, coord *domain.Coordinate) domain.Image {
t.Helper()
img, err := s.CreateImage(context.Background(), domain.Image{
PlaceID: placeID,
StorageKey: key,
ContentType: "image/jpeg",
ByteSize: 2048,
Coordinate: coord,
AnalysisStatus: domain.AnalysisPending,
})
if err != nil {
t.Fatalf("CreateImage(%q): %v", key, err)
}
return img
}
func coord(lat, lon float64) *domain.Coordinate {
return &domain.Coordinate{Latitude: lat, Longitude: lon}
}
func TestPlaceRoundTrip(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-1")
created := seedPlace(t, s, user.ID, "Montmartre", coord(48.8867, 2.3431))
if created.ID == "" {
t.Error("CreatePlace did not assign an ID")
}
if created.CreatedAt.IsZero() {
t.Error("CreatedAt was not set")
}
got, err := s.GetPlace(ctx, created.ID)
if err != nil {
t.Fatalf("GetPlace: %v", err)
}
if got.Title != "Montmartre" || got.Description != "notes for Montmartre" {
t.Errorf("round trip lost fields: %+v", got)
}
if got.Coordinate == nil {
t.Fatal("Coordinate was not persisted")
}
if d := got.Coordinate.Latitude - 48.8867; d > 1e-9 || d < -1e-9 {
t.Errorf("Latitude = %v, want 48.8867", got.Coordinate.Latitude)
}
if got.LinkRadiusMeters != 150 {
t.Errorf("LinkRadiusMeters = %v, want 150", got.LinkRadiusMeters)
}
if got.Creator.ID != user.ID {
t.Errorf("Creator.ID = %q, want %q", got.Creator.ID, user.ID)
}
}
func TestPlaceWithoutCoordinateStaysNil(t *testing.T) {
t.Parallel()
// (0,0) is a legitimate coordinate, so "unset" must be represented by nil
// rather than by zero values. Check the round trip does not invent a point.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-nocoord")
created := seedPlace(t, s, user.ID, "Unknown Spot", nil)
got, err := s.GetPlace(ctx, created.ID)
if err != nil {
t.Fatalf("GetPlace: %v", err)
}
if got.Coordinate != nil {
t.Errorf("Coordinate = %+v, want nil", got.Coordinate)
}
}
func TestGetPlaceMissingReturnsErrNotFound(t *testing.T) {
t.Parallel()
s := newTestStore(t)
if _, err := s.GetPlace(context.Background(), "does-not-exist"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestUpdatePlaceReplacesMutableFields(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-2")
created := seedPlace(t, s, user.ID, "Old Title", coord(10, 10))
created.Title = "New Title"
created.Description = "rewritten"
created.LinkRadiusMeters = 42
updated, err := s.UpdatePlace(ctx, created)
if err != nil {
t.Fatalf("UpdatePlace: %v", err)
}
if updated.Title != "New Title" || updated.LinkRadiusMeters != 42 {
t.Errorf("UpdatePlace returned %+v", updated)
}
got, err := s.GetPlace(ctx, created.ID)
if err != nil {
t.Fatalf("GetPlace: %v", err)
}
if got.Title != "New Title" || got.Description != "rewritten" || got.LinkRadiusMeters != 42 {
t.Errorf("stored value did not change: %+v", got)
}
}
func TestListPlacesIsNewestFirstAndPaginates(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-3")
// CreatedAt is stamped by the store, so insert in sequence and rely on the
// sub-millisecond difference the store writes.
const n = 3
for i := range n {
seedPlace(t, s, user.ID, "place-"+string(rune('a'+i)), nil)
time.Sleep(2 * time.Millisecond)
}
all, err := s.ListPlaces(ctx, 0, 0)
if err != nil {
t.Fatalf("ListPlaces: %v", err)
}
if len(all) != n {
t.Fatalf("ListPlaces returned %d places, want %d", len(all), n)
}
for i := 1; i < len(all); i++ {
if all[i-1].CreatedAt.Before(all[i].CreatedAt) {
t.Errorf("results are not newest first: %v then %v", all[i-1].CreatedAt, all[i].CreatedAt)
}
}
page, err := s.ListPlaces(ctx, 1, 0)
if err != nil {
t.Fatalf("ListPlaces(limit=1): %v", err)
}
if len(page) != 1 {
t.Errorf("limit=1 returned %d places", len(page))
}
if page[0].ID != all[0].ID {
t.Errorf("first page = %q, want the newest %q", page[0].ID, all[0].ID)
}
second, err := s.ListPlaces(ctx, 1, 1)
if err != nil {
t.Fatalf("ListPlaces(limit=1,offset=1): %v", err)
}
if len(second) != 1 || second[0].ID != all[1].ID {
t.Errorf("offset did not skip one row: %+v", second)
}
}
func TestDeletePlaceCascadesToImagesCommentsAndLinks(t *testing.T) {
t.Parallel()
// The schema relies on ON DELETE CASCADE. If foreign keys are not enabled the
// rows survive, which would leak every attachment of a deleted place.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-4")
place := seedPlace(t, s, user.ID, "Doomed", coord(1, 1))
other := seedPlace(t, s, user.ID, "Survivor", coord(1, 1))
a := seedImage(t, s, place.ID, "a.jpg", coord(1, 1))
b := seedImage(t, s, other.ID, "b.jpg", coord(1, 1))
if _, err := s.AddComment(ctx, domain.Comment{
PlaceID: place.ID, Content: "nice", Creator: domain.User{ID: user.ID},
}); err != nil {
t.Fatalf("AddComment: %v", err)
}
if err := s.CreateImageLink(ctx, a.ID, b.ID, 3.5, "same-place"); err != nil {
t.Fatalf("CreateImageLink: %v", err)
}
if err := s.DeletePlace(ctx, place.ID); err != nil {
t.Fatalf("DeletePlace: %v", err)
}
if _, err := s.GetPlace(ctx, place.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("place still readable: err = %v", err)
}
if _, err := s.GetImage(ctx, a.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("image survived the cascade: err = %v", err)
}
comments, err := s.ListComments(ctx, place.ID)
if err != nil {
t.Fatalf("ListComments: %v", err)
}
if len(comments) != 0 {
t.Errorf("%d comments survived the cascade", len(comments))
}
links, err := s.ListImageLinks(ctx, b.ID)
if err != nil {
t.Fatalf("ListImageLinks: %v", err)
}
if len(links) != 0 {
t.Errorf("%d links survived the cascade", len(links))
}
// The unrelated picture must be untouched.
if _, err := s.GetImage(ctx, b.ID); err != nil {
t.Errorf("unrelated image was removed: %v", err)
}
}
func TestImageCarriesMetadataAndBadges(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-5")
place := seedPlace(t, s, user.ID, "With Photo", coord(48, 2))
taken := time.Date(2026, 6, 1, 14, 23, 45, 0, time.UTC)
img, err := s.CreateImage(ctx, domain.Image{
PlaceID: place.ID,
StorageKey: "photos/eiffel.jpg",
ContentType: "image/jpeg",
ByteSize: 4096,
Width: 1920,
Height: 1080,
Caption: "the view",
Coordinate: coord(48.8584, 2.2945),
TakenAt: &taken,
CameraMake: "Canon",
CameraModel: "EOS R6",
})
if err != nil {
t.Fatalf("CreateImage: %v", err)
}
if img.AnalysisStatus != domain.AnalysisPending {
t.Errorf("AnalysisStatus = %q, want %q", img.AnalysisStatus, domain.AnalysisPending)
}
if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{
{Label: "sky", Display: "Sky", Confidence: 0.91, Source: "local"},
{Label: "water", Display: "Water", Confidence: 0.42, Source: "local"},
}); err != nil {
t.Fatalf("ReplaceBadges: %v", err)
}
got, err := s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if got.Caption != "the view" || got.Width != 1920 || got.Height != 1080 {
t.Errorf("image fields lost: %+v", got)
}
if got.TakenAt == nil || !got.TakenAt.Equal(taken) {
t.Errorf("TakenAt = %v, want %v", got.TakenAt, taken)
}
if got.CameraMake != "Canon" || got.CameraModel != "EOS R6" {
t.Errorf("camera = %q/%q, want Canon/EOS R6", got.CameraMake, got.CameraModel)
}
if len(got.Badges) != 2 {
t.Fatalf("got %d badges, want 2", len(got.Badges))
}
// Badges come back keyed by label so callers can look one up.
byLabel := map[string]domain.Badge{}
for _, b := range got.Badges {
byLabel[b.Label] = b
}
if sky := byLabel["sky"]; sky.Display != "Sky" || sky.Source != "local" {
t.Errorf("sky badge = %+v", sky)
}
}
func TestReplaceBadgesIsIdempotent(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-6")
place := seedPlace(t, s, user.ID, "Repeated Analysis", nil)
img := seedImage(t, s, place.ID, "r.jpg", nil)
for range 2 {
if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{
{Label: "sky", Display: "Sky", Confidence: 0.5, Source: "local"},
}); err != nil {
t.Fatalf("ReplaceBadges: %v", err)
}
}
got, err := s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if len(got.Badges) != 1 {
t.Errorf("ReplaceBadges duplicated rows: got %d badges", len(got.Badges))
}
}
func TestSetAnalysisStateRecordsFailure(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-7")
place := seedPlace(t, s, user.ID, "Failing", nil)
img := seedImage(t, s, place.ID, "f.jpg", nil)
if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, "decode failed"); err != nil {
t.Fatalf("SetAnalysisState: %v", err)
}
got, err := s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if got.AnalysisStatus != domain.AnalysisFailed {
t.Errorf("AnalysisStatus = %q, want %q", got.AnalysisStatus, domain.AnalysisFailed)
}
if got.AnalysisError != "decode failed" {
t.Errorf("AnalysisError = %q", got.AnalysisError)
}
// A later successful run must clear the stale error, otherwise a recovered
// picture keeps reporting why it failed.
if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisCompleted, ""); err != nil {
t.Fatalf("SetAnalysisState(completed): %v", err)
}
got, err = s.GetImage(ctx, img.ID)
if err != nil {
t.Fatalf("GetImage: %v", err)
}
if got.AnalysisStatus != domain.AnalysisCompleted || got.AnalysisError != "" {
t.Errorf("state = %q/%q, want completed with no error", got.AnalysisStatus, got.AnalysisError)
}
}
func TestGetImageByStorageKey(t *testing.T) {
t.Parallel()
// The media route uses this to serve a blob with the recorded content type
// and size instead of trusting the bytes on disk.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-8")
place := seedPlace(t, s, user.ID, "Keyed", nil)
want := seedImage(t, s, place.ID, "media/2026/photo.webp", nil)
got, err := s.GetImageByStorageKey(ctx, "media/2026/photo.webp")
if err != nil {
t.Fatalf("GetImageByStorageKey: %v", err)
}
if got.ID != want.ID {
t.Errorf("got image %q, want %q", got.ID, want.ID)
}
if got.ContentType != "image/webp" && got.ContentType != "image/jpeg" {
t.Errorf("ContentType = %q", got.ContentType)
}
if _, err := s.GetImageByStorageKey(ctx, "media/nope.jpg"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestImageLinkIsVisibleFromBothEnds(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-9")
place := seedPlace(t, s, user.ID, "Linked", coord(48.85, 2.29))
a := seedImage(t, s, place.ID, "a.jpg", coord(48.8566, 2.2943))
b := seedImage(t, s, place.ID, "b.jpg", coord(48.8570, 2.2950))
if err := s.CreateImageLink(ctx, a.ID, b.ID, 63.2, "same-place"); err != nil {
t.Fatalf("CreateImageLink: %v", err)
}
fromA, err := s.ListImageLinks(ctx, a.ID)
if err != nil {
t.Fatalf("ListImageLinks(a): %v", err)
}
if len(fromA) != 1 {
t.Fatalf("from a: got %d links, want 1", len(fromA))
}
if fromA[0].RelatedImageID != b.ID {
t.Errorf("from a: related = %q, want %q", fromA[0].RelatedImageID, b.ID)
}
if fromA[0].DistanceMeters != 63.2 {
t.Errorf("DistanceMeters = %v, want 63.2", fromA[0].DistanceMeters)
}
if fromA[0].Reason != "same-place" {
t.Errorf("Reason = %q, want same-place", fromA[0].Reason)
}
fromB, err := s.ListImageLinks(ctx, b.ID)
if err != nil {
t.Fatalf("ListImageLinks(b): %v", err)
}
if len(fromB) != 1 || fromB[0].RelatedImageID != a.ID {
t.Errorf("the link is not visible from b: %+v", fromB)
}
}
func TestCreateImageLinkUpdatesDistanceAndRejectsSelfLink(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-10")
place := seedPlace(t, s, user.ID, "Relinked", nil)
a := seedImage(t, s, place.ID, "a.jpg", nil)
b := seedImage(t, s, place.ID, "b.jpg", nil)
if err := s.CreateImageLink(ctx, a.ID, b.ID, 10, "within-radius"); err != nil {
t.Fatalf("CreateImageLink: %v", err)
}
// Re-running the linking pass must not create a duplicate edge.
if err := s.CreateImageLink(ctx, a.ID, b.ID, 12.5, "within-radius"); err != nil {
t.Fatalf("CreateImageLink again: %v", err)
}
links, err := s.ListImageLinks(ctx, a.ID)
if err != nil {
t.Fatalf("ListImageLinks: %v", err)
}
if len(links) != 1 {
t.Fatalf("got %d links, want 1", len(links))
}
if links[0].DistanceMeters != 12.5 {
t.Errorf("DistanceMeters = %v, want the updated 12.5", links[0].DistanceMeters)
}
if err := s.CreateImageLink(ctx, a.ID, a.ID, 0, "same-place"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("self link err = %v, want ErrInvalidInput", err)
}
}
func TestCommentsAreOldestFirstWithAuthor(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-11")
place := seedPlace(t, s, user.ID, "Talked About", nil)
for i, content := range []string{"first", "second", "third"} {
if _, err := s.AddComment(ctx, domain.Comment{
PlaceID: place.ID, Content: content, Creator: domain.User{ID: user.ID},
}); err != nil {
t.Fatalf("AddComment(%d): %v", i, err)
}
}
got, err := s.ListComments(ctx, place.ID)
if err != nil {
t.Fatalf("ListComments: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d comments, want 3", len(got))
}
want := []string{"first", "second", "third"}
for i, c := range got {
if c.Content != want[i] {
t.Errorf("comment %d = %q, want %q", i, c.Content, want[i])
}
if c.ID == "" {
t.Errorf("comment %d has no ID", i)
}
if c.Creator.ID != user.ID {
t.Errorf("comment %d creator = %q, want %q", i, c.Creator.ID, user.ID)
}
}
}
func TestAddCommentRejectsUnknownAuthor(t *testing.T) {
t.Parallel()
// A comment must not be able to invent an author that does not exist.
s := newTestStore(t)
ctx := context.Background()
user := seedUser(t, s, "user-12")
place := seedPlace(t, s, user.ID, "Guarded", nil)
_, err := s.AddComment(ctx, domain.Comment{
PlaceID: place.ID, Content: "ghost", Creator: domain.User{ID: "nobody"},
})
if err == nil {
t.Fatal("AddComment accepted an unknown author")
}
}
func TestListImagesFiltersByPlaceCreatorAndBox(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
ada := seedUser(t, s, "ada")
bo := seedUser(t, s, "bo")
eiffel := seedPlace(t, s, ada.ID, "Eiffel", coord(48.8584, 2.2945))
louvre := seedPlace(t, s, bo.ID, "Louvre", coord(48.8606, 2.3376))
near := seedImage(t, s, eiffel.ID, "near.jpg", coord(48.8585, 2.2946))
// Roughly 3 km away: outside a 150 m box but inside a 5 km one.
far := seedImage(t, s, louvre.ID, "far.jpg", coord(48.8730, 2.3320))
// No coordinate at all: geographic filters must skip it rather than fail.
noCoord := seedImage(t, s, eiffel.ID, "nolat.jpg", nil)
byPlace, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID})
if err != nil {
t.Fatalf("ListImages(place): %v", err)
}
if len(byPlace) != 2 {
t.Errorf("place filter returned %d images, want 2", len(byPlace))
}
byCreator, err := s.ListImages(ctx, ImageFilter{CreatorID: ada.ID})
if err != nil {
t.Fatalf("ListImages(creator): %v", err)
}
if len(byCreator) != 2 {
t.Errorf("creator filter returned %d images, want 2", len(byCreator))
}
// Creator and place filters combine.
both, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID, CreatorID: bo.ID})
if err != nil {
t.Fatalf("ListImages(place+creator): %v", err)
}
if len(both) != 0 {
t.Errorf("combined filter returned %d images, want 0", len(both))
}
tight := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 150}
withinTight, err := s.ListImages(ctx, ImageFilter{Within: &tight})
if err != nil {
t.Fatalf("ListImages(within 150m): %v", err)
}
if len(withinTight) != 1 || withinTight[0].ID != near.ID {
t.Errorf("150 m box returned %d images, want just the nearby one", len(withinTight))
}
for _, got := range withinTight {
if got.ID == far.ID {
t.Error("the 3 km image was inside the 150 m box")
}
}
wide := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 5000}
withinWide, err := s.ListImages(ctx, ImageFilter{Within: &wide})
if err != nil {
t.Fatalf("ListImages(within 5km): %v", err)
}
if len(withinWide) != 2 {
t.Errorf("5 km box returned %d images, want 2", len(withinWide))
}
ids := map[string]bool{}
for _, got := range withinWide {
ids[got.ID] = true
}
if !ids[near.ID] || !ids[far.ID] {
t.Error("5 km box should contain both the near and the far image")
}
if ids[noCoord.ID] {
t.Error("a picture without a coordinate must never match a geographic filter")
}
}
func TestUpsertUserIsIdempotentAndGetUserReadsBack(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
first := domain.User{ID: "u1", Username: "ada", Email: "ada@example.com"}
if err := s.UpsertUser(ctx, first); err != nil {
t.Fatalf("UpsertUser: %v", err)
}
// The auth layer re-asserts identity on every request, so a second call must
// update rather than fail.
second := domain.User{ID: "u1", Username: "ada-lovelace", Email: "ada@example.com"}
if err := s.UpsertUser(ctx, second); err != nil {
t.Fatalf("UpsertUser again: %v", err)
}
got, err := s.GetUser(ctx, "u1")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got.Username != "ada-lovelace" {
t.Errorf("Username = %q, want the updated value", got.Username)
}
if _, err := s.GetUser(ctx, "missing"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestPingSucceedsOnOpenStore(t *testing.T) {
t.Parallel()
s := newTestStore(t)
if err := s.Ping(context.Background()); err != nil {
t.Errorf("Ping: %v", err)
}
if err := s.PingContext(context.Background()); err != nil {
t.Errorf("PingContext: %v", err)
}
}

View file

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

View file

@ -0,0 +1,388 @@
package transport
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"go-places-api/internal/config"
)
// The tests in this file cover the REST surface behind ENABLE_REST, which the
// React client uses. The GraphQL surface has its own tests in transport_test.go.
//
// REST was added without the auth middleware at first, which left every place
// and picture readable by an anonymous caller; the first test here pins that
// closed.
// restServer returns a server with the REST routes enabled.
func restServer(t *testing.T) *Server {
t.Helper()
return newTestServer(t, func(c *config.Config) { c.EnableREST = true })
}
// apiRequest performs an authenticated REST call and returns the status and the
// decoded body. The body is decoded into any because the endpoints answer with
// both objects and arrays.
func apiRequest(t *testing.T, srv *Server, method, path string, body []byte, token string) (int, any) {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
// A non-JSON body (an error page, say) is still worth asserting on, so
// the text is returned as a plain string rather than failing the decode.
out = rec.Body.String()
}
return rec.Code, out
}
// object asserts that a decoded body is a JSON object and returns it.
func object(t *testing.T, body any) map[string]any {
t.Helper()
m, ok := body.(map[string]any)
if !ok {
t.Fatalf("body is %T, want a JSON object: %v", body, body)
}
return m
}
// array asserts that a decoded body is a JSON array and returns it.
func array(t *testing.T, body any) []any {
t.Helper()
a, ok := body.([]any)
if !ok {
t.Fatalf("body is %T, want a JSON array: %v", body, body)
}
return a
}
// restPlace creates a place over REST and returns its id.
func restPlace(t *testing.T, srv *Server, title string, lat, lon float64) string {
t.Helper()
body, err := json.Marshal(map[string]any{
"title": title, "description": "notes", "latitude": lat, "longitude": lon,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", body, "valid_token")
if code != http.StatusCreated {
t.Fatalf("create place status = %d, want 201: %v", code, resp)
}
id, _ := object(t, resp)["id"].(string)
if id == "" {
t.Fatalf("create place returned no id: %v", resp)
}
return id
}
// restUpload posts a PNG to a place and returns the status and image object.
func restUpload(t *testing.T, srv *Server, placeID string) (int, map[string]any) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 8, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255})
}
}
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
var body bytes.Buffer
w := multipart.NewWriter(&body)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="image"; filename="pic.png"`)
h.Set("Content-Type", "image/png")
part, err := w.CreatePart(h)
if err != nil {
t.Fatalf("create part: %v", err)
}
if _, err := part.Write(pngBuf.Bytes()); err != nil {
t.Fatalf("write part: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode upload response %q: %v", rec.Body.String(), err)
}
inner, _ := out["image"].(map[string]any)
return rec.Code, inner
}
func TestRESTRoutesRequireAuthentication(t *testing.T) {
srv := restServer(t)
placeID := restPlace(t, srv, "Guarded", 48.85, 2.29)
// Every route that reads or writes user data has to reject an anonymous
// caller. Without the token these endpoints returned a full listing.
tests := []struct {
name string
method string
path string
}{
{"list places", http.MethodGet, "/api/places"},
{"get place", http.MethodGet, "/api/places/" + placeID},
{"list place images", http.MethodGet, "/api/places/" + placeID + "/images"},
{"create place", http.MethodPost, "/api/places"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte(`{"title":"x"}`)))
if tc.method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
})
}
// And a wrong token is rejected just as firmly.
code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "not-a-real-token")
if code != http.StatusUnauthorized {
t.Errorf("bad token status = %d, want 401", code)
}
}
func TestRESTListsPlacesAndServesOne(t *testing.T) {
srv := restServer(t)
id := restPlace(t, srv, "Listed", 48.8584, 2.2945)
code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("list status = %d: %v", code, list)
}
if got := array(t, list); len(got) != 1 {
t.Errorf("list returned %d places, want 1", len(got))
}
code, rawOne := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawOne)
}
one := object(t, rawOne)
if one["title"] != "Listed" {
t.Errorf("title = %v, want Listed", one["title"])
}
if one["id"] != id {
t.Errorf("id = %v, want %q", one["id"], id)
}
code, _ = apiRequest(t, srv, http.MethodGet, "/api/places/nope", nil, "valid_token")
if code != http.StatusNotFound {
t.Errorf("unknown place status = %d, want 404", code)
}
}
func TestRESTPlaceCarriesImageURLs(t *testing.T) {
// The stored record has no URL column, it is derived from the storage key.
// When the REST handler skipped that step the client received pictures it
// could not render.
srv := restServer(t)
id := restPlace(t, srv, "With Photo", 48.8584, 2.2945)
code, img := restUpload(t, srv, id)
if code != http.StatusCreated {
t.Fatalf("upload status = %d, want 201", code)
}
wantURL, _ := img["url"].(string)
if wantURL == "" {
t.Fatalf("upload response has no url: %v", img)
}
code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawPlace)
}
place := object(t, rawPlace)
images, _ := place["images"].([]any)
if len(images) != 1 {
t.Fatalf("place carries %d images, want 1", len(images))
}
if got, _ := images[0].(map[string]any)["url"].(string); got != wantURL {
t.Errorf("picture url = %q, want %q", got, wantURL)
}
// The image listing has to carry the URL too.
code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/images", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("images status = %d: %v", code, rawListed)
}
listed := array(t, rawListed)
if got, _ := listed[0].(map[string]any)["url"].(string); got != wantURL {
t.Errorf("listed picture url = %q, want %q", got, wantURL)
}
}
func TestRESTListsEmptyArraysRatherThanNull(t *testing.T) {
// Go serialises a nil slice as JSON null. The client iterates these fields
// directly, so an empty result has to be [] rather than null.
srv := restServer(t)
code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("list status = %d: %v", code, list)
}
if got := array(t, list); len(got) != 0 {
t.Errorf("an empty list returned %d places", len(got))
}
id := restPlace(t, srv, "Bare", 10, 10)
code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawPlace)
}
place := object(t, rawPlace)
for _, field := range []string{"images", "comments", "relatedPlaces"} {
if place[field] == nil {
t.Errorf("%q is null on a place with nothing attached", field)
}
}
}
func TestRESTPlaceReportsRelatedPlaces(t *testing.T) {
// The same spot catalogued twice is the case the feature exists for, so the
// REST response has to surface the relationship.
srv := restServer(t)
first := restPlace(t, srv, "Eiffel (visit 1)", 48.8584, 2.2945)
second := restPlace(t, srv, "Eiffel (visit 2)", 48.8585, 2.2946)
if code, img := restUpload(t, srv, first); code != http.StatusCreated {
t.Fatalf("first upload status = %d", code)
} else if img["id"] == "" {
t.Fatal("first upload returned no id")
}
if code, _ := restUpload(t, srv, second); code != http.StatusCreated {
t.Fatalf("second upload status = %d", code)
}
code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+second, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawPlace)
}
place := object(t, rawPlace)
related, _ := place["relatedPlaces"].([]any)
if len(related) != 1 {
t.Fatalf("got %d related places, want 1", len(related))
}
entry := related[0].(map[string]any)
if got, _ := entry["place"].(map[string]any)["id"].(string); got != first {
t.Errorf("related place id = %q, want %q", got, first)
}
if _, ok := entry["distanceMeters"].(float64); !ok {
t.Errorf("distanceMeters missing: %v", entry)
}
// The relationship must not nest into itself.
nested, _ := entry["place"].(map[string]any)["relatedPlaces"].([]any)
if len(nested) != 0 {
t.Errorf("the nested place carries %d related places, want 0", len(nested))
}
}
func TestRESTPictureReportsLinkedImages(t *testing.T) {
// Pictures in different place records at the same coordinates are linked, and
// the link has to be visible from the picture listing.
srv := restServer(t)
first := restPlace(t, srv, "Spot A", 48.8584, 2.2945)
second := restPlace(t, srv, "Spot B", 48.8585, 2.2946)
restUpload(t, srv, first)
code, secondImg := restUpload(t, srv, second)
if code != http.StatusCreated {
t.Fatalf("upload status = %d", code)
}
linked, _ := secondImg["linkedImages"].([]any)
if len(linked) != 1 {
t.Fatalf("got %d linked images, want 1: %v", len(linked), secondImg)
}
code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+second+"/images", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("images status = %d: %v", code, rawListed)
}
listed := array(t, rawListed)
listedLinks, _ := listed[0].(map[string]any)["linkedImages"].([]any)
if len(listedLinks) != 1 {
t.Errorf("the listing reports %d linked images, want 1", len(listedLinks))
}
}
func TestRESTRejectsInvalidInputWithJSON(t *testing.T) {
// The error shape has to match the success shape, otherwise a client has to
// parse two formats.
srv := restServer(t)
code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`{"title":""}`), "valid_token")
if code != http.StatusBadRequest {
t.Errorf("empty title status = %d, want 400: %v", code, resp)
}
if msg, _ := object(t, resp)["error"].(string); msg == "" {
t.Errorf("error body has no message: %v", resp)
}
code, resp = apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`not json`), "valid_token")
if code != http.StatusBadRequest {
t.Errorf("malformed body status = %d, want 400: %v", code, resp)
}
}
func TestRESTUnknownSubresourceIsNotAPlaceLookup(t *testing.T) {
// GET /api/places/ is a prefix match, so a deeper path arrives at the same
// handler. IDs are UUIDs, so a path with a slash in it is an unknown route
// rather than a place that happens not to exist.
srv := restServer(t)
id := restPlace(t, srv, "Spot", 48.8584, 2.2945)
code, body := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/comments", nil, "valid_token")
if code != http.StatusNotFound {
t.Errorf("status = %d, want 404 for an unknown subresource: %v", code, body)
}
if msg, _ := object(t, body)["error"].(string); !strings.Contains(msg, "resource") {
t.Errorf("error = %q, want it to name the missing resource", msg)
}
}
func TestRESTDisabledByDefault(t *testing.T) {
// ENABLE_REST defaults to off, so a deployment that did not opt in must not
// expose the routes at all.
srv := newTestServer(t)
code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
if code != http.StatusNotFound {
t.Errorf("status = %d, want 404 when REST is disabled", code)
}
}

View file

@ -0,0 +1,513 @@
// Package transport exposes the service over HTTP: a GraphQL endpoint for the
// data, a multipart endpoint for picture uploads, read-only serving of stored
// media, and the health probes the Kubernetes charts poll.
package transport
import (
"context"
"errors"
"github.com/graphql-go/graphql"
"go-places-api/internal/auth"
"go-places-api/internal/domain"
"go-places-api/internal/service"
"go-places-api/internal/store"
)
// The GraphQL type set mirrors the domain structs. Field names line up with the
// `json` tags on the domain models, so graphql-go's default resolver already
// populates most fields; explicit resolvers are only needed where a value has to
// be computed (image URLs, linked pictures, related places) or where the raw
// domain type needs converting (AnalysisStatus, LinkRadiusMeters).
//
// Image, Place and RelatedPlace refer to each other, so their fields are built
// by thunks that graphql-go evaluates lazily. Declaring them eagerly would mean
// a type had to exist before it was defined.
var (
badgeType = graphql.NewObject(graphql.ObjectConfig{
Name: "Badge",
Description: "A recognisable attribute detected in a picture.",
Fields: graphql.Fields{
"label": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"display": &graphql.Field{Type: graphql.String},
"confidence": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"source": &graphql.Field{Type: graphql.String},
},
})
coordinateType = graphql.NewObject(graphql.ObjectConfig{
Name: "Coordinate",
Description: "A WGS-84 geographic point.",
Fields: graphql.Fields{
"latitude": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"longitude": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
},
})
userType = graphql.NewObject(graphql.ObjectConfig{
Name: "User",
Description: "A logged-in user, as asserted by the go-loose SSO service.",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"username": &graphql.Field{Type: graphql.String},
"email": &graphql.Field{Type: graphql.String},
},
})
commentType = graphql.NewObject(graphql.ObjectConfig{
Name: "Comment",
Description: "A user reaction on a place.",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"placeId": &graphql.Field{Type: graphql.String},
"content": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"creator": &graphql.Field{Type: graphql.NewNonNull(userType)},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
},
})
// PlaceInput is the createPlace argument. Latitude and longitude are
// optional so an omitted coordinate can be told apart from the origin, which
// is a legitimate point.
placeInput = graphql.NewInputObject(graphql.InputObjectConfig{
Name: "PlaceInput",
Fields: graphql.InputObjectConfigFieldMap{
"title": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)},
"description": &graphql.InputObjectFieldConfig{Type: graphql.String},
"latitude": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"longitude": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"linkRadiusMeters": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"caption": &graphql.InputObjectFieldConfig{Type: graphql.String},
},
})
)
// Image, Place and RelatedPlace refer to each other, so each type is created
// once and cached on the builder before any field thunk runs. Building the type
// objects as thunks keeps the references valid: graphql-go evaluates a thunk the
// first time it needs the type's field map, which happens while NewSchema walks
// the type graph, by which point all three instances already exist.
//
// The builder is used exactly once, during startup, before the server serves any
// request, so the lazy caching needs no further synchronisation.
type builder struct {
svc *service.Service
image *graphql.Object
place *graphql.Object
related *graphql.Object
}
// imageType returns the shared Image type.
func (b *builder) imageType() *graphql.Object {
if b.image == nil {
b.image = graphql.NewObject(graphql.ObjectConfig{
Name: "Image",
Description: "A picture attached to a place.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"placeId": &graphql.Field{Type: graphql.String},
"url": &graphql.Field{Type: graphql.String, Resolve: nullOnError(resolveImageURL(b.svc))},
"caption": &graphql.Field{Type: graphql.String},
"contentType": &graphql.Field{Type: graphql.String},
"byteSize": &graphql.Field{Type: graphql.NewNonNull(graphql.Int)},
"width": &graphql.Field{Type: graphql.Int},
"height": &graphql.Field{Type: graphql.Int},
"coordinate": &graphql.Field{Type: coordinateType},
"takenAt": &graphql.Field{Type: graphql.DateTime},
"cameraMake": &graphql.Field{Type: graphql.String},
"cameraModel": &graphql.Field{Type: graphql.String},
"badges": &graphql.Field{Type: graphql.NewList(badgeType)},
"analysisStatus": &graphql.Field{
Type: graphql.String,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return nil, nil
}
return string(img.AnalysisStatus), nil
},
},
"analysisError": &graphql.Field{Type: graphql.String},
// LinkedImages is derived on read, so it is only ever computed
// when a client actually selects it.
"linkedImages": &graphql.Field{
Type: graphql.NewList(b.imageType()),
Resolve: nullOnError(resolveLinkedImages(b.svc)),
},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
}
}),
})
}
return b.image
}
// placeType returns the shared Place type.
func (b *builder) placeType() *graphql.Object {
if b.place == nil {
b.place = graphql.NewObject(graphql.ObjectConfig{
Name: "Place",
Description: "A location the user visited and shared.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"title": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"description": &graphql.Field{Type: graphql.String},
"coordinate": &graphql.Field{Type: coordinateType},
"linkRadiusMeters": &graphql.Field{
Type: graphql.Float,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
pl, ok := p.Source.(domain.Place)
if !ok {
return nil, nil
}
return pl.LinkRadiusMeters, nil
},
},
"creator": &graphql.Field{Type: graphql.NewNonNull(userType)},
"images": &graphql.Field{Type: graphql.NewList(b.imageType())},
"comments": &graphql.Field{Type: graphql.NewList(commentType)},
"relatedPlaces": &graphql.Field{
Type: graphql.NewList(b.relatedPlaceType()),
Resolve: nullOnError(resolveRelatedPlaces(b.svc)),
},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
}
}),
})
}
return b.place
}
// relatedPlaceType returns the shared RelatedPlace type.
func (b *builder) relatedPlaceType() *graphql.Object {
if b.related == nil {
b.related = graphql.NewObject(graphql.ObjectConfig{
Name: "RelatedPlace",
Description: "A place whose pictures overlap the queried one.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"place": &graphql.Field{Type: graphql.NewNonNull(b.placeType())},
"distanceMeters": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"sharedImageIds": &graphql.Field{Type: graphql.NewList(graphql.String)},
}
}),
})
}
return b.related
}
// newSchema assembles the executable schema.
func newSchema(svc *service.Service) (graphql.Schema, error) {
b := &builder{svc: svc}
// Instantiated up front so the thunks above observe cached instances rather
// than building a second copy of each type.
imageType, placeType, relatedType := b.imageType(), b.placeType(), b.relatedPlaceType()
query := graphql.NewObject(graphql.ObjectConfig{
Name: "Query",
Description: "Read access to the travel log.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"place": &graphql.Field{
Type: placeType,
Description: "One place by identifier.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolvePlace(b.svc)),
},
"places": &graphql.Field{
Type: graphql.NewList(placeType),
Description: "Places, newest first.",
Args: graphql.FieldConfigArgument{
"limit": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
"offset": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
},
Resolve: nullOnError(resolvePlaces(b.svc)),
},
"images": &graphql.Field{
Type: graphql.NewList(imageType),
Description: "Pictures, newest first.",
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.String},
"creatorId": &graphql.ArgumentConfig{Type: graphql.String},
"limit": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
"offset": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
},
Resolve: nullOnError(resolveImages(b.svc)),
},
"relatedPlaces": &graphql.Field{
Type: graphql.NewList(relatedType),
Description: "Places whose pictures overlap the given one.",
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveRelatedByID(b.svc)),
},
}
}),
})
mutation := graphql.NewObject(graphql.ObjectConfig{
Name: "Mutation",
Description: "Write access to the travel log.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"createPlace": &graphql.Field{
Type: placeType,
Args: graphql.FieldConfigArgument{
"input": &graphql.ArgumentConfig{Type: graphql.NewNonNull(placeInput)},
},
Resolve: nullOnError(resolveCreatePlace(b.svc)),
},
"addComment": &graphql.Field{
Type: commentType,
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
"content": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveAddComment(b.svc)),
},
"updateImageCaption": &graphql.Field{
Type: imageType,
Args: graphql.FieldConfigArgument{
"imageId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
"caption": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveUpdateImageCaption(b.svc)),
},
"deletePlace": &graphql.Field{
Type: graphql.Boolean,
Description: "Remove a place and everything attached to it.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveDeletePlace(b.svc)),
},
"deleteImage": &graphql.Field{
Type: graphql.Boolean,
Description: "Remove a picture and its stored bytes.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveDeleteImage(b.svc)),
},
}
}),
})
return graphql.NewSchema(graphql.SchemaConfig{
Query: query,
Mutation: mutation,
})
}
// ---------------------------------------------------------------- resolvers ---
//
// Each resolver is written as a typed function and wrapped by nullOnError, so a
// failure nulls the field instead of emitting the zero value of the domain type.
// graphql-go uses whatever a resolver returns, error or not, so returning the
// zero struct alongside an error would hand clients a plausible-looking empty
// record alongside the error.
// nullOnError adapts a typed resolver to a GraphQL field resolver, reporting a
// failure as a null field.
func nullOnError[T any](fn func(graphql.ResolveParams) (T, error)) graphql.FieldResolveFn {
return func(p graphql.ResolveParams) (interface{}, error) {
v, err := fn(p)
if err != nil {
return nil, err
}
return v, nil
}
}
func resolvePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
return func(p graphql.ResolveParams) (domain.Place, error) {
return svc.GetPlace(p.Context, p.Args["id"].(string))
}
}
func resolvePlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.Place, error) {
return func(p graphql.ResolveParams) ([]domain.Place, error) {
limit, offset := pageArgs(p.Args)
return svc.ListPlaces(p.Context, limit, offset)
}
}
func resolveImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
return func(p graphql.ResolveParams) ([]domain.Image, error) {
limit, offset := pageArgs(p.Args)
f := store.ImageFilter{Limit: limit, Offset: offset}
if v, ok := p.Args["placeId"].(string); ok {
f.PlaceID = v
}
if v, ok := p.Args["creatorId"].(string); ok {
f.CreatorID = v
}
return svc.ListImages(p.Context, f)
}
}
func resolveRelatedByID(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return svc.RelatedPlaces(p.Context, p.Args["placeId"].(string))
}
}
func resolveCreatePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
return func(p graphql.ResolveParams) (domain.Place, error) {
author, err := requireUser(p.Context)
if err != nil {
return domain.Place{}, err
}
raw, _ := p.Args["input"].(map[string]interface{})
return svc.CreatePlace(p.Context, author, decodePlaceInput(raw))
}
}
func resolveAddComment(svc *service.Service) func(graphql.ResolveParams) (domain.Comment, error) {
return func(p graphql.ResolveParams) (domain.Comment, error) {
author, err := requireUser(p.Context)
if err != nil {
return domain.Comment{}, err
}
return svc.AddComment(p.Context, author, p.Args["placeId"].(string), p.Args["content"].(string))
}
}
func resolveUpdateImageCaption(svc *service.Service) func(graphql.ResolveParams) (domain.Image, error) {
return func(p graphql.ResolveParams) (domain.Image, error) {
return svc.UpdateImageCaption(p.Context, p.Args["imageId"].(string), p.Args["caption"].(string))
}
}
func resolveDeletePlace(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
return func(p graphql.ResolveParams) (bool, error) {
if err := svc.DeletePlace(p.Context, p.Args["id"].(string)); err != nil {
return false, err
}
return true, nil
}
}
func resolveDeleteImage(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
return func(p graphql.ResolveParams) (bool, error) {
if err := svc.DeleteImage(p.Context, p.Args["id"].(string)); err != nil {
return false, err
}
return true, nil
}
}
// resolveImageURL turns the private storage key into the public URL. The key
// itself is never exposed.
func resolveImageURL(svc *service.Service) func(graphql.ResolveParams) (string, error) {
return func(p graphql.ResolveParams) (string, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return "", nil
}
return svc.ImageURL(img.StorageKey), nil
}
}
// resolveLinkedImages hydrates the neighbours discovered by the linking pass. The
// links are already ordered by distance, so the order is preserved.
func resolveLinkedImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
return func(p graphql.ResolveParams) ([]domain.Image, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return nil, nil
}
links, err := svc.ImageLinks(p.Context, img.ID)
if err != nil {
return nil, err
}
out := make([]domain.Image, 0, len(links))
for _, link := range links {
// Each neighbour is re-read so the nested selection sees its badges
// and URL just like a top-level picture.
related, err := svc.ImageByID(p.Context, link.RelatedImageID)
if err != nil {
// A neighbour that vanished between the two reads is skipped
// rather than failing the whole selection.
continue
}
out = append(out, related)
}
return out, nil
}
}
func resolveRelatedPlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
place, ok := p.Source.(domain.Place)
if !ok {
return nil, nil
}
related, err := svc.RelatedPlaces(p.Context, place.ID)
if err != nil {
return nil, err
}
if related == nil {
// An empty list rather than null, so clients can iterate safely.
return []domain.RelatedPlace{}, nil
}
return related, nil
}
}
// ------------------------------------------------------------------ helpers ---
// requireUser pulls the verified identity that the auth middleware placed on the
// context. Reaching this point without one means a route was mounted without the
// middleware, which is a wiring bug rather than a client error.
func requireUser(ctx context.Context) (domain.User, error) {
u, ok := auth.FromContext(ctx)
if !ok {
return domain.User{}, errors.New("transport: request is not authenticated")
}
return u.User, nil
}
// pageArgs reads the shared limit/offset arguments, tolerating their absence.
func pageArgs(args map[string]interface{}) (limit, offset int) {
if v, ok := args["limit"].(int); ok {
limit = v
}
if v, ok := args["offset"].(int); ok {
offset = v
}
return limit, offset
}
// decodePlaceInput converts the GraphQL input object into the service payload,
// turning absent numeric fields into nil pointers.
func decodePlaceInput(raw map[string]interface{}) domain.PlaceInput {
var in domain.PlaceInput
if v, ok := raw["title"].(string); ok {
in.Title = v
}
if v, ok := raw["description"].(string); ok {
in.Description = v
}
if v, ok := raw["caption"].(string); ok {
in.Caption = v
}
if v, ok := raw["latitude"].(float64); ok {
in.Latitude = &v
}
if v, ok := raw["longitude"].(float64); ok {
in.Longitude = &v
}
if v, ok := raw["linkRadiusMeters"].(float64); ok {
in.LinkRadiusMeters = &v
}
return in
}

View file

@ -0,0 +1,604 @@
package transport
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/graphql-go/graphql"
"go-places-api/internal/auth"
"go-places-api/internal/blobstore"
"go-places-api/internal/config"
"go-places-api/internal/domain"
"go-places-api/internal/service"
"go-places-api/internal/store"
)
// mediaPrefix is the path segment under which stored pictures are served. It
// matches the URLs that Service.ImageURL builds.
const mediaPrefix = "/media/"
// maxGraphQLBody bounds a GraphQL request document. A legitimate document is
// small, so this keeps a hostile client from filling memory.
const maxGraphQLBody = 1 << 20
// sniffLen is how much of an upload is examined to guess its content type when
// the client did not declare one.
const sniffLen = 512
// Options configures a Server.
type Options struct {
// Service holds the business logic the handlers delegate to.
Service *service.Service
// Config is the resolved runtime configuration.
Config config.Config
// Auth resolves bearer tokens on the protected routes.
Auth *auth.Middleware
// Blobs serves stored picture bytes.
Blobs blobstore.BlobStore
// Logger receives request and failure logs.
Logger *slog.Logger
// EnableREST if true, adds REST endpoints alongside GraphQL.
EnableREST bool
}
// Server holds the HTTP routes.
type Server struct {
svc *service.Service
cfg config.Config
auth *auth.Middleware
blobs blobstore.BlobStore
logger *slog.Logger
schema graphql.Schema
}
// NewServer builds the Server and compiles the GraphQL schema. A schema that
// does not compile is a programming error, so it is reported to the caller
// rather than deferred to the first request.
func NewServer(opts Options) (*Server, error) {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
schema, err := newSchema(opts.Service)
if err != nil {
return nil, fmt.Errorf("transport: build graphql schema: %w", err)
}
return &Server{
svc: opts.Service,
cfg: opts.Config,
auth: opts.Auth,
blobs: opts.Blobs,
logger: logger,
schema: schema,
}, nil
}
// Handler returns the fully routed handler.
//
// The probes and the media route are deliberately unauthenticated: a kubelet
// health probe cannot present a bearer token, and picture URLs have to be
// renderable where a token cannot be attached, such as an <img> tag. Every
// route that reads or writes user data sits behind the auth middleware.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET "+mediaPrefix, s.handleMedia)
upload := http.HandlerFunc(s.handleUpload)
if s.auth != nil {
upload = s.auth.WrapFunc(s.handleUpload)
}
// The place may come from the path or from a form field, so both shapes are
// accepted.
mux.HandleFunc("POST /upload", upload)
mux.HandleFunc("POST /places/{placeId}/images", upload)
// REST API routes. These read and write user data, so they sit behind the
// same auth middleware as the upload and GraphQL routes. Leaving them open
// would expose every place, picture and comment to an unauthenticated
// caller.
if s.cfg.EnableREST {
// When no middleware is configured there is nothing to verify against,
// so the handlers are mounted unwrapped; they will reject the request
// themselves because no identity reaches the context.
protect := func(h http.HandlerFunc) http.HandlerFunc {
if s.auth == nil {
return h
}
return s.auth.WrapFunc(h)
}
mux.HandleFunc("GET /api/places", protect(s.handleListPlaces))
mux.HandleFunc("GET /api/places/", protect(s.handleGetPlace))
mux.HandleFunc("POST /api/places", protect(s.handleCreatePlace))
mux.HandleFunc("GET /api/places/{placeId}/images", protect(s.handleListPlaceImages))
}
var graphqlHandler http.Handler = http.HandlerFunc(s.handleGraphQL)
if s.auth != nil {
graphqlHandler = s.auth.Wrap(http.HandlerFunc(s.handleGraphQL))
}
mux.Handle("POST /graphql", graphqlHandler)
// GET only ever serves the static query console. Executing a query over GET
// would bypass the auth middleware above, so a caller could read the whole
// travel log from a link in an email client.
mux.HandleFunc("GET /graphql", s.handleGraphQLConsole)
return s.withLogging(s.recoverPanics(mux))
}
// ------------------------------------------------------------------ probes ---
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleReadyz reports whether the service can serve traffic. It is separate
// from healthz so a failing dependency does not trigger a restart loop.
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
if err := s.svc.Ping(r.Context()); err != nil {
s.logger.Warn("readiness check failed", "error", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "unavailable",
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
// ------------------------------------------------------------------- media ---
// handleMedia streams a stored picture. The key is the path after the prefix
// and is resolved by the blob store, which rejects anything escaping its root.
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, mediaPrefix)
if key == "" {
http.Error(w, "a media key is required", http.StatusBadRequest)
return
}
if s.blobs == nil {
http.Error(w, "media storage is not configured", http.StatusNotFound)
return
}
// The declared type and size come from the record rather than from the file,
// so a mislabelled upload is still served as what it was stored as.
if ct, err := s.svc.ContentTypeByStorageKey(r.Context(), key); err == nil && ct != "" {
w.Header().Set("Content-Type", ct)
}
if n, err := s.svc.ImageSize(r.Context(), key); err == nil && n > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(n, 10))
}
body, err := s.blobs.Get(r.Context(), key)
if err != nil {
if errors.Is(err, blobstore.ErrNotFound) {
http.Error(w, "no such media", http.StatusNotFound)
return
}
s.logger.Warn("failed to read media", "key", key, "error", err)
http.Error(w, "media could not be read", http.StatusInternalServerError)
return
}
defer body.Close()
// Media is immutable once written, so it can be cached hard.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if _, err := io.Copy(w, body); err != nil {
s.logger.Warn("failed to stream media", "key", key, "error", err)
}
}
// ------------------------------------------------------------------ upload ---
// handleUpload accepts a multipart picture and attaches it to a place.
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
user, ok := auth.FromContext(r.Context())
if !ok {
// Only reachable if the middleware was not applied, which is a wiring
// bug rather than a client error.
http.Error(w, "unauthorized: a bearer token is required", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(8 << 20); err != nil {
http.Error(w, "a multipart form body is required", http.StatusBadRequest)
return
}
placeID := r.PathValue("placeId")
if placeID == "" {
placeID = r.FormValue("placeId")
}
if placeID == "" {
http.Error(w, "a placeId is required", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("image")
if err != nil {
http.Error(w, `a multipart field named "image" is required`, http.StatusBadRequest)
return
}
defer file.Close()
// The declared type is authoritative, but a client that omits it gets the
// type sniffed from the payload instead of a confusing rejection.
contentType := header.Header.Get("Content-Type")
var body io.Reader = file
if contentType == "" {
prefix := make([]byte, sniffLen)
n, err := io.ReadFull(file, prefix)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
http.Error(w, "the uploaded image could not be read", http.StatusBadRequest)
return
}
prefix = prefix[:n]
contentType = http.DetectContentType(prefix)
body = io.MultiReader(bytes.NewReader(prefix), file)
}
img, err := s.svc.AddImage(r.Context(), user.User, placeID, service.ImageUpload{
ContentType: contentType,
Caption: r.FormValue("caption"),
Body: body,
})
if err != nil {
writeServiceError(w, err)
return
}
// The client needs the URLs of the pictures this upload linked to, and the
// link evidence lives in the store rather than on the record, so they are
// read back here.
linked, err := s.svc.LinkedImages(r.Context(), img)
if err != nil {
// The picture itself was stored, so this is reported rather than failing
// a request that already succeeded.
s.logger.Warn("failed to load linked pictures", "imageId", img.ID, "error", err)
} else {
img.LinkedImages = linked
}
s.logger.Info("image stored",
"imageId", img.ID,
"placeId", img.PlaceID,
"bytes", img.ByteSize,
"analysisStatus", string(img.AnalysisStatus),
)
writeJSON(w, http.StatusCreated, map[string]interface{}{"image": img})
}
// ----------------------------------------------------------------- graphql ---
// graphQLRequest is the standard GraphQL-over-HTTP payload.
type graphQLRequest struct {
Query string `json:"query"`
OperationName string `json:"operationName"`
Variables map[string]interface{} `json:"variables"`
}
// handleGraphQLConsole serves the static query console. It never executes a
// query: this route is unauthenticated, so any data it returned would leak to
// whoever opened the URL.
func (s *Server) handleGraphQLConsole(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("query") != "" {
writeGraphQLRequestError(w, "send queries as POST /graphql with an Authorization header")
return
}
s.servePlayground(w, r)
}
func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
var req graphQLRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxGraphQLBody))
if err := dec.Decode(&req); err != nil {
writeGraphQLRequestError(w, "the request body must be a GraphQL JSON object")
return
}
if strings.TrimSpace(req.Query) == "" {
writeGraphQLRequestError(w, "a query is required")
return
}
result := graphql.Do(graphql.Params{
Schema: s.schema,
RequestString: req.Query,
VariableValues: req.Variables,
OperationName: req.OperationName,
Context: r.Context(),
})
if len(result.Errors) > 0 {
s.logger.Warn("graphql reported errors",
"operation", req.OperationName,
"errors", len(result.Errors),
"detail", firstErrorMessage(result),
)
}
// A GraphQL response carries its own errors in the body, so the transport
// status stays 200 even when a resolver failed. Only a malformed request is
// a 4xx.
writeJSON(w, http.StatusOK, result)
}
// handleListPlaces lists all places, optionally filtered by limit/offset.
func (s *Server) handleListPlaces(w http.ResponseWriter, r *http.Request) {
limit := 20
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
places, err := s.svc.ListPlaces(r.Context(), limit, offset)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, places)
}
// handleGetPlace returns one place by ID.
func (s *Server) handleGetPlace(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/places/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "place ID is required"})
return
}
// This route is a prefix match so that /api/places/{id}/images can be
// mounted alongside it. A deeper path is an unknown subresource, not a place
// whose ID happens to contain a slash, and IDs are UUIDs, so say so rather
// than reporting a place as missing.
if strings.Contains(id, "/") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such resource"})
return
}
pl, err := s.svc.GetPlace(r.Context(), id)
if err != nil {
writeServiceError(w, err)
return
}
// Related places are derived on read, so the REST handler fills them in the
// way the GraphQL resolver does. The search is a spatial query, so a failure
// is reported rather than swallowed; the place itself has already loaded.
related, err := s.svc.RelatedPlaces(r.Context(), id)
if err != nil {
writeServiceError(w, err)
return
}
if related == nil {
related = []domain.RelatedPlace{}
}
pl.RelatedPlaces = related
// The same-place neighbours of each picture are read back so a client can
// render them without a further request.
for i := range pl.Images {
linked, err := s.svc.LinkedImages(r.Context(), pl.Images[i])
if err != nil {
s.logger.Warn("failed to load linked pictures", "imageId", pl.Images[i].ID, "error", err)
continue
}
if linked != nil {
pl.Images[i].LinkedImages = linked
}
}
writeJSON(w, http.StatusOK, pl)
}
// handleCreatePlace creates a new place.
func (s *Server) handleCreatePlace(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
LinkRadiusMeters *float64 `json:"linkRadiusMeters,omitempty"`
Caption string `json:"caption,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
author, err := requireUser(r.Context())
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized: a bearer token is required"})
return
}
pl, err := s.svc.CreatePlace(r.Context(), author, domain.PlaceInput{
Title: input.Title,
Description: input.Description,
Latitude: input.Latitude,
Longitude: input.Longitude,
LinkRadiusMeters: input.LinkRadiusMeters,
Caption: input.Caption,
})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, pl)
}
// handleListPlaceImages lists images for a place.
func (s *Server) handleListPlaceImages(w http.ResponseWriter, r *http.Request) {
placeID := strings.TrimPrefix(r.URL.Path, "/api/places/")
idx := strings.Index(placeID, "/images")
if idx >= 0 {
placeID = placeID[:idx]
}
if placeID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "place ID is required"})
return
}
// Support ?limit&?offset query params
limit := 20
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
f := store.ImageFilter{Limit: limit, Offset: offset}
if v, ok := r.URL.Query()["placeId"]; ok && len(v) > 0 {
f.PlaceID = v[0]
}
images, err := s.svc.ListImages(r.Context(), f)
if err != nil {
writeServiceError(w, err)
return
}
// The same-place neighbours are read back per picture so the client can show
// them without a second round trip.
for i := range images {
linked, err := s.svc.LinkedImages(r.Context(), images[i])
if err != nil {
s.logger.Warn("failed to load linked pictures", "imageId", images[i].ID, "error", err)
continue
}
images[i].LinkedImages = linked
}
writeJSON(w, http.StatusOK, images)
}
// servePlayground answers a browser hitting GET /graphql with no query.
func (s *Server) servePlayground(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, playgroundHTML)
}
// ----------------------------------------------------------------- helpers ---
// writeServiceError maps a service sentinel to an HTTP status and emits the same
// JSON envelope as the other handlers, so a client only has to parse one shape.
func writeServiceError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, service.ErrNotFound):
status = http.StatusNotFound
case errors.Is(err, service.ErrInvalidInput):
status = http.StatusBadRequest
case errors.Is(err, service.ErrTooLarge):
status = http.StatusRequestEntityTooLarge
case errors.Is(err, service.ErrUnsupportedType):
status = http.StatusUnsupportedMediaType
case errors.Is(err, context.DeadlineExceeded):
status = http.StatusGatewayTimeout
case errors.Is(err, context.Canceled):
// The client hung up, so there is nobody left to read the response.
return
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}
// writeGraphQLRequestError reports a malformed request. A request that never
// reached the executor is a client error, so this is a 4xx rather than a 200
// with an errors array.
func writeGraphQLRequestError(w http.ResponseWriter, message string) {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
"errors": []map[string]string{{"message": message}},
})
}
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// The status line is already committed, so an encoding failure here can only
// truncate the body; there is nothing left to tell the client.
_ = json.NewEncoder(w).Encode(body)
}
// withLogging records one line per request.
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.written,
"duration", time.Since(start).String(),
)
})
}
// recoverPanics keeps one failing request from taking the whole process down.
func (s *Server) recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.logger.Error("panic while serving request",
"method", r.Method,
"path", r.URL.Path,
"panic", rec,
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// statusRecorder captures the status and byte count for the access log.
type statusRecorder struct {
http.ResponseWriter
status int
written int
wroteHeader bool
}
func (r *statusRecorder) WriteHeader(status int) {
if !r.wroteHeader {
r.status = status
r.wroteHeader = true
}
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
r.wroteHeader = true
n, err := r.ResponseWriter.Write(b)
r.written += n
return n, err
}
// firstErrorMessage returns the first resolver error message for the log line.
func firstErrorMessage(result *graphql.Result) string {
if result == nil || len(result.Errors) == 0 {
return ""
}
return result.Errors[0].Message
}

View file

@ -0,0 +1,386 @@
package transport
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"go-places-api/internal/analysis"
"go-places-api/internal/auth"
"go-places-api/internal/blobstore"
"go-places-api/internal/config"
"go-places-api/internal/service"
"go-places-api/internal/store"
)
// newTestServer wires a Server against a throwaway database and media
// directory, so a test exercises the real store and blob store rather than
// fakes. mutate, when given, adjusts the configuration before wiring.
func newTestServer(t *testing.T, mutate ...func(*config.Config)) *Server {
t.Helper()
dir := t.TempDir()
cfg := config.Default()
cfg.DatabaseURL = "file:" + filepath.Join(dir, "test.db")
cfg.MediaDir = filepath.Join(dir, "media")
cfg.AuthMode = "dev"
cfg.MaxUploadBytes = 1 << 20
for _, m := range mutate {
m(&cfg)
}
db, err := store.OpenSQL(cfg.DatabaseURL)
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { db.Close() })
blobs, err := blobstore.NewLocalFS(cfg.MediaDir)
if err != nil {
t.Fatalf("open blob store: %v", err)
}
svc := service.New(service.Options{
Store: db,
Blobs: blobs,
Analyzer: stubAnalyzer{},
Config: cfg,
})
srv, err := NewServer(Options{
Service: svc,
Config: cfg,
Auth: auth.NewMiddleware(auth.NewDevVerifier(nil)),
Blobs: blobs,
})
if err != nil {
t.Fatalf("new server: %v", err)
}
return srv
}
// stubAnalyzer keeps the badge rules out of these tests, which are about the
// transport rather than about image classification.
type stubAnalyzer struct{}
var _ analysis.Analyzer = stubAnalyzer{}
func (stubAnalyzer) Name() string { return "stub" }
func (stubAnalyzer) Analyze(ctx context.Context, in analysis.Input) (*analysis.Result, error) {
return &analysis.Result{Width: 8, Height: 4}, nil
}
// do runs a GraphQL request against the handler and returns the decoded body.
func do(t *testing.T, srv *Server, query string, vars map[string]any) (int, map[string]any) {
t.Helper()
body, err := json.Marshal(map[string]any{"query": query, "variables": vars})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/graphql", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", rec.Body.String(), err)
}
return rec.Code, out
}
// data returns the "data" object, failing the test if the response carried
// errors.
func data(t *testing.T, resp map[string]any) map[string]any {
t.Helper()
if errs, ok := resp["errors"]; ok {
t.Fatalf("unexpected GraphQL errors: %v", errs)
}
d, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("response has no data object: %v", resp)
}
return d
}
// TestNewSchemaCompiles guards the self-referential Place/Image/RelatedPlace
// types against being constructed more than once. graphql-go rejects a schema
// that contains two distinct types under the same name, and that failure only
// surfaces at startup, so it is worth pinning down here.
func TestNewSchemaCompiles(t *testing.T) {
srv := newTestServer(t)
if _, err := newSchema(srv.svc); err != nil {
t.Fatalf("schema did not compile: %v", err)
}
for _, name := range []string{"Image", "Place", "RelatedPlace", "Badge", "Coordinate", "Comment", "User"} {
if srv.schema.Type(name) == nil {
t.Errorf("schema is missing the %s type", name)
}
}
}
// TestPlaceLifecycle walks a place through creation, upload and read-back.
func TestPlaceLifecycle(t *testing.T) {
srv := newTestServer(t)
code, resp := do(t, srv,
`mutation($i: PlaceInput!) { createPlace(input: $i) { id title coordinate { latitude longitude } } }`,
map[string]any{"i": map[string]any{
"title": "Test Place", "latitude": 48.85, "longitude": 2.33,
}})
if code != http.StatusOK {
t.Fatalf("createPlace status = %d, want 200", code)
}
created := data(t, resp)["createPlace"].(map[string]any)
placeID := created["id"].(string)
if placeID == "" {
t.Fatal("createPlace returned an empty id")
}
if created["title"] != "Test Place" {
t.Errorf("title = %v, want %q", created["title"], "Test Place")
}
// Upload a real PNG so the analysis and decode paths are exercised.
uploadCode, img := uploadPNG(t, srv, placeID)
if uploadCode != http.StatusCreated {
t.Fatalf("upload status = %d, want 201", uploadCode)
}
imageID := img["id"].(string)
url := img["url"].(string)
if !strings.HasPrefix(url, "/media/") {
t.Errorf("url = %q, want a /media/ prefix", url)
}
if img["analysisStatus"] != "completed" {
t.Errorf("analysisStatus = %v, want completed", img["analysisStatus"])
}
// The picture must be readable back through the place.
_, resp = do(t, srv,
`query($id: String!) { place(id: $id) { images { id url } } }`,
map[string]any{"id": placeID})
images := data(t, resp)["place"].(map[string]any)["images"].([]any)
if len(images) != 1 {
t.Fatalf("got %d images on the place, want 1", len(images))
}
if got := images[0].(map[string]any)["url"]; got != url {
t.Errorf("url = %v, want %q", got, url)
}
// And the media route must serve the stored bytes.
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", url, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "image/png" {
t.Errorf("Content-Type = %q, want image/png", got)
}
// Deleting the place must remove the stored blob too.
_, resp = do(t, srv, `mutation($id: String!) { deletePlace(id: $id) }`, map[string]any{"id": placeID})
if data(t, resp)["deletePlace"] != true {
t.Fatalf("deletePlace did not return true: %v", resp)
}
entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
if err == nil && len(entries) > 0 {
t.Errorf("blob directory still holds %d entries after delete", len(entries))
}
_ = imageID
}
// TestResolverErrorYieldsNull checks that a failed resolver nulls its field
// instead of emitting the zero value of the domain type, which would otherwise
// hand clients a plausible-looking empty record.
func TestResolverErrorYieldsNull(t *testing.T) {
srv := newTestServer(t)
code, resp := do(t, srv, `{ place(id: "does-not-exist") { id title } }`, nil)
if code != http.StatusOK {
t.Fatalf("status = %d, want 200 (errors travel in the body)", code)
}
if _, ok := resp["errors"]; !ok {
t.Error("expected an error entry for a missing place")
}
if got := data2(t, resp)["place"]; got != nil {
t.Errorf("data.place = %v, want null", got)
}
}
// data2 returns the data object without asserting the absence of errors, for
// the case where errors are expected.
func data2(t *testing.T, resp map[string]any) map[string]any {
t.Helper()
d, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("response has no data object: %v", resp)
}
return d
}
// TestUnauthenticatedIsRejected checks the auth middleware is actually mounted.
func TestUnauthenticatedIsRejected(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/graphql", strings.NewReader(`{"query":"{ places { id } }"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
// TestProbesAreReachableWithoutAuth guards against a probe route accidentally
// being mounted behind the auth middleware: a kubelet cannot present a token.
func TestProbesAreReachableWithoutAuth(t *testing.T) {
srv := newTestServer(t)
for _, path := range []string{"/healthz", "/readyz"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s status = %d, want 200", path, rec.Code)
}
}
}
// TestUploadRejectsUnsupportedType checks the content-type guard.
func TestUploadRejectsUnsupportedType(t *testing.T) {
srv := newTestServer(t)
_, resp := do(t, srv,
`mutation { createPlace(input: {title: "T"}) { id } }`, nil)
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
uploadCode, _ := upload(t, srv, placeID, "notes.txt", "text/plain", []byte("hello"))
if uploadCode != http.StatusUnsupportedMediaType {
t.Errorf("upload status = %d, want 415", uploadCode)
}
}
// TestUploadRejectsOversizedPayload checks the size guard and that no partial
// blob is left behind.
func TestUploadRejectsOversizedPayload(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.MaxUploadBytes = 64 })
_, resp := do(t, srv, `mutation { createPlace(input: {title: "T"}) { id } }`, nil)
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
uploadCode, _ := upload(t, srv, placeID, "big.png", "image/png", bytes.Repeat([]byte{0x41}, 4096))
if uploadCode != http.StatusRequestEntityTooLarge {
t.Errorf("upload status = %d, want 413", uploadCode)
}
entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
if err == nil && len(entries) > 0 {
t.Errorf("blob directory holds %d entries after a rejected upload", len(entries))
}
}
// uploadPNG posts a small generated PNG and returns the decoded response.
func uploadPNG(t *testing.T, srv *Server, placeID string) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 8, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255})
}
}
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
code, resp := upload(t, srv, placeID, "pic.png", "image/png", buf.Bytes())
if code != http.StatusCreated {
t.Fatalf("upload status = %d, want 201: %v", code, resp)
}
inner, _ := resp["image"].(map[string]any)
if inner == nil {
t.Fatalf("upload response has no image object: %v", resp)
}
return code, inner
}
// TestGraphQLGetCannotExecuteQueries checks that the unauthenticated GET route
// only serves the console. If a ?query= URL ran a query, anyone who got hold of
// the link could read the whole travel log.
func TestGraphQLGetCannotExecuteQueries(t *testing.T) {
srv := newTestServer(t)
_, resp := do(t, srv, `mutation { createPlace(input: {title: "Secret"}) { id } }`, nil)
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
req := httptest.NewRequest(http.MethodGet, "/graphql?"+url.Values{
"query": {`{place(id:"` + placeID + `"){title}}`},
}.Encode(), nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("GET /graphql?query status = %d, want 400", rec.Code)
}
if strings.Contains(rec.Body.String(), "Secret") {
t.Fatalf("GET /graphql?query leaked data: %s", rec.Body.String())
}
// The console itself stays reachable, since it carries no data.
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/graphql", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /graphql status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("GET /graphql content-type = %q, want text/html", ct)
}
}
// upload posts a multipart image and returns the status and decoded body.
func upload(t *testing.T, srv *Server, placeID, filename, contentType string, payload []byte) (int, map[string]any) {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="image"; filename="`+filename+`"`)
h.Set("Content-Type", contentType)
part, err := w.CreatePart(h)
if err != nil {
t.Fatalf("create part: %v", err)
}
if _, err := part.Write(payload); err != nil {
t.Fatalf("write part: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
// Error responses are plain text, which is fine here.
return rec.Code, map[string]any{"error": strings.TrimSpace(rec.Body.String())}
}
return rec.Code, out
}