mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
115 lines
5.0 KiB
Markdown
115 lines
5.0 KiB
Markdown
# 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.
|