Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ R2_SECRET_KEY=your_r2_secret_key_here
# The name of your R2 bucket
R2_BUCKET=your_bucket_name_here
# Public URL for the bucket (set up a custom domain or use r2.dev subdomain)
R2_PUBLIC_URL=https://pub-xxxxxxxxxxxx.r2.dev
R2_PUBLIC_URL=https://pub-xxxxxxxxxxxx.r2.dev
# IP geolocation via ipapi.co (optional; free tier works without a key)
# Get a key from: https://ipapi.co/api/#introduction
IPAPI_KEY=
1 change: 1 addition & 0 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ The `docs` agent reads this index first to locate the right file before diving i
| Firebase Cloud Messaging — token storage, send API, FCM endpoints | [fcm.md](fcm.md) | `internal/domain/fcm_token.go`, `internal/usecase/notification.go`, `internal/infrastructure/database/postgres/fcm_token_repository.go`, `internal/transport/handlers/fcm_handler.go`, `pkg/firebase/app.go`, `pkg/firebase/messaging.go` |
| Transactional email (Mailjet) — EmailSender interface, MailjetSender, sandbox mode, templates | [email.md](email.md) | `internal/usecase/email.go`, `internal/infrastructure/email/mailjet.go`, `internal/infrastructure/email/templates/welcome.html`, `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `internal/transport/handlers/handler.go` |
| Object storage (Cloudflare R2) — StorageService interface, R2 implementation, presign/delete endpoints | [storage.md](storage.md) | `internal/usecase/storage.go`, `internal/infrastructure/storage/r2/storage.go`, `internal/transport/handlers/storage_handler.go`, `internal/transport/handlers/routes.go`, `internal/bootstrap/bootstrap.go` |
| IP geolocation (ipapi.co) — GeoLocation entity, GeoLocator interface, ipgeo.Client, GeoFromRequest middleware, RealIP helper | [geo.md](geo.md) | `internal/domain/geolocation.go`, `internal/usecase/geolocation.go`, `internal/infrastructure/ipgeo/ipapi_client.go`, `internal/transport/middleware/geo.go`, `internal/bootstrap/bootstrap.go` |
7 changes: 5 additions & 2 deletions backend/docs/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ type App struct {
FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set
EmailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set
StorageService usecase.StorageService // nil when R2_ACCOUNT_ID is not set
GeoLocator usecase.GeoLocator // always initialised; free tier works without a key
Config Config
Log *slog.Logger
}
```
`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Enqueuer`, `Firebase`, `FCMSender`, `EmailSender`, `StorageService`) are nil when their corresponding env vars are absent.
`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Enqueuer`, `Firebase`, `FCMSender`, `EmailSender`, `StorageService`) are nil when their corresponding env vars are absent. `GeoLocator` is always non-nil after a successful `Run()`.

## Config struct
```go
Expand All @@ -49,6 +50,7 @@ type Config struct {
R2SecretKey string
R2Bucket string
R2PublicURL string
IPAPIKey string
}
```
`loadConfig()` reads all values from environment variables. `PORT` defaults to `8080`; `BLUEPRINT_DB_SCHEMA` defaults to `public`; `BLUEPRINT_DB_SSLMODE` defaults to `disable`. `RateLimitBurst` is derived as `int(RPS)*5` when omitted and RPS is set. Optional fields (`RedisURL`, `FirebaseProjectID`, `FirebaseServiceAccountJSON`) default to empty string — their respective services are skipped when empty.
Expand All @@ -65,7 +67,8 @@ type Config struct {
7. Init Firebase app via `firebase.NewApp(ctx, ...)`, then init Auth client (`firebase.NewAuthClient`) and FCM messaging client (`firebase.NewMessagingClient`) from the same app instance — all skipped when `FIREBASE_PROJECT_ID` is empty
8. Init Mailjet email sender via `email.NewMailjetSender(...)` — skipped when `MAILJET_API_KEY` or `MAILJET_SECRET_KEY` is empty; startup fails if only a partial Mailjet config is provided
9. Init R2 storage client via `r2.New(...)` — skipped when `R2_ACCOUNT_ID` is empty; startup fails if `R2_ACCOUNT_ID` is set but any other R2 var is missing
10. Return `*App` on success; return a non-nil error on any failure
10. Init `ipgeo.Client` via `ipgeo.New(cache, cfg.IPAPIKey)` — always runs, never conditional; `cache` is nil when Redis is unavailable, which disables caching for geolocation
11. Return `*App` on success; return a non-nil error on any failure

```go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
Expand Down
1 change: 1 addition & 0 deletions backend/docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ This runs on package init before any env var is read — no explicit `godotenv.L
| `R2_SECRET_KEY` | `bootstrap.go` | — | R2 API token secret key. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `R2_BUCKET` | `bootstrap.go` | — | R2 bucket name. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `R2_PUBLIC_URL` | `bootstrap.go` | — | Public base URL for the R2 bucket (custom domain or `r2.dev` subdomain). Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `IPAPI_KEY` | `bootstrap.go` | — | ipapi.co API key (optional). Free tier works without a key; supplying one enables higher rate limits. |

Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing.

Expand Down
179 changes: 179 additions & 0 deletions backend/docs/geo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
topic: geo
last_verified: 2026-06-23
sources:
- backend/internal/domain/geolocation.go
- backend/internal/usecase/geolocation.go
- backend/internal/infrastructure/ipgeo/ipapi_client.go
- backend/internal/transport/middleware/geo.go
- backend/internal/bootstrap/bootstrap.go
---

# IP Geolocation

## What it does
The geolocation feature resolves a client IP address to geographic metadata (country, region, city, timezone, currency, EU membership) using the [ipapi.co](https://ipapi.co) JSON API. Results are cached in Redis under the key `geo:<ip>` for 24 hours when a `CacheService` is available.

The feature is always initialised — ipapi.co's free tier requires no API key. Supplying `IPAPI_KEY` enables the paid tier with higher rate limits.

Private and loopback IPs (`ErrPrivateIP`) are rejected immediately without any HTTP call or cache lookup.

## domain.GeoLocation

```go
// backend/internal/domain/geolocation.go
type GeoLocation struct {
IP string
CountryCode string
CountryName string
Region string
City string
Timezone string
Currency string
IsEU bool
}
```

## usecase.GeoLocator interface

```go
// backend/internal/usecase/geolocation.go
type GeoLocator interface {
Lookup(ctx context.Context, ip string) (*domain.GeoLocation, error)
}
```

## ipgeo.Client constructors

```go
// Production — cache may be nil (disables caching), apiKey may be empty (free tier).
func New(cache usecase.CacheService, apiKey string) *Client

// Tests — points the HTTP client at a custom base URL (e.g. an httptest.Server).
func NewWithBaseURL(cache usecase.CacheService, apiKey, baseURL string) *Client
```

`Client` satisfies `usecase.GeoLocator` via a compile-time assertion:
```go
var _ usecase.GeoLocator = (*Client)(nil)
```

### Lookup behaviour

1. Returns `ErrPrivateIP` immediately for loopback and RFC-1918/RFC-4193 addresses.
2. Checks the cache (`geo:<ip>`) — returns the deserialised value on hit.
3. On cache miss, calls `GET {baseURL}/{ip}/json/` (appends `?key=<apiKey>` when a key is set).
4. On success, writes the serialised result to the cache (TTL 24 h, best-effort — write errors are silently ignored).
5. Returns the populated `*domain.GeoLocation`.

Sentinel error: `ipgeo.ErrPrivateIP`

## GeoFromRequest middleware

```go
// backend/internal/transport/middleware/geo.go
const GeoLocationKey = "geo_location"

func GeoFromRequest(locator usecase.GeoLocator) gin.HandlerFunc
```

Best-effort: if `locator.Lookup` returns any error (private IP, rate-limit, network error), the middleware calls `c.Next()` without storing anything. Handlers must nil-check before reading the key.

## RealIP helper

```go
func RealIP(r *http.Request) string
```

Precedence order:
1. `X-Forwarded-For` header — takes the first (leftmost) comma-separated address.
2. `X-Real-IP` header.
3. `r.RemoteAddr` — `net.SplitHostPort` strips the port; falls back to the raw string on parse error.

`RealIP` is exported so tests and other packages can call it directly.

## Reading geo data in a handler

```go
val, exists := c.Get(middleware.GeoLocationKey)
if !exists {
// geo unavailable — private IP, rate-limited, or middleware not registered
return
}
geo, ok := val.(*domain.GeoLocation)
if !ok || geo == nil {
return
}
// use geo.CountryCode, geo.City, etc.
```

## Bootstrap wiring

`GeoLocator` is always initialised inside `bootstrap.Run()`, unconditionally:

```go
// bootstrap.go — always runs, not guarded by an env var check
geoLocator := ipgeo.New(cache, cfg.IPAPIKey)
log.Info("bootstrap: ipapi geolocation client initialised", "cached", cache != nil)
```

`cache` is the same `usecase.CacheService` used elsewhere — it is `nil` when `REDIS_URL` is not set, which disables caching for geolocation as well.

`App.GeoLocator` is always non-nil after a successful `Run()`.

## Environment variable

| Variable | Required | Default | Description |
|---|---|---|---|
| `IPAPI_KEY` | No | — | ipapi.co API key. Omit or leave empty for the free tier. |

## Testing patterns

### Unit-testing ipgeo.Client
Use `NewWithBaseURL` pointing at an `httptest.NewServer`:

```go
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"ip": "1.2.3.4", "country_code": "US", ...
})
}))
defer srv.Close()

client := ipgeo.NewWithBaseURL(nil, "", srv.URL)
geo, err := client.Lookup(context.Background(), "1.2.3.4")
```

Inject a cache with an inline mock struct:

```go
type mockCacheService struct {
data map[string]string
}

func (m *mockCacheService) Get(_ context.Context, key string) (string, bool, error) {
v, ok := m.data[key]
return v, ok, nil
}
func (m *mockCacheService) Set(_ context.Context, key, value string, _ time.Duration) error {
m.data[key] = value
return nil
}
// implement remaining usecase.CacheService methods as no-ops
```

### Unit-testing GeoFromRequest middleware
Use an inline `mockGeoLocator`:

```go
type mockGeoLocator struct {
geo *domain.GeoLocation
err error
}

func (m *mockGeoLocator) Lookup(_ context.Context, _ string) (*domain.GeoLocation, error) {
return m.geo, m.err
}
```

Record the handler under test with `httptest.NewRecorder` and assert `c.Get(middleware.GeoLocationKey)`.
61 changes: 60 additions & 1 deletion backend/docs/middleware.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
---
topic: middleware
last_verified: 2026-06-15
last_verified: 2026-06-23
sources:
- internal/transport/middleware/logger.go
- internal/transport/middleware/ratelimit.go
- internal/transport/middleware/auth.go
- internal/transport/middleware/metrics.go
- internal/transport/middleware/local_network.go
- internal/transport/middleware/geo.go
- internal/transport/handlers/routes.go
---

Expand Down Expand Up @@ -77,6 +80,62 @@ token, ok := val.(*usecase.FirebaseToken)

Pass `nil` as the `verifier` to `NewHandler` to skip Firebase auth entirely (development without credentials). `RegisterRoutes` reads `h.verifier` from the struct — it is not a parameter of `RegisterRoutes`.

## PrometheusMiddleware

`PrometheusMiddleware() gin.HandlerFunc` records two metrics for every request except `/metrics` itself:

- `http_requests_total` — counter vector with labels `method`, `path`, `status`.
- `http_request_duration_seconds` — histogram vector with labels `method`, `path`.

Unmatched routes (404s with no Gin `FullPath()`) are recorded under the path label `"unmatched"`.

## LocalNetworkOnly

`LocalNetworkOnly() gin.HandlerFunc` aborts with `403 Forbidden` when the client IP is neither a loopback address nor an RFC 1918 private address. In release mode, `RegisterRoutes` applies it as a per-route middleware on `/metrics` so the Prometheus scrape endpoint is reachable from the internal network but not from external clients.

```go
// release mode only:
r.GET("/metrics", middleware.LocalNetworkOnly(), gin.WrapH(promhttp.Handler()))
```

## GeoFromRequest

`GeoFromRequest(locator usecase.GeoLocator) gin.HandlerFunc` resolves the request's originating IP to geographic metadata and stores the result in the Gin context. It is best-effort: any error from `locator.Lookup` (private IP, rate-limit, network failure) is silently dropped and the request continues without geo data.

```go
const GeoLocationKey = "geo_location"

func GeoFromRequest(locator usecase.GeoLocator) gin.HandlerFunc
```

Context key: `middleware.GeoLocationKey` (`"geo_location"`). Value type: `*domain.GeoLocation`.

Reading geo data in a handler:
```go
val, exists := c.Get(middleware.GeoLocationKey)
if !exists {
// geo unavailable
return
}
geo, ok := val.(*domain.GeoLocation)
if !ok || geo == nil {
return
}
```

### RealIP helper

```go
func RealIP(r *http.Request) string
```

IP extraction precedence:
1. First address in `X-Forwarded-For` (proxy/Railway deploys).
2. `X-Real-IP` header.
3. `r.RemoteAddr` with port stripped via `net.SplitHostPort`.

`RealIP` is exported for direct use in tests and other packages.

## Adding new middleware

1. Create `internal/transport/middleware/<name>.go` with a function returning `gin.HandlerFunc`.
Expand Down
10 changes: 10 additions & 0 deletions backend/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"backend/internal/infrastructure/cache/redis"
"backend/internal/infrastructure/database/postgres"
"backend/internal/infrastructure/email"
"backend/internal/infrastructure/ipgeo"
"backend/internal/infrastructure/queue"
"backend/internal/infrastructure/storage/r2"
"backend/internal/usecase"
Expand All @@ -43,6 +44,7 @@ type App struct {
FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set
EmailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set
StorageService usecase.StorageService // nil when R2_ACCOUNT_ID is not set
GeoLocator usecase.GeoLocator // always initialised; free tier works without a key
Config Config
Log *slog.Logger
}
Expand All @@ -67,6 +69,7 @@ type Config struct {
R2SecretKey string
R2Bucket string
R2PublicURL string
IPAPIKey string
}

// ConfigError is returned when required configuration is absent or invalid.
Expand Down Expand Up @@ -172,6 +175,11 @@ func Run(ctx context.Context) (*App, error) {
log.Info("bootstrap: R2 storage client initialised", "bucket", cfg.R2Bucket)
}

// Geolocation is always available — ipapi.co has a free tier.
// Cache is used when Redis is available; nil cache means no caching.
geoLocator := ipgeo.New(cache, cfg.IPAPIKey)
log.Info("bootstrap: ipapi geolocation client initialised", "cached", cache != nil)

log.Info("bootstrap: all checks passed — ready to serve")

return &App{
Expand All @@ -182,6 +190,7 @@ func Run(ctx context.Context) (*App, error) {
FCMSender: fcmSender,
EmailSender: emailSender,
StorageService: storageService,
GeoLocator: geoLocator,
Config: cfg,
Log: log,
}, nil
Expand Down Expand Up @@ -227,6 +236,7 @@ func loadConfig() Config {
R2SecretKey: os.Getenv("R2_SECRET_KEY"),
R2Bucket: os.Getenv("R2_BUCKET"),
R2PublicURL: os.Getenv("R2_PUBLIC_URL"),
IPAPIKey: os.Getenv("IPAPI_KEY"),
DB: postgres.DBConfig{
Host: os.Getenv("BLUEPRINT_DB_HOST"),
Port: os.Getenv("BLUEPRINT_DB_PORT"),
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/domain/geolocation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package domain

// GeoLocation holds IP-derived geographic metadata.
type GeoLocation struct {
IP string
CountryCode string
CountryName string
Region string
City string
Timezone string
Currency string
IsEU bool
}
Loading
Loading