# snip backend Go service for the snip URL shortener. Hexagonal (ports & adapters) with constructor-based dependency injection, Postgres for source of truth, Redis as the redirect hot-path cache. ## One binary, three commands `cmd/snip` builds a single binary with three subcommands so one Docker image can back three docker-compose services: | Command | Default port | Serves | |------------|--------------|---------------------------------------------------------------| | `api` | `8080` | JSON API under `/api/v1/*` (links CRUD, PIN, OAuth/OIDC login) | | `frontend` | `8081` | The built SPA — owns `/`, `/login`, `/dashboard` + assets | | `redirect` | `8082` | `GET /{code}` → 302, plus the fast self-contained enter-PIN page | ```bash PORT=8080 ./snip api PORT=8081 ./snip frontend PORT=8082 ./snip redirect ``` In production a gateway routes by path (`/api/*`→api, app routes→frontend, the rest→redirect). See the repo-root `Caddyfile` + `docker-compose.yml`. ## Architecture (hexagon) ``` cmd/snip entrypoint: parses subcommand, graceful shutdown internal/ domain/ entities + sentinel errors (no deps) port/ interfaces the core depends on (the hexagon edges) service/ application core — depends only on ports link_service.go create/list/update/delete/pin redirect_service.go cache-first resolve + pin verify (hot path) clicks.go batched async click recorder auth_service.go OAuth/OIDC login + sessions shortcode.go base62 codes, validation, reserved-path guard adapter/ implementations of the ports memory/ in-memory (tests + STORE=memory) postgres/ pgxpool repos + advisory-locked migrations rediscache/ go-redis resolution cache identity/ OIDC provider (covers Google + generic OIDC) security/ bcrypt hasher + HMAC session tokens httpx/ inbound HTTP adapters (api / frontend / redirect) config/ env config di/ wires adapters → services per command ``` The core (`service`) imports only `port` and `domain`; nothing in it knows about Postgres, Redis, HTTP or OAuth. `di` is the only package that imports concrete adapters. ## Performance design The redirect path is the hot one and is built to **avoid Postgres**: 1. `GET /{code}` reads `code:{code}` from Redis. On a hit it 302s immediately — **no DB**. 2. On a miss it reads Postgres once, then caches the tiny resolution (`{longURL, hasPin, pinHash}`) with a 24h TTL. 3. Unknown codes are **negatively cached** for 60s so scanners can't hammer the DB. 4. PINs are verified against the cached bcrypt hash — protected links also skip the DB. 5. Clicks never block the redirect: they're counted in memory and flushed to `click_daily` (+ `links.total_clicks`) in **batches** (`CLICK_FLUSH_SECONDS`, default 10), collapsing bursts into one write. ## Two login methods Both `google` and a generic `oidc` provider implement the same `IdentityProvider` port (Google is itself an OIDC issuer). Each is optional — absent credentials simply hide that button. - `GET /api/v1/auth/{provider}/login` → 302 to the provider - `GET /api/v1/auth/{provider}/callback` → sets an HttpOnly session cookie, 302s to `/dashboard` - `GET /api/v1/auth/me`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/providers` Sessions are stateless HMAC-signed tokens (no server store). State is validated with a double-submit cookie. ## Reserved paths Custom aliases can't collide with app-owned paths — `api`, `login`, `dashboard`, `assets`, `healthz`, etc. are rejected at creation (`service.ReservedCodes`). ## Configuration (env) | Var | Default | Notes | |-----|---------|-------| | `STORE` | `postgres` | or `memory` (no infra) | | `DATABASE_URL` | `postgres://snip:snip@localhost:5432/snip?sslmode=disable` | | | `REDIS_ADDR` / `REDIS_PASSWORD` / `REDIS_DB` | `localhost:6379` / `` / `0` | | | `SESSION_SECRET` | dev default | **set in prod** | | `PUBLIC_URL` | `http://localhost:8080` | builds OAuth callback URLs | | `POST_LOGIN_REDIRECT` | `/dashboard` | | | `SHORT_DOMAIN` | `snip.to` | display host on the PIN page | | `FRONTEND_DIST` | `./web` | built SPA directory (frontend cmd) | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `CLICK_FLUSH_SECONDS` | `10` | click batch interval | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | — | enables Google login | | `OIDC_ISSUER` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | — | enables OIDC login | ## Develop & test ```bash go test ./... # unit + HTTP integration tests (in-memory, no infra) STORE=memory PORT=8080 go run ./cmd/snip api # run with zero infrastructure ``` The test suite uses the in-memory adapters, so it needs neither Postgres nor Redis. The real adapters are exercised via docker-compose.