mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user