feat: first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-15 21:25:57 +07:00
commit 3395ab6dd3
88 changed files with 10034 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "snip-compose",
"runtimeExecutable": "docker",
"runtimeArgs": ["compose", "up", "--no-build"],
"port": 8080,
"autoPort": false
},
{
"name": "snip",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
backend/bin
**/*.log
.git
.claude
.DS_Store
memory
+26
View File
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
dist/
# Go binary
backend/snip
# Environment
.env
.env.*
!.env.example
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
+28
View File
@@ -0,0 +1,28 @@
# Gateway routing for the three snip services.
# Path ownership (matches the app's reserved paths):
# /api/* -> api service
# /, /login, /dashboard -> frontend (SPA shell)
# /assets/*, /favicon.svg -> frontend (static)
# everything else (/{code}) -> redirect service
:80 {
encode gzip
handle /api/* {
reverse_proxy api:8080
}
# SPA app routes + static assets are owned by the frontend service.
handle /assets/* {
reverse_proxy frontend:8081
}
@app path / /login /dashboard /favicon.svg /favicon_dark.svg /robots.txt
handle @app {
reverse_proxy frontend:8081
}
# Anything else is a short code → redirect service.
handle {
reverse_proxy redirect:8082
}
}
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
#
# One image, three commands. docker-compose runs it three times with different
# entrypoints (api / frontend / redirect). See docker-compose.yml.
# --- 1. Build the SPA -------------------------------------------------------
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY index.html vite.config.ts tsconfig*.json postcss.config.js tailwind.config.js ./
COPY src ./src
COPY public ./public
RUN npm run build # -> /app/dist
# --- 2. Build the Go binary -------------------------------------------------
FROM golang:1.25-alpine AS backend
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/snip ./cmd/snip
# --- 3. Minimal runtime -----------------------------------------------------
FROM alpine:3.20
RUN apk add --no-cache ca-certificates wget && adduser -D -u 10001 snip
WORKDIR /app
COPY --from=backend /out/snip /usr/local/bin/snip
COPY --from=frontend /app/dist ./web
ENV FRONTEND_DIST=/app/web
USER snip
# Default command; compose overrides with api / frontend / redirect.
ENTRYPOINT ["snip"]
CMD ["api"]
+176
View File
@@ -0,0 +1,176 @@
# snip — a fast, friendly URL shortener
A minimal, delightful link shortener. **This phase is frontend-only**, running
entirely on a mock backend (localStorage + simulated latency) so the UI/UX can
be built and reviewed before the Go service exists.
![stack](https://img.shields.io/badge/react-18-000) ![stack](https://img.shields.io/badge/vite-5-000) ![stack](https://img.shields.io/badge/tailwind-3-000) ![stack](https://img.shields.io/badge/motion-11-000)
## Run it
```bash
npm install
npm run dev # http://localhost:5173
npm run build # typecheck + production build
```
There's a seeded demo account — click **Sign in → Continue with demo account**
(any email works, no password). Data persists in `localStorage`; clear it to
reset.
## What it does
- **Create short links** on the home page with three code styles:
- **Random** _(default)_ — shortest possible unique code via base62 (see
[`src/lib/shortcode.ts`](src/lib/shortcode.ts)).
- **Memorable** — three easy words, e.g. `amber-otter-loop`.
- **Custom** _(signed-in only)_ — pick your own alias.
- **PIN protection** _(signed-in only)_ — a 6-digit gate before redirect.
- **Result card** — one-tap copy + QR code.
- **Dashboard** — your links with **search**, **pagination**, and a
**comfortable ↔ compact** view toggle (persisted) for large lists. Search and
page are kept in the URL (`?q=&page=`) so they survive a refresh or share, and
paging scrolls back to the top. Each link opens a consistent popup for
**edit**, **delete** (with confirmation), **PIN management** (view / change /
remove), and a **QR code** (copy + download SVG), plus total clicks and a
**7-day click chart** (or sparkline in compact mode).
- **Dark mode** + spring-physics ("bouncy") motion throughout, fully responsive.
## Design
- **Type:** Clash Display (headings) · General Sans (body) · Space Mono (codes).
- **Palette:** warm paper / near-black ink with an **electric-lime** accent —
deliberately not the purple-on-white AI default. All colors are CSS variables
in [`src/index.css`](src/index.css) with a `.dark` override.
- **Motion:** `framer-motion` spring transitions — the segmented mode selector
(shared `layoutId`), result-card pop, staggered hero reveal, chart bars, and
toasts.
## Project structure
```
src/
lib/
mockApi.ts ← THE BACKEND SEAM. Swap these methods for fetch() calls.
shortcode.ts ← base62 code generation, URL + alias validation
words.ts ← memorable-word pools
types.ts ← shared API types (mirror the Go structs)
format.ts ← display helpers (compact numbers, relative time…)
context/ ← Auth + Theme providers
components/ ← Navbar, ShortenForm, ResultCard, UrlCard, BarChart, PinInput…
pages/ ← Home, Login, Dashboard
```
## Swapping in the real backend
Every server interaction goes through the single `api` object in
[`src/lib/mockApi.ts`](src/lib/mockApi.ts). Replacing each method body with a
`fetch()` to the Go service — keeping the same signatures and the types in
[`src/lib/types.ts`](src/lib/types.ts) — is the entire integration.
---
## Backend
> **Built** — see [`backend/`](backend/) and [backend/README.md](backend/README.md).
> Go (hexagonal + DI), Postgres, Redis. One binary with three commands
> (`api` / `frontend` / `redirect`), packed into one [Dockerfile](Dockerfile) and
> wired up by [docker-compose.yml](docker-compose.yml) behind a [Caddy](Caddyfile)
> gateway. Run the whole stack with `docker compose up --build`, then open
> http://localhost:8080. The brief's priority — **minimize DB hits, cache in
> Redis, serve the fastest redirect** — is implemented as described below.
The SPA talks to the **real API** (`src/lib/api.ts`) — there's no mock backend
anymore. Anonymous random/memorable links work immediately; custom aliases,
PINs, the dashboard, and stats require signing in.
### Logging in locally (mock OIDC)
Compose bundles a **mock OpenID Connect provider** (`navikt/mock-oauth2-server`)
so login works with no real Google/OIDC credentials. Because an OIDC issuer URL
must be identical from the browser and the backend, add one line to your hosts
file so the browser can resolve the provider's docker name:
```
# /etc/hosts
127.0.0.1 mock-oidc
```
Then `docker compose up --build`, open http://localhost:8080, click **Sign in →
Continue with SSO**, and you're logged in as the mock "Demo User" (`[email protected]`).
To use real providers instead, set `GOOGLE_*` / `OIDC_*` env vars (and remove the
mock) — the login screen shows a button per configured provider automatically.
**Stack:** Go (HTTP server) · PostgreSQL (source of truth) · Redis (hot cache).
### Data model (Postgres)
```sql
CREATE TABLE links (
id BIGSERIAL PRIMARY KEY, -- drives the base62 random code
code TEXT UNIQUE NOT NULL, -- random | memorable | custom alias
long_url TEXT NOT NULL,
mode SMALLINT NOT NULL,
pin_hash TEXT, -- bcrypt; NULL = no PIN
owner_id BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON links (owner_id);
-- Clicks are append-only and aggregated for the 7-day chart.
CREATE TABLE click_daily (
code TEXT NOT NULL,
day DATE NOT NULL,
count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (code, day)
);
```
### The redirect hot path (the part that must be fast)
`GET /{code}` is the highest-traffic route and must rarely touch Postgres:
1. **`GET code:{code}` from Redis** → on hit, `302` immediately. No DB.
2. On miss, read Postgres once, then `SET code:{code} = long_url` with a TTL
(e.g. 24h). Subsequent hits are pure cache.
3. **Negative cache** unknown codes briefly (`SET code:{code} = "" EX 60`) so
bot/scanner traffic on non-existent codes can't hammer Postgres.
4. **Clicks never block the redirect.** Fire-and-forget `INCR
clicks:{code}:{yyyy-mm-dd}` in Redis; a background worker flushes counters
into `click_daily` every ~10s. The user sees the redirect at Redis latency.
5. PIN-protected codes return the unlock page instead of a 302; the PIN is
checked against `pin_hash` and never leaves the server.
### Code generation
- **random:** insert the row to get the `BIGSERIAL id`, then base62-encode a
scrambled id → shortest globally-unique string, no collision checks. (The
mock mirrors this in `shortcode.ts`.)
- **memorable:** pick 3 words; on the rare unique-constraint violation, retry
with a different combo or a short numeric suffix.
- **custom:** validate against `^[a-zA-Z0-9_-]{3,32}$`, rely on the `UNIQUE`
constraint to reject collisions.
### Dashboard reads (list, search, pagination)
`GET /api/links?q=&page=&perPage=&view=` is owner-scoped. Search runs as a
Postgres `WHERE owner_id = $1 AND (code ILIKE $2 OR long_url ILIKE $2)` with
`LIMIT/OFFSET` (or keyset pagination on `created_at` for large accounts); a
`pg_trgm` index on `code`/`long_url` keeps `ILIKE` fast. The list response can
use a short-TTL per-user cache keyed `user:{id}:links` (the frontend already
paginates/filters in-memory, so the API can also return the full owned set and
let the client slice — fine until a user has thousands of links).
### PINs
`pin_hash` is bcrypt and **never returned**. The UI's "reveal" is a
prototype-only convenience (see `getPin` in `mockApi.ts`); against the real
service the PIN manager would only **set / replace / remove**
(`PUT /api/links/{id}/pin`, `DELETE /api/links/{id}/pin`), never display an
existing value.
### Cache invalidation
On `PUT`/`DELETE` of a link (or its PIN), delete `code:{code}` and
`user:{id}:links` from Redis so the next read repopulates from Postgres.
```
+114
View File
@@ -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.
+100
View File
@@ -0,0 +1,100 @@
// Command snip is a single binary exposing three subcommands — api, frontend
// and redirect — so one Docker image can back three docker-compose services.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/snip/backend/internal/adapter/postgres"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/di"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
cmd := os.Args[1]
cfg := config.Load()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
container := di.New(cfg)
var (
app *di.App
addr string
err error
)
switch cmd {
case "migrate":
if cfg.Store != "postgres" {
log.Printf("snip migrate: STORE=%s, nothing to do", cfg.Store)
os.Exit(0)
}
if err := postgres.Migrate(ctx, cfg.DatabaseURL); err != nil {
log.Fatalf("snip migrate: %v", err)
}
log.Println("snip migrate: schema is up to date")
os.Exit(0)
case "frontend":
app, addr = container.BuildFrontend(), listenAddr("8081")
case "api":
app, err = container.BuildAPI(ctx)
addr = listenAddr("8080")
case "redirect":
app, err = container.BuildRedirect(ctx)
addr = listenAddr("8082")
default:
usage()
os.Exit(2)
}
if err != nil {
log.Fatalf("snip %s: startup failed: %v", cmd, err)
}
defer app.Close()
serve(ctx, cmd, addr, app.Handler)
}
func listenAddr(def string) string {
if p := os.Getenv("PORT"); p != "" {
return ":" + p
}
return ":" + def
}
func serve(ctx context.Context, name, addr string, h http.Handler) {
srv := &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("snip %s listening on %s", name, addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("snip %s: %v", name, err)
}
}()
<-ctx.Done()
log.Printf("snip %s: shutting down", name)
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: snip <migrate|api|frontend|redirect>")
}
+22
View File
@@ -0,0 +1,22 @@
module github.com/snip/backend
go 1.25.0
require (
github.com/coreos/go-oidc/v3 v3.18.0
github.com/jackc/pgx/v5 v5.10.0
github.com/redis/go-redis/v9 v9.20.1
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
+50
View File
@@ -0,0 +1,50 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+74
View File
@@ -0,0 +1,74 @@
// Package identity adapts OpenID Connect providers (Google, generic OIDC) to
// the IdentityProvider port. Google is just an OIDC issuer, so one
// implementation covers both required login methods.
package identity
import (
"context"
"errors"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"github.com/snip/backend/internal/port"
)
// GoogleIssuer is Google's OIDC discovery issuer.
const GoogleIssuer = "https://accounts.google.com"
type OIDCProvider struct {
name string
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
}
// NewOIDCProvider performs OIDC discovery against the issuer (one network call
// at startup) and returns a ready provider.
func NewOIDCProvider(ctx context.Context, name, issuer, clientID, clientSecret, redirectURL string, extraScopes []string) (*OIDCProvider, error) {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, err
}
scopes := append([]string{oidc.ScopeOpenID, "email", "profile"}, extraScopes...)
return &OIDCProvider{
name: name,
oauth: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: redirectURL,
Scopes: scopes,
},
verifier: provider.Verifier(&oidc.Config{ClientID: clientID}),
}, nil
}
func (p *OIDCProvider) Name() string { return p.name }
func (p *OIDCProvider) AuthURL(state string) string {
return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
func (p *OIDCProvider) Exchange(ctx context.Context, code string) (*port.Identity, error) {
tok, err := p.oauth.Exchange(ctx, code)
if err != nil {
return nil, err
}
rawID, ok := tok.Extra("id_token").(string)
if !ok {
return nil, errors.New("oidc: response missing id_token")
}
idToken, err := p.verifier.Verify(ctx, rawID)
if err != nil {
return nil, err
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
return &port.Identity{Subject: claims.Sub, Email: claims.Email, Name: claims.Name}, nil
}
+48
View File
@@ -0,0 +1,48 @@
package memory
import (
"context"
"sync"
"time"
"github.com/snip/backend/internal/port"
)
type cacheEntry struct {
res port.Resolution
expires time.Time
}
// Cache is a TTL map standing in for Redis.
type Cache struct {
mu sync.RWMutex
m map[string]cacheEntry
}
func NewCache() *Cache {
return &Cache{m: make(map[string]cacheEntry)}
}
func (c *Cache) GetResolution(_ context.Context, code string) (port.Resolution, bool, error) {
c.mu.RLock()
e, ok := c.m[code]
c.mu.RUnlock()
if !ok || time.Now().After(e.expires) {
return port.Resolution{}, false, nil
}
return e.res, true, nil
}
func (c *Cache) SetResolution(_ context.Context, code string, r port.Resolution, ttl time.Duration) error {
c.mu.Lock()
c.m[code] = cacheEntry{res: r, expires: time.Now().Add(ttl)}
c.mu.Unlock()
return nil
}
func (c *Cache) Invalidate(_ context.Context, code string) error {
c.mu.Lock()
delete(c.m, code)
c.mu.Unlock()
return nil
}
@@ -0,0 +1,171 @@
// Package memory provides in-memory adapters used by tests and for running the
// stack without Postgres/Redis (STORE=memory).
package memory
import (
"context"
"sort"
"strings"
"sync"
"time"
"github.com/snip/backend/internal/domain"
)
type LinkRepo struct {
mu sync.RWMutex
byID map[string]*domain.Link
seq int64
clock func() time.Time
}
func NewLinkRepo() *LinkRepo {
return &LinkRepo{byID: make(map[string]*domain.Link), clock: time.Now}
}
func clone(l *domain.Link) *domain.Link {
cp := *l
cp.Last7Days = append([]domain.DayCount(nil), l.Last7Days...)
return &cp
}
func (r *LinkRepo) Create(_ context.Context, l *domain.Link) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, e := range r.byID {
if e.Code == l.Code {
return domain.ErrCodeTaken
}
}
r.byID[l.ID] = clone(l)
return nil
}
func (r *LinkRepo) GetByID(_ context.Context, id string) (*domain.Link, error) {
r.mu.RLock()
defer r.mu.RUnlock()
l, ok := r.byID[id]
if !ok {
return nil, domain.ErrNotFound
}
return clone(l), nil
}
func (r *LinkRepo) GetByCode(_ context.Context, code string) (*domain.Link, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, l := range r.byID {
if l.Code == code {
return clone(l), nil
}
}
return nil, domain.ErrNotFound
}
func (r *LinkRepo) ListByOwner(_ context.Context, ownerID, query string, limit, offset int) ([]domain.Link, int, error) {
r.mu.RLock()
defer r.mu.RUnlock()
q := strings.ToLower(query)
var matched []domain.Link
for _, l := range r.byID {
if l.OwnerID != ownerID {
continue
}
if q != "" && !strings.Contains(strings.ToLower(l.Code), q) && !strings.Contains(strings.ToLower(l.LongURL), q) {
continue
}
matched = append(matched, *clone(l))
}
sort.Slice(matched, func(i, j int) bool { return matched[i].CreatedAt.After(matched[j].CreatedAt) })
total := len(matched)
if offset > total {
offset = total
}
end := offset + limit
if limit <= 0 || end > total {
end = total
}
return matched[offset:end], total, nil
}
func (r *LinkRepo) StatsByOwner(_ context.Context, ownerID string) (domain.OwnerStats, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var s domain.OwnerStats
for _, l := range r.byID {
if l.OwnerID != ownerID {
continue
}
s.TotalLinks++
s.TotalClicks += l.TotalClicks
for _, d := range l.Last7Days {
s.WeekClicks += d.Count
}
}
return s, nil
}
func (r *LinkRepo) Update(_ context.Context, l *domain.Link) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byID[l.ID]; !ok {
return domain.ErrNotFound
}
r.byID[l.ID] = clone(l)
return nil
}
func (r *LinkRepo) Delete(_ context.Context, id, ownerID string) error {
r.mu.Lock()
defer r.mu.Unlock()
l, ok := r.byID[id]
if !ok || l.OwnerID != ownerID {
return domain.ErrNotFound
}
delete(r.byID, id)
return nil
}
func (r *LinkRepo) ExistsCode(_ context.Context, code string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, l := range r.byID {
if l.Code == code {
return true, nil
}
}
return false, nil
}
func (r *LinkRepo) NextSequence(_ context.Context) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.seq++
return r.seq, nil
}
func (r *LinkRepo) RecordClicks(_ context.Context, code string, day time.Time, n int64) error {
r.mu.Lock()
defer r.mu.Unlock()
key := day.Format("2006-01-02")
for _, l := range r.byID {
if l.Code != code {
continue
}
l.TotalClicks += n
found := false
for i := range l.Last7Days {
if l.Last7Days[i].Date == key {
l.Last7Days[i].Count += n
found = true
break
}
}
if !found {
l.Last7Days = append(l.Last7Days, domain.DayCount{Date: key, Count: n})
}
return nil
}
return domain.ErrNotFound
}
@@ -0,0 +1,60 @@
package memory
import (
"context"
"crypto/rand"
"sync"
"github.com/snip/backend/internal/domain"
)
type UserRepo struct {
mu sync.RWMutex
byID map[string]*domain.User
byKey map[string]string // provider|subject -> id
}
func NewUserRepo() *UserRepo {
return &UserRepo{byID: make(map[string]*domain.User), byKey: make(map[string]string)}
}
func (r *UserRepo) Upsert(_ context.Context, u *domain.User) (*domain.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := u.Provider + "|" + u.Subject
if id, ok := r.byKey[key]; ok {
existing := r.byID[id]
existing.Email = u.Email
existing.Name = u.Name
cp := *existing
return &cp, nil
}
id := randomID()
u.ID = id
r.byID[id] = u
r.byKey[key] = id
cp := *u
return &cp, nil
}
func (r *UserRepo) GetByID(_ context.Context, id string) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
u, ok := r.byID[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *u
return &cp, nil
}
func randomID() string {
const hex = "0123456789abcdef"
b := make([]byte, 16)
_, _ = rand.Read(b)
out := make([]byte, 16)
for i := range out {
out[i] = hex[int(b[i])%16]
}
return "u_" + string(out)
}
+98
View File
@@ -0,0 +1,98 @@
// Package postgres implements the repository ports on top of pgx/pgxpool.
package postgres
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
const schema = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
name TEXT NOT NULL,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (provider, subject)
);
CREATE SEQUENCE IF NOT EXISTS link_code_seq;
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
long_url TEXT NOT NULL,
mode TEXT NOT NULL,
has_pin BOOLEAN NOT NULL DEFAULT false,
pin_hash TEXT NOT NULL DEFAULT '',
owner_id TEXT,
total_clicks BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS links_owner_idx ON links (owner_id);
CREATE TABLE IF NOT EXISTS click_daily (
code TEXT NOT NULL,
day DATE NOT NULL,
count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (code, day)
);
`
// migrationLockKey is an arbitrary, stable key for the advisory lock that
// serializes schema creation across concurrently-starting services.
const migrationLockKey = 947213
// Migrate opens a short-lived connection pool, runs the schema migrations, and
// closes the pool. Safe to call concurrently: an advisory lock serialises DDL
// across multiple callers. All statements use IF NOT EXISTS so re-runs are
// idempotent and no data is lost.
func Migrate(ctx context.Context, dsn string) error {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return err
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
return err
}
return migrate(ctx, pool)
}
// Connect opens a pool and applies the schema (idempotent migrations). Because
// all three commands may boot at once and each runs migrations, the DDL is
// guarded by a session advisory lock so concurrent `CREATE ... IF NOT EXISTS`
// can't race on the Postgres catalog.
func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
if err := migrate(ctx, pool); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
func migrate(ctx context.Context, pool *pgxpool.Pool) error {
conn, err := pool.Acquire(ctx) // advisory lock is session-scoped → one conn
if err != nil {
return err
}
defer conn.Release()
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
return err
}
defer conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", migrationLockKey)
_, err = conn.Exec(ctx, schema)
return err
}
@@ -0,0 +1,230 @@
package postgres
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/snip/backend/internal/domain"
)
type LinkRepo struct{ pool *pgxpool.Pool }
func NewLinkRepo(pool *pgxpool.Pool) *LinkRepo { return &LinkRepo{pool: pool} }
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func (r *LinkRepo) Create(ctx context.Context, l *domain.Link) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO links (id, code, long_url, mode, has_pin, pin_hash, owner_id, total_clicks, created_at)
VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),0,$8)`,
l.ID, l.Code, l.LongURL, string(l.Mode), l.HasPin, l.PinHash, l.OwnerID, l.CreatedAt)
if isUniqueViolation(err) {
return domain.ErrCodeTaken
}
return err
}
const selectCols = `id, code, long_url, mode, has_pin, pin_hash, COALESCE(owner_id,''), total_clicks, created_at`
func scanLink(row pgx.Row) (*domain.Link, error) {
var l domain.Link
var mode string
if err := row.Scan(&l.ID, &l.Code, &l.LongURL, &mode, &l.HasPin, &l.PinHash, &l.OwnerID, &l.TotalClicks, &l.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
l.Mode = domain.Mode(mode)
return &l, nil
}
// GetByCode is the redirect hot-path read (cache miss only). It deliberately
// skips the 7-day stats join — the redirect doesn't need them.
func (r *LinkRepo) GetByCode(ctx context.Context, code string) (*domain.Link, error) {
return scanLink(r.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM links WHERE code=$1`, code))
}
func (r *LinkRepo) GetByID(ctx context.Context, id string) (*domain.Link, error) {
l, err := scanLink(r.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM links WHERE id=$1`, id))
if err != nil {
return nil, err
}
weeks, err := r.weeksFor(ctx, []string{l.Code})
if err != nil {
return nil, err
}
l.Last7Days = fillWeek(weeks[l.Code], time.Now().UTC())
return l, nil
}
func (r *LinkRepo) ListByOwner(ctx context.Context, ownerID, query string, limit, offset int) ([]domain.Link, int, error) {
// `pattern` is NULL when there's no query, so the filter is skipped.
var pattern any
if query != "" {
pattern = "%" + query + "%"
}
var total int
if err := r.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM links
WHERE owner_id=$1 AND ($2::text IS NULL OR code ILIKE $2 OR long_url ILIKE $2)`,
ownerID, pattern).Scan(&total); err != nil {
return nil, 0, err
}
if total == 0 {
return nil, 0, nil
}
rows, err := r.pool.Query(ctx,
`SELECT `+selectCols+` FROM links
WHERE owner_id=$1 AND ($2::text IS NULL OR code ILIKE $2 OR long_url ILIKE $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4`,
ownerID, pattern, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var links []domain.Link
var codes []string
for rows.Next() {
l, err := scanLink(rows)
if err != nil {
return nil, 0, err
}
links = append(links, *l)
codes = append(codes, l.Code)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
weeks, err := r.weeksFor(ctx, codes)
if err != nil {
return nil, 0, err
}
for i := range links {
links[i].Last7Days = fillWeek(weeks[links[i].Code], time.Now().UTC())
}
return links, total, nil
}
func (r *LinkRepo) StatsByOwner(ctx context.Context, ownerID string) (domain.OwnerStats, error) {
var s domain.OwnerStats
if err := r.pool.QueryRow(ctx,
`SELECT COUNT(*), COALESCE(SUM(total_clicks),0) FROM links WHERE owner_id=$1`,
ownerID).Scan(&s.TotalLinks, &s.TotalClicks); err != nil {
return s, err
}
since := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02")
if err := r.pool.QueryRow(ctx,
`SELECT COALESCE(SUM(cd.count),0) FROM click_daily cd
JOIN links l ON l.code = cd.code
WHERE l.owner_id=$1 AND cd.day >= $2`,
ownerID, since).Scan(&s.WeekClicks); err != nil {
return s, err
}
return s, nil
}
func (r *LinkRepo) Update(ctx context.Context, l *domain.Link) error {
tag, err := r.pool.Exec(ctx,
`UPDATE links SET long_url=$2, has_pin=$3, pin_hash=$4 WHERE id=$1`,
l.ID, l.LongURL, l.HasPin, l.PinHash)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *LinkRepo) Delete(ctx context.Context, id, ownerID string) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM links WHERE id=$1 AND owner_id=$2`, id, ownerID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *LinkRepo) ExistsCode(ctx context.Context, code string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM links WHERE code=$1)`, code).Scan(&exists)
return exists, err
}
func (r *LinkRepo) NextSequence(ctx context.Context) (int64, error) {
var seq int64
err := r.pool.QueryRow(ctx, `SELECT nextval('link_code_seq')`).Scan(&seq)
return seq, err
}
func (r *LinkRepo) RecordClicks(ctx context.Context, code string, day time.Time, n int64) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`INSERT INTO click_daily (code, day, count) VALUES ($1,$2,$3)
ON CONFLICT (code, day) DO UPDATE SET count = click_daily.count + EXCLUDED.count`,
code, day, n); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE links SET total_clicks = total_clicks + $2 WHERE code=$1`, code, n); err != nil {
return err
}
return tx.Commit(ctx)
}
// weeksFor returns, per code, that code's day->count map for the last 7 days.
func (r *LinkRepo) weeksFor(ctx context.Context, codes []string) (map[string]map[string]int64, error) {
out := make(map[string]map[string]int64)
if len(codes) == 0 {
return out, nil
}
since := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02")
rows, err := r.pool.Query(ctx,
`SELECT code, to_char(day,'YYYY-MM-DD'), count FROM click_daily WHERE code = ANY($1) AND day >= $2`,
codes, since)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var code, day string
var count int64
if err := rows.Scan(&code, &day, &count); err != nil {
return nil, err
}
if out[code] == nil {
out[code] = make(map[string]int64)
}
out[code][day] = count
}
return out, rows.Err()
}
// fillWeek expands a sparse day->count map into a dense 7-day window.
func fillWeek(counts map[string]int64, now time.Time) []domain.DayCount {
out := make([]domain.DayCount, 0, 7)
for i := 6; i >= 0; i-- {
key := now.AddDate(0, 0, -i).Format("2006-01-02")
out = append(out, domain.DayCount{Date: key, Count: counts[key]})
}
return out
}
@@ -0,0 +1,58 @@
package postgres
import (
"context"
"crypto/rand"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/snip/backend/internal/domain"
)
type UserRepo struct{ pool *pgxpool.Pool }
func NewUserRepo(pool *pgxpool.Pool) *UserRepo { return &UserRepo{pool: pool} }
func (r *UserRepo) Upsert(ctx context.Context, u *domain.User) (*domain.User, error) {
id := newUserID()
var out domain.User
err := r.pool.QueryRow(ctx,
`INSERT INTO users (id, email, name, provider, subject)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (provider, subject)
DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name
RETURNING id, email, name, provider, subject`,
id, u.Email, u.Name, u.Provider, u.Subject).
Scan(&out.ID, &out.Email, &out.Name, &out.Provider, &out.Subject)
if err != nil {
return nil, err
}
return &out, nil
}
func (r *UserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
var u domain.User
err := r.pool.QueryRow(ctx,
`SELECT id, email, name, provider, subject FROM users WHERE id=$1`, id).
Scan(&u.ID, &u.Email, &u.Name, &u.Provider, &u.Subject)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &u, nil
}
func newUserID() string {
const hex = "0123456789abcdef"
b := make([]byte, 16)
_, _ = rand.Read(b)
out := make([]byte, 16)
for i := range out {
out[i] = hex[int(b[i])%16]
}
return "u_" + string(out)
}
@@ -0,0 +1,58 @@
// Package rediscache implements the LinkCache port on Redis. This is the layer
// that keeps the redirect hot path off Postgres.
package rediscache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
"github.com/snip/backend/internal/port"
)
type Cache struct {
rdb *redis.Client
prefix string
}
func New(ctx context.Context, addr, password string, db int) (*Cache, error) {
rdb := redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})
if err := rdb.Ping(ctx).Err(); err != nil {
_ = rdb.Close()
return nil, err
}
return &Cache{rdb: rdb, prefix: "code:"}, nil
}
func (c *Cache) key(code string) string { return c.prefix + code }
func (c *Cache) GetResolution(ctx context.Context, code string) (port.Resolution, bool, error) {
b, err := c.rdb.Get(ctx, c.key(code)).Bytes()
if err == redis.Nil {
return port.Resolution{}, false, nil
}
if err != nil {
return port.Resolution{}, false, err
}
var r port.Resolution
if err := json.Unmarshal(b, &r); err != nil {
return port.Resolution{}, false, err
}
return r, true, nil
}
func (c *Cache) SetResolution(ctx context.Context, code string, r port.Resolution, ttl time.Duration) error {
b, err := json.Marshal(r)
if err != nil {
return err
}
return c.rdb.Set(ctx, c.key(code), b, ttl).Err()
}
func (c *Cache) Invalidate(ctx context.Context, code string) error {
return c.rdb.Del(ctx, c.key(code)).Err()
}
func (c *Cache) Close() error { return c.rdb.Close() }
@@ -0,0 +1,74 @@
// Package security provides the password hasher and stateless session manager.
package security
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// BcryptHasher implements port.PasswordHasher.
type BcryptHasher struct{ cost int }
func NewBcryptHasher() *BcryptHasher { return &BcryptHasher{cost: bcrypt.DefaultCost} }
func (h *BcryptHasher) Hash(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), h.cost)
return string(b), err
}
func (h *BcryptHasher) Compare(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// HMACSessions implements port.SessionManager with signed, stateless tokens of
// the form base64(userID|expiryUnix).base64(hmacSHA256). No server-side store.
type HMACSessions struct{ key []byte }
func NewHMACSessions(secret string) *HMACSessions {
return &HMACSessions{key: []byte(secret)}
}
func (s *HMACSessions) Issue(userID string, ttl time.Duration) (string, error) {
payload := userID + "|" + strconv.FormatInt(time.Now().Add(ttl).Unix(), 10)
p := base64.RawURLEncoding.EncodeToString([]byte(payload))
return p + "." + s.sign(p), nil
}
func (s *HMACSessions) Verify(token string) (string, error) {
p, sig, ok := strings.Cut(token, ".")
if !ok {
return "", errors.New("malformed token")
}
if !hmac.Equal([]byte(sig), []byte(s.sign(p))) {
return "", errors.New("bad signature")
}
raw, err := base64.RawURLEncoding.DecodeString(p)
if err != nil {
return "", err
}
userID, expStr, ok := strings.Cut(string(raw), "|")
if !ok {
return "", errors.New("malformed payload")
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return "", err
}
if time.Now().Unix() > exp {
return "", errors.New("session expired")
}
return userID, nil
}
func (s *HMACSessions) sign(payload string) string {
mac := hmac.New(sha256.New, s.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
+92
View File
@@ -0,0 +1,92 @@
// Package config loads runtime settings from the environment.
package config
import (
"os"
"strconv"
"time"
)
type Config struct {
// Storage: "postgres" (default) or "memory" (no infra, for dev/tests).
Store string
DatabaseURL string
RedisAddr string
RedisPassword string
RedisDB int
// PublicURL is the externally reachable base (used to build OAuth callback
// URLs). PostLoginRedirect is where users land after a successful login.
PublicURL string
PostLoginRedirect string
// FrontendDist is the directory of the built SPA served by the frontend cmd.
FrontendDist string
// ShortDomain is the display host for short links (e.g. snip.to).
ShortDomain string
SessionSecret string
CookieSecure bool
// Identity providers (each optional; absent => that login button is hidden).
GoogleClientID string
GoogleClientSecret string
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
ClickFlushInterval time.Duration
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
b, err := strconv.ParseBool(v)
if err == nil {
return b
}
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
n, err := strconv.Atoi(v)
if err == nil {
return n
}
}
return def
}
// Load reads configuration from the environment with sensible local defaults.
func Load() Config {
flush := time.Duration(envInt("CLICK_FLUSH_SECONDS", 10)) * time.Second
return Config{
Store: env("STORE", "postgres"),
DatabaseURL: env("DATABASE_URL", "postgres://snip:snip@localhost:5432/snip?sslmode=disable"),
RedisAddr: env("REDIS_ADDR", "localhost:6379"),
RedisPassword: env("REDIS_PASSWORD", ""),
RedisDB: envInt("REDIS_DB", 0),
PublicURL: env("PUBLIC_URL", "http://localhost:8080"),
PostLoginRedirect: env("POST_LOGIN_REDIRECT", "/dashboard"),
FrontendDist: env("FRONTEND_DIST", "./web"),
ShortDomain: env("SHORT_DOMAIN", "snip.to"),
SessionSecret: env("SESSION_SECRET", "dev-insecure-secret-change-me"),
CookieSecure: envBool("COOKIE_SECURE", false),
GoogleClientID: env("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: env("GOOGLE_CLIENT_SECRET", ""),
OIDCIssuer: env("OIDC_ISSUER", ""),
OIDCClientID: env("OIDC_CLIENT_ID", ""),
OIDCClientSecret: env("OIDC_CLIENT_SECRET", ""),
ClickFlushInterval: flush,
}
}
+160
View File
@@ -0,0 +1,160 @@
// Package di wires concrete adapters into the application core. Each command
// builds only what it needs (the frontend command touches no infrastructure).
package di
import (
"context"
"log"
"net/http"
"time"
"github.com/snip/backend/internal/adapter/identity"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/adapter/postgres"
"github.com/snip/backend/internal/adapter/rediscache"
"github.com/snip/backend/internal/adapter/security"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/httpx/api"
"github.com/snip/backend/internal/httpx/frontend"
"github.com/snip/backend/internal/httpx/redirect"
"github.com/snip/backend/internal/port"
"github.com/snip/backend/internal/service"
)
// Container holds config and constructs per-command applications.
type Container struct {
cfg config.Config
}
func New(cfg config.Config) *Container { return &Container{cfg: cfg} }
// App is a runnable handler plus its resource closers.
type App struct {
Handler http.Handler
closers []func()
}
func (a *App) Close() {
for i := len(a.closers) - 1; i >= 0; i-- {
a.closers[i]()
}
}
type infra struct {
links port.LinkRepository
users port.UserRepository
cache port.LinkCache
closers []func()
}
// buildInfra picks the storage adapters based on STORE.
func (c *Container) buildInfra(ctx context.Context) (*infra, error) {
if c.cfg.Store == "memory" {
return &infra{links: memory.NewLinkRepo(), users: memory.NewUserRepo(), cache: memory.NewCache()}, nil
}
pool, err := postgres.Connect(ctx, c.cfg.DatabaseURL)
if err != nil {
return nil, err
}
cache, err := rediscache.New(ctx, c.cfg.RedisAddr, c.cfg.RedisPassword, c.cfg.RedisDB)
if err != nil {
pool.Close()
return nil, err
}
return &infra{
links: postgres.NewLinkRepo(pool),
users: postgres.NewUserRepo(pool),
cache: cache,
closers: []func(){pool.Close, func() { _ = cache.Close() }},
}, nil
}
// BuildFrontend constructs the static SPA server (no DB/Redis).
func (c *Container) BuildFrontend() *App {
return &App{Handler: frontend.New(c.cfg.FrontendDist).Handler()}
}
// BuildAPI constructs the /api/v1 server. OIDC provider discovery runs in the
// background so the HTTP server (and its health endpoint) starts immediately.
// Providers become available once discovery completes; until then the /auth/*
// endpoints return an empty provider list and login buttons are hidden.
func (c *Container) BuildAPI(ctx context.Context) (*App, error) {
inf, err := c.buildInfra(ctx)
if err != nil {
return nil, err
}
hasher := security.NewBcryptHasher()
sessions := security.NewHMACSessions(c.cfg.SessionSecret)
linkSvc := service.NewLinkService(inf.links, inf.cache, hasher)
authSvc := service.NewAuthService(nil, inf.users, sessions) // providers injected below
h := api.New(linkSvc, authSvc, sessions, c.cfg).Handler()
// Discover OIDC providers in the background; inject them once ready.
go func() {
providers := c.buildProviders(ctx)
authSvc.SetProviders(providers)
log.Printf("di: %d identity provider(s) ready", len(providers))
}()
return &App{Handler: h, closers: inf.closers}, nil
}
// BuildRedirect constructs the redirect server and starts the click flusher.
func (c *Container) BuildRedirect(ctx context.Context) (*App, error) {
inf, err := c.buildInfra(ctx)
if err != nil {
return nil, err
}
hasher := security.NewBcryptHasher()
recorder := service.NewAsyncClickRecorder(inf.links, c.cfg.ClickFlushInterval)
recorder.Start(ctx)
redirectSvc := service.NewRedirectService(inf.links, inf.cache, hasher, recorder)
h := redirect.New(redirectSvc, c.cfg.ShortDomain, c.cfg.PublicURL).Handler()
closers := append([]func(){recorder.Stop}, inf.closers...)
return &App{Handler: h, closers: closers}, nil
}
// buildProviders configures the available login methods. Each is optional: if
// its credentials are absent (or discovery fails) the button simply won't show.
func (c *Container) buildProviders(ctx context.Context) []port.IdentityProvider {
var providers []port.IdentityProvider
callback := func(name string) string {
return c.cfg.PublicURL + "/api/v1/auth/" + name + "/callback"
}
if c.cfg.GoogleClientID != "" && c.cfg.GoogleClientSecret != "" {
if p := dialProvider(ctx, "google", identity.GoogleIssuer,
c.cfg.GoogleClientID, c.cfg.GoogleClientSecret, callback("google")); p != nil {
providers = append(providers, p)
}
}
if c.cfg.OIDCIssuer != "" && c.cfg.OIDCClientID != "" {
if p := dialProvider(ctx, "oidc", c.cfg.OIDCIssuer,
c.cfg.OIDCClientID, c.cfg.OIDCClientSecret, callback("oidc")); p != nil {
providers = append(providers, p)
}
}
return providers
}
// dialProvider performs OIDC discovery with a short retry, so the api command
// can start alongside a still-booting (mock) provider instead of silently
// disabling login on a transient connection error.
func dialProvider(ctx context.Context, name, issuer, clientID, secret, redirect string) port.IdentityProvider {
var lastErr error
for attempt := 1; attempt <= 15; attempt++ {
p, err := identity.NewOIDCProvider(ctx, name, issuer, clientID, secret, redirect, nil)
if err == nil {
log.Printf("di: %s provider ready (issuer %s)", name, issuer)
return p
}
lastErr = err
select {
case <-ctx.Done():
return nil
case <-time.After(2 * time.Second):
}
}
log.Printf("di: %s provider disabled after retries: %v", name, lastErr)
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package domain
import "errors"
// Sentinel errors crossing the port boundary. Adapters translate infra errors
// into these; HTTP layer maps these to status codes.
var (
ErrNotFound = errors.New("not found")
ErrCodeTaken = errors.New("code already taken")
ErrReserved = errors.New("code is reserved")
ErrInvalidURL = errors.New("invalid destination url")
ErrInvalidAlias = errors.New("invalid custom alias")
ErrInvalidPin = errors.New("pin must be exactly 6 digits")
ErrUnauthorized = errors.New("authentication required")
ErrForbidden = errors.New("not allowed")
ErrPinRequired = errors.New("pin required")
ErrPinInvalid = errors.New("incorrect pin")
)
+49
View File
@@ -0,0 +1,49 @@
package domain
import "time"
// Mode is how a short code was produced.
type Mode string
const (
ModeRandom Mode = "random"
ModeMemorable Mode = "memorable"
ModeCustom Mode = "custom"
)
// DayCount is a single day's click total.
type DayCount struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
// Link is the aggregate root: a short code pointing at a destination.
type Link struct {
ID string
Code string
LongURL string
Mode Mode
HasPin bool
PinHash string // bcrypt hash; never serialized out of the system
OwnerID string // empty for anonymous links
CreatedAt time.Time
TotalClicks int64
Last7Days []DayCount
}
// OwnerStats are the dashboard aggregate tiles, computed in the DB so the
// client never has to fetch every link to total them up.
type OwnerStats struct {
TotalLinks int `json:"totalLinks"`
TotalClicks int64 `json:"totalClicks"`
WeekClicks int64 `json:"weekClicks"`
}
// User is an authenticated account, keyed by (provider, subject).
type User struct {
ID string
Email string
Name string
Provider string
Subject string
}
+198
View File
@@ -0,0 +1,198 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/adapter/security"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/httpx/api"
"github.com/snip/backend/internal/service"
)
type harness struct {
h http.Handler
token string
}
func newHarness(t *testing.T) *harness {
t.Helper()
repo := memory.NewLinkRepo()
cache := memory.NewCache()
users := memory.NewUserRepo()
hasher := security.NewBcryptHasher()
sessions := security.NewHMACSessions("test-secret")
links := service.NewLinkService(repo, cache, hasher)
auth := service.NewAuthService(nil, users, sessions)
cfg := config.Config{PostLoginRedirect: "/dashboard"}
user, err := users.Upsert(context.Background(), &domain.User{
Email: "[email protected]", Name: "Sam", Provider: "test", Subject: "s1",
})
if err != nil {
t.Fatal(err)
}
token, _ := sessions.Issue(user.ID, time.Hour)
return &harness{h: api.New(links, auth, sessions, cfg).Handler(), token: token}
}
func (h *harness) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
if token != "" {
r.AddCookie(&http.Cookie{Name: "snip_session", Value: token})
}
w := httptest.NewRecorder()
h.h.ServeHTTP(w, r)
return w
}
func decodeLink(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("bad json (%d): %s", w.Code, w.Body.String())
}
return m
}
func TestAnonCanCreateRandomButNotList(t *testing.T) {
h := newHarness(t)
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/x","mode":"random"}`, "")
if w.Code != http.StatusCreated {
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
}
if code, _ := decodeLink(t, w)["code"].(string); code == "" {
t.Fatal("expected a code")
}
if w := h.do(t, http.MethodGet, "/api/v1/links", "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("anon list: want 401, got %d", w.Code)
}
}
func TestCustomAndReservedAndConflict(t *testing.T) {
h := newHarness(t)
// reserved alias → 409
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"dashboard"}`, h.token); w.Code != http.StatusConflict {
t.Fatalf("reserved: want 409, got %d", w.Code)
}
// valid custom → 201
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusCreated {
t.Fatalf("custom: want 201, got %d: %s", w.Code, w.Body.String())
}
// duplicate → 409
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusConflict {
t.Fatalf("dupe: want 409, got %d", w.Code)
}
// anon custom → 401
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"x9z"}`, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("anon custom: want 401, got %d", w.Code)
}
}
func TestFullLifecycleWithPin(t *testing.T) {
h := newHarness(t)
// create
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/a","mode":"random"}`, h.token)
id := decodeLink(t, w)["id"].(string)
// list shows it
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
if w.Code != http.StatusOK {
t.Fatalf("list: %d", w.Code)
}
if total := listTotal(t, w); total != 1 {
t.Fatalf("want total 1, got %d", total)
}
// patch destination
w = h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"acme.com/b"}`, h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["longUrl"] != "https://acme.com/b" {
t.Fatalf("patch failed: %d %s", w.Code, w.Body.String())
}
// set pin
w = h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"123456"}`, h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != true {
t.Fatalf("set pin failed: %d %s", w.Code, w.Body.String())
}
// bad pin → 400
if w := h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"12"}`, h.token); w.Code != http.StatusBadRequest {
t.Fatalf("bad pin: want 400, got %d", w.Code)
}
// remove pin
w = h.do(t, http.MethodDelete, "/api/v1/links/"+id+"/pin", "", h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != false {
t.Fatalf("remove pin failed: %d", w.Code)
}
// delete
if w := h.do(t, http.MethodDelete, "/api/v1/links/"+id, "", h.token); w.Code != http.StatusNoContent {
t.Fatalf("delete: want 204, got %d", w.Code)
}
// gone from list
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
if total := listTotal(t, w); total != 0 {
t.Fatalf("want 0 links after delete, got %d", total)
}
}
func listTotal(t *testing.T, w *httptest.ResponseRecorder) int {
t.Helper()
var resp struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad list json: %s", w.Body.String())
}
return resp.Total
}
func TestPatchForbiddenForOtherUser(t *testing.T) {
h := newHarness(t)
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"random"}`, h.token)
id := decodeLink(t, w)["id"].(string)
other := security.NewHMACSessions("test-secret")
otherToken, _ := other.Issue("someone-else", time.Hour)
if w := h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"https://evil.com"}`, otherToken); w.Code != http.StatusForbidden {
t.Fatalf("want 403 for non-owner, got %d", w.Code)
}
}
func TestMeRequiresSession(t *testing.T) {
h := newHarness(t)
if w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("me anon: want 401, got %d", w.Code)
}
w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["email"] != "[email protected]" {
t.Fatalf("me: %d %s", w.Code, w.Body.String())
}
}
func TestHealthz(t *testing.T) {
h := newHarness(t)
if w := h.do(t, http.MethodGet, "/api/v1/healthz", "", ""); w.Code != http.StatusOK {
t.Fatalf("healthz: %d", w.Code)
}
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/snip/backend/internal/domain"
)
// /api/v1/auth/...
// GET providers
// GET {provider}/login
// GET {provider}/callback
// GET me
// POST logout
func (a *API) authRoutes(w http.ResponseWriter, r *http.Request) {
rest := pathTail(r.URL.Path, "/api/v1/auth/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
switch {
case len(parts) == 1 && parts[0] == "providers":
writeJSON(w, http.StatusOK, map[string][]string{"providers": a.auth.Providers()})
case len(parts) == 1 && parts[0] == "me":
a.me(w, r)
case len(parts) == 1 && parts[0] == "logout":
a.clearCookie(w, sessionCookie)
w.WriteHeader(http.StatusNoContent)
case len(parts) == 2 && parts[1] == "login":
a.beginLogin(w, r, parts[0])
case len(parts) == 2 && parts[1] == "callback":
a.callback(w, r, parts[0])
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (a *API) me(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
user, err := a.auth.Me(r.Context(), c.Value)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
writeJSON(w, http.StatusOK, userDTO{ID: user.ID, Email: user.Email, Name: user.Name})
}
func (a *API) beginLogin(w http.ResponseWriter, r *http.Request, provider string) {
state := randomState()
url, err := a.auth.AuthURL(provider, state)
if err != nil {
writeError(w, err)
return
}
// Double-submit: stash state in a short-lived cookie, compare on callback.
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state, Path: "/", HttpOnly: true,
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(10 * time.Minute),
})
http.Redirect(w, r, url, http.StatusFound)
}
func (a *API) callback(w http.ResponseWriter, r *http.Request, provider string) {
cookie, err := r.Cookie(stateCookie)
if err != nil || cookie.Value == "" || cookie.Value != r.URL.Query().Get("state") {
writeError(w, domain.ErrUnauthorized)
return
}
a.clearCookie(w, stateCookie)
code := r.URL.Query().Get("code")
if code == "" {
writeError(w, domain.ErrUnauthorized)
return
}
_, token, err := a.auth.Complete(r.Context(), provider, code)
if err != nil {
writeError(w, err)
return
}
a.setSession(w, token)
http.Redirect(w, r, a.cfg.PostLoginRedirect, http.StatusFound)
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"time"
"github.com/snip/backend/internal/domain"
)
// linkDTO is the JSON shape returned to the SPA. It intentionally matches the
// frontend's `ShortLink` type so the mock API is a drop-in swap. PinHash is
// never included.
type linkDTO struct {
ID string `json:"id"`
Code string `json:"code"`
LongURL string `json:"longUrl"`
Mode string `json:"mode"`
HasPin bool `json:"hasPin"`
CreatedAt string `json:"createdAt"`
TotalClicks int64 `json:"totalClicks"`
Last7Days []domain.DayCount `json:"last7Days"`
Owned bool `json:"owned"`
}
func toDTO(l *domain.Link) linkDTO {
week := l.Last7Days
if week == nil {
week = []domain.DayCount{}
}
return linkDTO{
ID: l.ID,
Code: l.Code,
LongURL: l.LongURL,
Mode: string(l.Mode),
HasPin: l.HasPin,
CreatedAt: l.CreatedAt.Format(time.RFC3339),
TotalClicks: l.TotalClicks,
Last7Days: week,
Owned: l.OwnerID != "",
}
}
// listResponse is the paginated link listing returned to the dashboard.
type listResponse struct {
Items []linkDTO `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type createReq struct {
LongURL string `json:"longUrl"`
Mode string `json:"mode"`
CustomAlias string `json:"customAlias"`
Pin string `json:"pin"`
}
type updateReq struct {
LongURL string `json:"longUrl"`
}
type pinReq struct {
Pin string `json:"pin"`
}
type userDTO struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/service"
)
// /api/v1/links — POST create (auth optional), GET list (auth required)
func (a *API) linksCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
a.createLink(w, r)
case http.MethodGet:
a.listLinks(w, r)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (a *API) createLink(w http.ResponseWriter, r *http.Request) {
var req createReq
if err := decode(r, &req); err != nil {
writeError(w, domain.ErrInvalidURL)
return
}
mode := domain.Mode(req.Mode)
if mode != domain.ModeRandom && mode != domain.ModeMemorable && mode != domain.ModeCustom {
mode = domain.ModeRandom
}
link, err := a.links.Create(r.Context(), service.CreateInput{
LongURL: req.LongURL,
Mode: mode,
CustomAlias: req.CustomAlias,
Pin: req.Pin,
}, a.userID(r))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, toDTO(link))
}
func (a *API) listLinks(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
result, err := a.links.List(r.Context(), a.userID(r), q.Get("q"), page, pageSize)
if err != nil {
writeError(w, err)
return
}
items := make([]linkDTO, 0, len(result.Items))
for i := range result.Items {
items = append(items, toDTO(&result.Items[i]))
}
writeJSON(w, http.StatusOK, listResponse{
Items: items,
Total: result.Total,
Page: result.Page,
PageSize: result.PageSize,
})
}
func (a *API) linkStats(w http.ResponseWriter, r *http.Request) {
stats, err := a.links.Stats(r.Context(), a.userID(r))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, stats)
}
// /api/v1/links/{id} — PATCH, DELETE
// /api/v1/links/{id}/pin — PUT, DELETE
func (a *API) linkItem(w http.ResponseWriter, r *http.Request) {
rest := pathTail(r.URL.Path, "/api/v1/links/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
id := parts[0]
if id == "" {
w.WriteHeader(http.StatusNotFound)
return
}
owner := a.userID(r)
// /{id}/pin
if len(parts) == 2 && parts[1] == "pin" {
a.managePin(w, r, id, owner)
return
}
if len(parts) != 1 {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case http.MethodPatch:
var req updateReq
if err := decode(r, &req); err != nil {
writeError(w, domain.ErrInvalidURL)
return
}
link, err := a.links.UpdateDestination(r.Context(), id, owner, req.LongURL)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, toDTO(link))
case http.MethodDelete:
if err := a.links.Delete(r.Context(), id, owner); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (a *API) managePin(w http.ResponseWriter, r *http.Request, id, owner string) {
switch r.Method {
case http.MethodPut:
var req pinReq
if err := decode(r, &req); err != nil {
writeError(w, domain.ErrInvalidPin)
return
}
link, err := a.links.SetPin(r.Context(), id, owner, req.Pin)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, toDTO(link))
case http.MethodDelete:
link, err := a.links.SetPin(r.Context(), id, owner, "")
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, toDTO(link))
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
+152
View File
@@ -0,0 +1,152 @@
// Package api is the inbound HTTP adapter for the /api/v1 surface.
package api
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
"github.com/snip/backend/internal/service"
)
const (
sessionCookie = "snip_session"
stateCookie = "snip_oauth_state"
)
// API wires the link and auth use cases to HTTP.
type API struct {
links *service.LinkService
auth *service.AuthService
sessions port.SessionManager
cfg config.Config
}
func New(links *service.LinkService, auth *service.AuthService, sessions port.SessionManager, cfg config.Config) *API {
return &API{links: links, auth: auth, sessions: sessions, cfg: cfg}
}
// Handler returns the routed, CORS-wrapped /api/v1 handler.
func (a *API) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/healthz", a.health)
mux.HandleFunc("/api/v1/config", a.config)
mux.HandleFunc("/api/v1/links/stats", a.linkStats) // exact: wins over /links/
mux.HandleFunc("/api/v1/links", a.linksCollection)
mux.HandleFunc("/api/v1/links/", a.linkItem)
mux.HandleFunc("/api/v1/auth/", a.authRoutes)
return cors(mux)
}
func (a *API) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// config exposes client-relevant, non-secret settings (e.g. the short domain),
// so the frontend doesn't hardcode them.
func (a *API) config(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"shortDomain": a.cfg.ShortDomain})
}
// --- helpers ---
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, domain.ErrUnauthorized):
status = http.StatusUnauthorized
case errors.Is(err, domain.ErrForbidden):
status = http.StatusForbidden
case errors.Is(err, domain.ErrNotFound):
status = http.StatusNotFound
case errors.Is(err, domain.ErrCodeTaken), errors.Is(err, domain.ErrReserved):
status = http.StatusConflict
case errors.Is(err, domain.ErrInvalidURL), errors.Is(err, domain.ErrInvalidAlias),
errors.Is(err, domain.ErrInvalidPin), errors.Is(err, domain.ErrPinInvalid),
errors.Is(err, domain.ErrPinRequired):
status = http.StatusBadRequest
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}
// userID extracts and verifies the caller's session, or "" if anonymous.
func (a *API) userID(r *http.Request) string {
c, err := r.Cookie(sessionCookie)
if err != nil {
return ""
}
id, err := a.sessions.Verify(c.Value)
if err != nil {
return ""
}
return id
}
func (a *API) setSession(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: a.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
}
func (a *API) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", HttpOnly: true,
Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
func randomState() string {
b := make([]byte, 24)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func decode(r *http.Request, v any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
// cors reflects the request Origin and allows credentials (cookies). In
// production the SPA and API are same-origin behind a gateway; this keeps local
// dev (vite :5173 → api :8080) working.
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// pathTail returns the part of the request path after a prefix.
func pathTail(path, prefix string) string {
return strings.TrimPrefix(path, prefix)
}
@@ -0,0 +1,57 @@
package frontend_test
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/snip/backend/internal/httpx/frontend"
)
func tempDist(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><div id=root>SPA</div>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "assets", "app.js"), []byte("console.log('hi')"), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
func TestServesSPARoutes(t *testing.T) {
h := frontend.New(tempDist(t)).Handler()
for _, route := range []string{"/", "/login", "/dashboard"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, route, nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SPA") {
t.Fatalf("%s should render index, got %d", route, w.Code)
}
}
}
func TestServesAssets(t *testing.T) {
h := frontend.New(tempDist(t)).Handler()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "console.log") {
t.Fatalf("asset should be served, got %d", w.Code)
}
}
func TestUnknownPathIs404(t *testing.T) {
h := frontend.New(tempDist(t)).Handler()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/some/short-code", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("unknown path: want 404 (redirect cmd owns it), got %d", w.Code)
}
}
+59
View File
@@ -0,0 +1,59 @@
// Package frontend serves the built SPA. It owns exactly the app routes
// (/, /login, /dashboard) plus static assets; everything else is 404 here and
// handled by the redirect command in production.
package frontend
import (
"net/http"
"os"
"path"
"path/filepath"
)
type Server struct {
dist string
fs http.Handler
spa map[string]bool
indexAbs string
}
func New(dist string) *Server {
return &Server{
dist: dist,
fs: http.FileServer(http.Dir(dist)),
spa: map[string]bool{"/": true, "/login": true, "/dashboard": true},
indexAbs: filepath.Join(dist, "index.html"),
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/", s.serve)
return mux
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
clean := path.Clean(r.URL.Path)
// App routes always render the SPA shell.
if s.spa[clean] {
http.ServeFile(w, r, s.indexAbs)
return
}
// Real static file (assets, favicon, …)?
if clean != "/" {
full := filepath.Join(s.dist, filepath.FromSlash(clean))
if st, err := os.Stat(full); err == nil && !st.IsDir() {
s.fs.ServeHTTP(w, r)
return
}
}
// Unknown path: not this command's concern.
http.NotFound(w, r)
}
@@ -0,0 +1,24 @@
package redirect
import "html"
// notFoundHTML is a tiny, self-contained 404 in the snip style.
func notFoundHTML(shortHost, homeURL string) string {
h := html.EscapeString(shortHost)
href := html.EscapeString(homeURL)
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Link not found</title><style>
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e}
@media(prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020}}
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--ink);
font-family:ui-sans-serif,system-ui,sans-serif;text-align:center;padding:24px}
.card{background:var(--surface);border:1.5px solid var(--line);border-radius:24px;padding:36px;max-width:360px}
h1{font-size:54px;margin:0;letter-spacing:-.03em}
p{color:var(--muted);margin:8px 0 20px}
a{display:inline-block;background:var(--accent);color:#16170f;text-decoration:none;font-weight:600;
padding:12px 20px;border-radius:14px}
</style></head><body><div class="card"><h1>404</h1>
<p>This short link doesn't exist or was removed.</p>
<a href="` + href + `">Go to ` + h + `</a></div></body></html>`
}
+108
View File
@@ -0,0 +1,108 @@
package redirect
import (
"html/template"
"net/http"
)
// pinData feeds the enter-PIN template.
type pinData struct {
Code string
ShortHost string
ActionPath string
HasError bool
}
// The page is fully self-contained (no external CSS/JS/fonts) so it paints in a
// single round trip — the redirect path must stay fast. It mirrors the snip
// look: warm paper + ink, electric-lime accent, with a dark-mode variant.
var pinTmpl = template.Must(template.New("pin").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#16170f">
<title>Enter PIN · {{.ShortHost}}/{{.Code}}</title>
<style>
:root{--bg:#f4f2ea;--surface:#fffdf7;--ink:#16170f;--muted:#6b6c5e;--line:#e2decf;--accent:#c6f24e;--accent-ink:#16170f}
@media (prefers-color-scheme:dark){:root{--bg:#0e100a;--surface:#181b11;--ink:#f1efe3;--muted:#9b9d8a;--line:#2c3020;--accent:#c6f24e}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
background:var(--bg);color:var(--ink);
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
background-image:radial-gradient(60% 50% at 12% 8%,rgba(198,242,78,.30),transparent 60%),radial-gradient(60% 50% at 90% 90%,rgba(198,242,78,.22),transparent 60%)}
.card{width:100%;max-width:380px;background:var(--surface);border:1.5px solid var(--line);
border-radius:24px;padding:28px;box-shadow:0 30px 80px -30px rgba(0,0,0,.45);
animation:pop .45s cubic-bezier(.2,.9,.25,1.2)}
@keyframes pop{from{opacity:0;transform:translateY(16px) scale(.96)}to{opacity:1;transform:none}}
.lock{width:48px;height:48px;border-radius:16px;background:var(--ink);display:grid;place-items:center;margin-bottom:18px}
.lock svg{width:24px;height:24px}
h1{font-size:24px;margin:0 0 6px;letter-spacing:-.02em}
p{margin:0 0 20px;color:var(--muted);font-size:14px;line-height:1.5}
p b{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.pins{display:flex;gap:8px;margin-bottom:14px}
.pins input{flex:1;width:100%;height:54px;text-align:center;font-size:22px;font-weight:700;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
background:var(--bg);border:1.5px solid var(--line);border-radius:14px;outline:none;transition:transform .12s,border-color .12s}
.pins input:focus{transform:translateY(-2px) scale(1.05);border-color:var(--accent)}
.err{color:#ef4444;font-size:13px;font-weight:600;margin:0 0 14px;min-height:18px}
button{width:100%;height:52px;border:0;border-radius:16px;background:var(--accent);color:var(--accent-ink);
font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 8px 24px -8px rgba(198,242,78,.6);transition:transform .12s}
button:active{transform:scale(.96)}
.foot{margin-top:16px;text-align:center;font-size:12px;color:var(--muted)}
.foot b{color:var(--ink)}
</style>
</head>
<body>
<div class="card">
<div class="lock"><svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="11" rx="3" fill="#c6f24e"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="#c6f24e" stroke-width="2.4" fill="none"/><circle cx="12" cy="15.5" r="1.7" fill="#16170f"/></svg></div>
<h1>This link is protected</h1>
<p>Enter the 6-digit PIN to continue to <b>{{.ShortHost}}/{{.Code}}</b>.</p>
<form method="post" action="{{.ActionPath}}" id="f" autocomplete="off">
<div class="pins" id="pins">
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 1" required>
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 2" required>
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 3" required>
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 4" required>
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 5" required>
<input inputmode="numeric" maxlength="1" aria-label="PIN digit 6" required>
</div>
<p class="err">{{if .HasError}}That PIN didn't match. Try again.{{end}}</p>
<input type="hidden" name="pin" id="pin">
<button type="submit">Unlock &rarr;</button>
</form>
<div class="foot">Secured by <b>{{.ShortHost}}</b></div>
</div>
<script>
(function(){
var boxes=[].slice.call(document.querySelectorAll('#pins input')),hidden=document.getElementById('pin'),f=document.getElementById('f');
function sync(){hidden.value=boxes.map(function(b){return b.value}).join('')}
boxes.forEach(function(b,i){
b.addEventListener('input',function(){
b.value=b.value.replace(/\D/g,'').slice(0,1);
if(b.value&&i<boxes.length-1)boxes[i+1].focus();
sync();
if(hidden.value.length===6)f.submit();
});
b.addEventListener('keydown',function(e){if(e.key==='Backspace'&&!b.value&&i>0)boxes[i-1].focus()});
b.addEventListener('paste',function(e){
var d=(e.clipboardData.getData('text')||'').replace(/\D/g,'').slice(0,6);
if(!d)return;e.preventDefault();
d.split('').forEach(function(c,j){if(boxes[j])boxes[j].value=c});
boxes[Math.min(d.length,5)].focus();sync();if(d.length===6)f.submit();
});
});
if(boxes[0])boxes[0].focus();
})();
</script>
</body>
</html>`))
// renderPinPage writes the enter-PIN page. status is 200 on first view, 401 on
// a wrong-PIN retry.
func renderPinPage(w http.ResponseWriter, status int, d pinData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = pinTmpl.Execute(w, d)
}
@@ -0,0 +1,99 @@
package redirect_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/adapter/security"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/httpx/redirect"
"github.com/snip/backend/internal/service"
)
func setup(t *testing.T) (*service.LinkService, http.Handler) {
t.Helper()
repo := memory.NewLinkRepo()
cache := memory.NewCache()
hasher := security.NewBcryptHasher()
links := service.NewLinkService(repo, cache, hasher)
redir := service.NewRedirectService(repo, cache, hasher, service.NoopRecorder{})
srv := redirect.New(redir, "snip.to", "https://snip.to")
return links, srv.Handler()
}
func TestRedirectFound(t *testing.T) {
links, h := setup(t)
l, _ := links.Create(context.Background(), service.CreateInput{LongURL: "acme.com/go", Mode: domain.ModeRandom}, "")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+l.Code, nil))
if w.Code != http.StatusFound {
t.Fatalf("want 302, got %d", w.Code)
}
if got := w.Header().Get("Location"); got != "https://acme.com/go" {
t.Fatalf("bad location: %s", got)
}
}
func TestRedirectNotFound(t *testing.T) {
_, h := setup(t)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ghost", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", w.Code)
}
}
func TestApexRedirectsHome(t *testing.T) {
_, h := setup(t)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
if w.Code != http.StatusFound || w.Header().Get("Location") != "https://snip.to" {
t.Fatalf("apex should redirect home, got %d %s", w.Code, w.Header().Get("Location"))
}
}
func TestPinPageAndUnlock(t *testing.T) {
links, h := setup(t)
l, _ := links.Create(context.Background(), service.CreateInput{LongURL: "acme.com/secret", Mode: domain.ModeRandom, Pin: "424242"}, "owner")
// GET shows the enter-PIN page, not a redirect.
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/"+l.Code, nil))
if w.Code != http.StatusOK {
t.Fatalf("pin page: want 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "protected") || !strings.Contains(body, l.Code) {
t.Fatal("pin page missing expected content")
}
if strings.Contains(body, "acme.com/secret") {
t.Fatal("pin page must not leak the destination")
}
// Wrong pin → 401 + error page.
w = httptest.NewRecorder()
h.ServeHTTP(w, postForm("/"+l.Code, "pin", "000000"))
if w.Code != http.StatusUnauthorized {
t.Fatalf("wrong pin: want 401, got %d", w.Code)
}
// Correct pin → 302 to destination.
w = httptest.NewRecorder()
h.ServeHTTP(w, postForm("/"+l.Code, "pin", "424242"))
if w.Code != http.StatusFound || w.Header().Get("Location") != "https://acme.com/secret" {
t.Fatalf("unlock: want 302 to target, got %d %s", w.Code, w.Header().Get("Location"))
}
}
func postForm(path, key, val string) *http.Request {
form := url.Values{key: {val}}
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
+92
View File
@@ -0,0 +1,92 @@
// Package redirect is the inbound adapter for the high-traffic redirect path.
// It resolves a code to a destination (cache-first) and renders the enter-PIN
// page for protected links.
package redirect
import (
"errors"
"net/http"
"strings"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/service"
)
type Server struct {
svc *service.RedirectService
shortHost string
homeURL string
}
func New(svc *service.RedirectService, shortHost, homeURL string) *Server {
return &Server{svc: svc, shortHost: shortHost, homeURL: homeURL}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/", s.handle)
return mux
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
code := strings.Trim(r.URL.Path, "/")
if code == "" {
http.Redirect(w, r, s.homeURL, http.StatusFound)
return
}
if strings.Contains(code, "/") {
s.notFound(w)
return
}
switch r.Method {
case http.MethodGet:
out, err := s.svc.Resolve(r.Context(), code)
if errors.Is(err, domain.ErrNotFound) {
s.notFound(w)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if out.RequiresPin {
renderPinPage(w, http.StatusOK, pinData{Code: code, ShortHost: s.shortHost, ActionPath: "/" + code})
return
}
redirectOut(w, r, out.LongURL)
case http.MethodPost:
_ = r.ParseForm()
long, err := s.svc.VerifyPin(r.Context(), code, r.FormValue("pin"))
switch {
case errors.Is(err, domain.ErrNotFound):
s.notFound(w)
case errors.Is(err, domain.ErrPinInvalid):
renderPinPage(w, http.StatusUnauthorized, pinData{Code: code, ShortHost: s.shortHost, ActionPath: "/" + code, HasError: true})
case err != nil:
http.Error(w, "internal error", http.StatusInternalServerError)
default:
redirectOut(w, r, long)
}
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func redirectOut(w http.ResponseWriter, r *http.Request, target string) {
// Don't let intermediaries cache the bounce.
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, target, http.StatusFound)
}
func (s *Server) notFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(notFoundHTML(s.shortHost, s.homeURL)))
}
+85
View File
@@ -0,0 +1,85 @@
// Package port declares the hexagon's boundaries. The core (service) depends
// only on these interfaces; adapters (postgres, redis, oidc, …) implement them.
package port
import (
"context"
"time"
"github.com/snip/backend/internal/domain"
)
// LinkRepository is the source of truth (Postgres in prod, in-memory in tests).
type LinkRepository interface {
Create(ctx context.Context, l *domain.Link) error
GetByID(ctx context.Context, id string) (*domain.Link, error)
GetByCode(ctx context.Context, code string) (*domain.Link, error)
// ListByOwner returns one page of the owner's links matching the (optional)
// query, newest first, plus the total count for that filter — all done in
// the database so the API never loads every row.
ListByOwner(ctx context.Context, ownerID, query string, limit, offset int) (items []domain.Link, total int, err error)
// StatsByOwner returns the aggregate dashboard tiles for an owner.
StatsByOwner(ctx context.Context, ownerID string) (domain.OwnerStats, error)
Update(ctx context.Context, l *domain.Link) error
Delete(ctx context.Context, id, ownerID string) error
ExistsCode(ctx context.Context, code string) (bool, error)
// NextSequence returns a monotonically increasing int used to mint the
// shortest-possible unique base62 code for ModeRandom.
NextSequence(ctx context.Context) (int64, error)
// RecordClicks adds n clicks for a code on a given day (batched, off the
// hot path) and bumps the running total.
RecordClicks(ctx context.Context, code string, day time.Time, n int64) error
}
// Resolution is the tiny payload the redirect hot path needs. Cached in Redis
// so the common case never touches Postgres.
type Resolution struct {
LongURL string
HasPin bool
PinHash string
Found bool // false = negatively cached (known-missing code)
}
// LinkCache fronts the repository for read-heavy redirect traffic.
type LinkCache interface {
GetResolution(ctx context.Context, code string) (res Resolution, hit bool, err error)
SetResolution(ctx context.Context, code string, r Resolution, ttl time.Duration) error
Invalidate(ctx context.Context, code string) error
}
// UserRepository persists authenticated accounts.
type UserRepository interface {
Upsert(ctx context.Context, u *domain.User) (*domain.User, error)
GetByID(ctx context.Context, id string) (*domain.User, error)
}
// Identity is the normalized result of an OAuth/OIDC exchange.
type Identity struct {
Subject string
Email string
Name string
}
// IdentityProvider abstracts a login method (Google OAuth, generic OIDC).
type IdentityProvider interface {
Name() string
AuthURL(state string) string
Exchange(ctx context.Context, code string) (*Identity, error)
}
// SessionManager mints and verifies stateless session tokens.
type SessionManager interface {
Issue(userID string, ttl time.Duration) (string, error)
Verify(token string) (userID string, err error)
}
// PasswordHasher hashes/compares PINs (bcrypt in prod).
type PasswordHasher interface {
Hash(plain string) (string, error)
Compare(hash, plain string) bool
}
// ClickRecorder accepts clicks off the hot path; implementations batch them.
type ClickRecorder interface {
Record(code string)
}
+120
View File
@@ -0,0 +1,120 @@
package service
import (
"context"
"sync"
"time"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
const sessionTTL = 30 * 24 * time.Hour
// AuthService coordinates the OAuth/OIDC login dance and sessions. It supports
// any number of identity providers (Google, generic OIDC) keyed by name.
// providers may be nil on construction and injected later via SetProviders.
type AuthService struct {
mu sync.RWMutex
providers map[string]port.IdentityProvider
users port.UserRepository
sessions port.SessionManager
}
func NewAuthService(providers []port.IdentityProvider, users port.UserRepository, sessions port.SessionManager) *AuthService {
s := &AuthService{users: users, sessions: sessions}
s.setProviders(providers)
return s
}
// SetProviders replaces the provider set; safe to call from a background goroutine.
func (s *AuthService) SetProviders(providers []port.IdentityProvider) {
s.mu.Lock()
defer s.mu.Unlock()
s.setProviders(providers)
}
func (s *AuthService) setProviders(providers []port.IdentityProvider) {
m := make(map[string]port.IdentityProvider, len(providers))
for _, p := range providers {
if p != nil {
m[p.Name()] = p
}
}
s.providers = m
}
// Providers lists configured provider names (e.g. "google", "oidc").
func (s *AuthService) Providers() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.providers))
for name := range s.providers {
out = append(out, name)
}
return out
}
// AuthURL returns the provider's authorization URL for the given state.
func (s *AuthService) AuthURL(provider, state string) (string, error) {
s.mu.RLock()
p, ok := s.providers[provider]
s.mu.RUnlock()
if !ok {
return "", domain.ErrNotFound
}
return p.AuthURL(state), nil
}
// Complete exchanges an auth code, upserts the user and issues a session token.
func (s *AuthService) Complete(ctx context.Context, provider, code string) (*domain.User, string, error) {
s.mu.RLock()
p, ok := s.providers[provider]
s.mu.RUnlock()
if !ok {
return nil, "", domain.ErrNotFound
}
id, err := p.Exchange(ctx, code)
if err != nil {
return nil, "", err
}
user, err := s.users.Upsert(ctx, &domain.User{
Email: id.Email,
Name: displayName(id),
Provider: provider,
Subject: id.Subject,
})
if err != nil {
return nil, "", err
}
token, err := s.sessions.Issue(user.ID, sessionTTL)
if err != nil {
return nil, "", err
}
return user, token, nil
}
// Me resolves a session token back to a user.
func (s *AuthService) Me(ctx context.Context, token string) (*domain.User, error) {
userID, err := s.sessions.Verify(token)
if err != nil {
return nil, domain.ErrUnauthorized
}
return s.users.GetByID(ctx, userID)
}
func displayName(id *port.Identity) string {
if id.Name != "" {
return id.Name
}
if id.Email != "" {
return id.Email
}
return "there"
}
// NoopRecorder discards clicks; handy for tests and the api command (which
// doesn't serve redirects).
type NoopRecorder struct{}
func (NoopRecorder) Record(string) {}
+95
View File
@@ -0,0 +1,95 @@
package service
import (
"context"
"sync"
"time"
"github.com/snip/backend/internal/port"
)
// AsyncClickRecorder coalesces clicks in memory and flushes them to the
// repository in batches. This keeps the redirect path free of synchronous DB
// writes and collapses bursts (e.g. 1000 clicks/sec on one code) into a single
// periodic UPDATE.
type AsyncClickRecorder struct {
repo port.LinkRepository
interval time.Duration
mu sync.Mutex
pending map[string]int64
stop chan struct{}
done chan struct{}
}
func NewAsyncClickRecorder(repo port.LinkRepository, interval time.Duration) *AsyncClickRecorder {
if interval <= 0 {
interval = 10 * time.Second
}
return &AsyncClickRecorder{
repo: repo,
interval: interval,
pending: make(map[string]int64),
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Record is non-blocking: it just bumps an in-memory counter.
func (r *AsyncClickRecorder) Record(code string) {
r.mu.Lock()
r.pending[code]++
r.mu.Unlock()
}
// Start runs the flush loop until ctx is cancelled or Stop is called.
func (r *AsyncClickRecorder) Start(ctx context.Context) {
go func() {
defer close(r.done)
t := time.NewTicker(r.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
r.flush(context.Background())
return
case <-r.stop:
r.flush(context.Background())
return
case <-t.C:
r.flush(ctx)
}
}
}()
}
// Stop flushes and halts the loop.
func (r *AsyncClickRecorder) Stop() {
select {
case <-r.stop:
default:
close(r.stop)
}
<-r.done
}
func (r *AsyncClickRecorder) flush(ctx context.Context) {
r.mu.Lock()
batch := r.pending
r.pending = make(map[string]int64)
r.mu.Unlock()
if len(batch) == 0 {
return
}
day := time.Now().UTC().Truncate(24 * time.Hour)
for code, n := range batch {
if err := r.repo.RecordClicks(ctx, code, day, n); err != nil {
// Re-queue on failure so clicks aren't lost.
r.mu.Lock()
r.pending[code] += n
r.mu.Unlock()
}
}
}
+255
View File
@@ -0,0 +1,255 @@
package service
import (
"context"
"errors"
"strings"
"time"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
// cacheTTL is how long a resolution stays hot in the cache.
const cacheTTL = 24 * time.Hour
// CreateInput is the use-case input for minting a link.
type CreateInput struct {
LongURL string
Mode domain.Mode
CustomAlias string
Pin string
}
// LinkService is the application core for owning links.
type LinkService struct {
repo port.LinkRepository
cache port.LinkCache
hasher port.PasswordHasher
now func() time.Time
}
func NewLinkService(repo port.LinkRepository, cache port.LinkCache, hasher port.PasswordHasher) *LinkService {
return &LinkService{repo: repo, cache: cache, hasher: hasher, now: time.Now}
}
// Create resolves a code by mode, persists the link and warms the cache.
func (s *LinkService) Create(ctx context.Context, in CreateInput, ownerID string) (*domain.Link, error) {
longURL := NormalizeURL(in.LongURL)
if !ValidURL(longURL) {
return nil, domain.ErrInvalidURL
}
code, err := s.resolveCode(ctx, in, ownerID)
if err != nil {
return nil, err
}
link := &domain.Link{
ID: newID(),
Code: code,
LongURL: longURL,
Mode: in.Mode,
OwnerID: ownerID,
CreatedAt: s.now().UTC(),
Last7Days: emptyWeek(s.now()),
}
// PIN is an authenticated-only feature.
if in.Pin != "" && ownerID != "" {
if !ValidPin(in.Pin) {
return nil, domain.ErrInvalidPin
}
hash, err := s.hasher.Hash(in.Pin)
if err != nil {
return nil, err
}
link.HasPin = true
link.PinHash = hash
}
if err := s.repo.Create(ctx, link); err != nil {
return nil, err
}
s.warm(ctx, link)
return link, nil
}
func (s *LinkService) resolveCode(ctx context.Context, in CreateInput, ownerID string) (string, error) {
switch in.Mode {
case domain.ModeCustom:
if ownerID == "" {
return "", domain.ErrUnauthorized
}
if err := ValidateAlias(in.CustomAlias); err != nil {
return "", err
}
exists, err := s.repo.ExistsCode(ctx, in.CustomAlias)
if err != nil {
return "", err
}
if exists {
return "", domain.ErrCodeTaken
}
return in.CustomAlias, nil
case domain.ModeMemorable:
for i := 0; i < 6; i++ {
code := MemorableSlug()
exists, err := s.repo.ExistsCode(ctx, code)
if err != nil {
return "", err
}
if !exists {
return code, nil
}
}
return "", domain.ErrCodeTaken
default: // ModeRandom
seq, err := s.repo.NextSequence(ctx)
if err != nil {
return "", err
}
return CodeFromSequence(seq), nil
}
}
// Page is one page of a link listing.
type Page struct {
Items []domain.Link
Total int
Page int
PageSize int
}
const (
defaultPageSize = 10
maxPageSize = 100
)
// List returns one page of the caller's links matching query (server-side
// pagination + search). PIN hashes are stripped by the HTTP layer.
func (s *LinkService) List(ctx context.Context, ownerID, query string, page, pageSize int) (Page, error) {
if ownerID == "" {
return Page{}, domain.ErrUnauthorized
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
if page <= 0 {
page = 1
}
items, total, err := s.repo.ListByOwner(ctx, ownerID, strings.TrimSpace(query), pageSize, (page-1)*pageSize)
if err != nil {
return Page{}, err
}
return Page{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// Stats returns the caller's aggregate dashboard tiles.
func (s *LinkService) Stats(ctx context.Context, ownerID string) (domain.OwnerStats, error) {
if ownerID == "" {
return domain.OwnerStats{}, domain.ErrUnauthorized
}
return s.repo.StatsByOwner(ctx, ownerID)
}
// UpdateDestination changes where an owned link points and invalidates cache.
func (s *LinkService) UpdateDestination(ctx context.Context, id, ownerID, rawURL string) (*domain.Link, error) {
link, err := s.ownedLink(ctx, id, ownerID)
if err != nil {
return nil, err
}
longURL := NormalizeURL(rawURL)
if !ValidURL(longURL) {
return nil, domain.ErrInvalidURL
}
link.LongURL = longURL
if err := s.repo.Update(ctx, link); err != nil {
return nil, err
}
s.warm(ctx, link)
return link, nil
}
// SetPin sets or replaces a PIN; pass empty to remove it.
func (s *LinkService) SetPin(ctx context.Context, id, ownerID, pin string) (*domain.Link, error) {
link, err := s.ownedLink(ctx, id, ownerID)
if err != nil {
return nil, err
}
if pin == "" {
link.HasPin = false
link.PinHash = ""
} else {
if !ValidPin(pin) {
return nil, domain.ErrInvalidPin
}
hash, err := s.hasher.Hash(pin)
if err != nil {
return nil, err
}
link.HasPin = true
link.PinHash = hash
}
if err := s.repo.Update(ctx, link); err != nil {
return nil, err
}
s.warm(ctx, link)
return link, nil
}
// Delete removes an owned link and evicts it from cache.
func (s *LinkService) Delete(ctx context.Context, id, ownerID string) error {
link, err := s.ownedLink(ctx, id, ownerID)
if err != nil {
return err
}
if err := s.repo.Delete(ctx, id, ownerID); err != nil {
return err
}
_ = s.cache.Invalidate(ctx, link.Code)
return nil
}
func (s *LinkService) ownedLink(ctx context.Context, id, ownerID string) (*domain.Link, error) {
if ownerID == "" {
return nil, domain.ErrUnauthorized
}
link, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if link.OwnerID != ownerID {
return nil, domain.ErrForbidden
}
return link, nil
}
func (s *LinkService) warm(ctx context.Context, l *domain.Link) {
_ = s.cache.SetResolution(ctx, l.Code, port.Resolution{
LongURL: l.LongURL,
HasPin: l.HasPin,
PinHash: l.PinHash,
Found: true,
}, cacheTTL)
}
// emptyWeek builds a zeroed 7-day window ending today.
func emptyWeek(now time.Time) []domain.DayCount {
out := make([]domain.DayCount, 0, 7)
for i := 6; i >= 0; i-- {
d := now.AddDate(0, 0, -i)
out = append(out, domain.DayCount{Date: d.Format("2006-01-02"), Count: 0})
}
return out
}
// IsConflict helps the HTTP layer pick a status code.
func IsConflict(err error) bool {
return errors.Is(err, domain.ErrCodeTaken) || errors.Is(err, domain.ErrReserved)
}
@@ -0,0 +1,158 @@
package service
import (
"context"
"errors"
"sync"
"testing"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/domain"
)
// --- test doubles ---
type fakeHasher struct{}
func (fakeHasher) Hash(p string) (string, error) { return "h:" + p, nil }
func (fakeHasher) Compare(hash, p string) bool { return hash == "h:"+p }
type countingRecorder struct {
mu sync.Mutex
n map[string]int
}
func newCountingRecorder() *countingRecorder { return &countingRecorder{n: map[string]int{}} }
func (r *countingRecorder) Record(code string) {
r.mu.Lock()
r.n[code]++
r.mu.Unlock()
}
func (r *countingRecorder) count(code string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.n[code]
}
func newLinkSvc() *LinkService {
return NewLinkService(memory.NewLinkRepo(), memory.NewCache(), fakeHasher{})
}
// --- tests ---
func TestCreateRandomAndMemorable(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
r, err := svc.Create(ctx, CreateInput{LongURL: "acme.com/a", Mode: domain.ModeRandom}, "")
if err != nil {
t.Fatal(err)
}
if r.Code == "" || r.LongURL != "https://acme.com/a" {
t.Fatalf("bad random link: %+v", r)
}
m, err := svc.Create(ctx, CreateInput{LongURL: "https://acme.com/b", Mode: domain.ModeMemorable}, "")
if err != nil {
t.Fatal(err)
}
if len(m.Code) < 5 {
t.Fatalf("memorable code too short: %s", m.Code)
}
}
func TestCustomRequiresAuthAndIsUnique(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, ""); !errors.Is(err, domain.ErrUnauthorized) {
t.Fatalf("anon custom should be unauthorized, got %v", err)
}
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, "u1"); err != nil {
t.Fatal(err)
}
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, "u1"); !errors.Is(err, domain.ErrCodeTaken) {
t.Fatalf("duplicate alias should conflict, got %v", err)
}
}
func TestCustomRejectsReservedPath(t *testing.T) {
svc := newLinkSvc()
if _, err := svc.Create(context.Background(), CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "dashboard"}, "u1"); !errors.Is(err, domain.ErrReserved) {
t.Fatalf("reserved alias should be rejected, got %v", err)
}
}
func TestInvalidURLRejected(t *testing.T) {
svc := newLinkSvc()
if _, err := svc.Create(context.Background(), CreateInput{LongURL: "not a url", Mode: domain.ModeRandom}, ""); !errors.Is(err, domain.ErrInvalidURL) {
t.Fatalf("expected ErrInvalidURL, got %v", err)
}
}
func TestPinLifecycleAndOwnership(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
link, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeRandom, Pin: "123456"}, "owner")
if err != nil {
t.Fatal(err)
}
if !link.HasPin {
t.Fatal("expected pin set")
}
// A different owner cannot touch it.
if _, err := svc.UpdateDestination(ctx, link.ID, "intruder", "https://evil.com"); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("expected forbidden, got %v", err)
}
// Owner removes the pin.
updated, err := svc.SetPin(ctx, link.ID, "owner", "")
if err != nil {
t.Fatal(err)
}
if updated.HasPin {
t.Fatal("pin should be removed")
}
if err := svc.Delete(ctx, link.ID, "owner"); err != nil {
t.Fatal(err)
}
if _, err := svc.List(ctx, "owner", "", 1, 10); err != nil {
t.Fatal(err)
}
}
func TestListPaginationAndSearch(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
for _, alias := range []string{"alpha", "beta", "gamma", "delta", "epsilon"} {
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com/" + alias, Mode: domain.ModeCustom, CustomAlias: alias}, "owner"); err != nil {
t.Fatal(err)
}
}
// page 1 of size 2 → 2 items, total 5
p, err := svc.List(ctx, "owner", "", 1, 2)
if err != nil {
t.Fatal(err)
}
if p.Total != 5 || len(p.Items) != 2 {
t.Fatalf("want total=5 items=2, got total=%d items=%d", p.Total, len(p.Items))
}
// page 3 → 1 item
if p, _ := svc.List(ctx, "owner", "", 3, 2); len(p.Items) != 1 {
t.Fatalf("page 3 should have 1 item, got %d", len(p.Items))
}
// search narrows
if p, _ := svc.List(ctx, "owner", "alpha", 1, 10); p.Total != 1 || p.Items[0].Code != "alpha" {
t.Fatalf("search 'alpha' should match 1, got total=%d", p.Total)
}
// stats reflect all links
s, _ := svc.Stats(ctx, "owner")
if s.TotalLinks != 5 {
t.Fatalf("stats total links want 5, got %d", s.TotalLinks)
}
}
@@ -0,0 +1,89 @@
package service
import (
"context"
"errors"
"time"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
// negativeTTL keeps unknown codes briefly cached so scanners can't hammer the DB.
const negativeTTL = 60 * time.Second
// RedirectService serves the latency-critical path. The common case (a known,
// pin-less code on a warm cache) never touches Postgres.
type RedirectService struct {
repo port.LinkRepository
cache port.LinkCache
hasher port.PasswordHasher
recorder port.ClickRecorder
}
func NewRedirectService(repo port.LinkRepository, cache port.LinkCache, hasher port.PasswordHasher, rec port.ClickRecorder) *RedirectService {
return &RedirectService{repo: repo, cache: cache, hasher: hasher, recorder: rec}
}
// Outcome is what the redirect handler should do.
type Outcome struct {
LongURL string
RequiresPin bool
}
// Resolve looks up a code (cache-first). If a PIN is required it returns
// RequiresPin without leaking the destination; the click is recorded only on an
// actual redirect.
func (s *RedirectService) Resolve(ctx context.Context, code string) (Outcome, error) {
res, err := s.resolution(ctx, code)
if err != nil {
return Outcome{}, err
}
if res.HasPin {
return Outcome{RequiresPin: true}, nil
}
s.recorder.Record(code)
return Outcome{LongURL: res.LongURL}, nil
}
// VerifyPin checks a PIN against the cached hash (no DB hit) and, on success,
// records the click and returns the destination.
func (s *RedirectService) VerifyPin(ctx context.Context, code, pin string) (string, error) {
res, err := s.resolution(ctx, code)
if err != nil {
return "", err
}
if !res.HasPin {
s.recorder.Record(code)
return res.LongURL, nil
}
if !s.hasher.Compare(res.PinHash, pin) {
return "", domain.ErrPinInvalid
}
s.recorder.Record(code)
return res.LongURL, nil
}
// resolution returns a found resolution or ErrNotFound, populating the cache
// (including a negative entry for misses).
func (s *RedirectService) resolution(ctx context.Context, code string) (port.Resolution, error) {
if res, hit, err := s.cache.GetResolution(ctx, code); err == nil && hit {
if !res.Found {
return port.Resolution{}, domain.ErrNotFound
}
return res, nil
}
link, err := s.repo.GetByCode(ctx, code)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
_ = s.cache.SetResolution(ctx, code, port.Resolution{Found: false}, negativeTTL)
return port.Resolution{}, domain.ErrNotFound
}
return port.Resolution{}, err
}
res := port.Resolution{LongURL: link.LongURL, HasPin: link.HasPin, PinHash: link.PinHash, Found: true}
_ = s.cache.SetResolution(ctx, code, res, cacheTTL)
return res, nil
}
@@ -0,0 +1,131 @@
package service
import (
"context"
"errors"
"testing"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/port"
)
// countingRepo wraps the memory repo to count GetByCode calls, proving the
// cache keeps the redirect path off the "DB".
type countingRepo struct {
*memory.LinkRepo
codeReads int
}
func (r *countingRepo) GetByCode(ctx context.Context, code string) (*domain.Link, error) {
r.codeReads++
return r.LinkRepo.GetByCode(ctx, code)
}
func TestRedirectCacheAvoidsRepeatedDBReads(t *testing.T) {
repo := &countingRepo{LinkRepo: memory.NewLinkRepo()}
cache := memory.NewCache()
rec := newCountingRecorder()
links := NewLinkService(repo, cache, fakeHasher{})
redir := NewRedirectService(repo, cache, fakeHasher{}, rec)
ctx := context.Background()
created, err := links.Create(ctx, CreateInput{LongURL: "acme.com/go", Mode: domain.ModeRandom}, "")
if err != nil {
t.Fatal(err)
}
// Create already warmed the cache, so resolving never reads the repo.
for i := 0; i < 5; i++ {
out, err := redir.Resolve(ctx, created.Code)
if err != nil {
t.Fatal(err)
}
if out.LongURL != "https://acme.com/go" {
t.Fatalf("wrong target: %s", out.LongURL)
}
}
if repo.codeReads != 0 {
t.Fatalf("expected 0 DB reads on warm cache, got %d", repo.codeReads)
}
if rec.count(created.Code) != 5 {
t.Fatalf("expected 5 clicks recorded, got %d", rec.count(created.Code))
}
}
func TestRedirectColdCacheReadsOnce(t *testing.T) {
repo := &countingRepo{LinkRepo: memory.NewLinkRepo()}
cache := memory.NewCache()
links := NewLinkService(repo, cache, fakeHasher{})
redir := NewRedirectService(repo, cache, fakeHasher{}, newCountingRecorder())
ctx := context.Background()
created, _ := links.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeRandom}, "")
_ = cache.Invalidate(ctx, created.Code) // simulate a cold cache
for i := 0; i < 3; i++ {
if _, err := redir.Resolve(ctx, created.Code); err != nil {
t.Fatal(err)
}
}
if repo.codeReads != 1 {
t.Fatalf("expected exactly 1 DB read (then cached), got %d", repo.codeReads)
}
}
func TestRedirectNegativeCache(t *testing.T) {
repo := &countingRepo{LinkRepo: memory.NewLinkRepo()}
cache := memory.NewCache()
redir := NewRedirectService(repo, cache, fakeHasher{}, newCountingRecorder())
ctx := context.Background()
for i := 0; i < 4; i++ {
if _, err := redir.Resolve(ctx, "ghost"); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("expected not found, got %v", err)
}
}
if repo.codeReads != 1 {
t.Fatalf("unknown code should hit DB once then negatively cache, got %d reads", repo.codeReads)
}
if res, hit, _ := cache.GetResolution(ctx, "ghost"); !hit || res.Found {
t.Fatal("expected a negative cache entry")
}
}
func TestRedirectPinFlow(t *testing.T) {
repo := memory.NewLinkRepo()
cache := memory.NewCache()
rec := newCountingRecorder()
links := NewLinkService(repo, cache, fakeHasher{})
redir := NewRedirectService(repo, cache, fakeHasher{}, rec)
ctx := context.Background()
link, _ := links.Create(ctx, CreateInput{LongURL: "acme.com/secret", Mode: domain.ModeRandom, Pin: "424242"}, "owner")
out, err := redir.Resolve(ctx, link.Code)
if err != nil {
t.Fatal(err)
}
if !out.RequiresPin || out.LongURL != "" {
t.Fatalf("pinned link must not leak target: %+v", out)
}
if rec.count(link.Code) != 0 {
t.Fatal("no click should be recorded before unlock")
}
if _, err := redir.VerifyPin(ctx, link.Code, "000000"); !errors.Is(err, domain.ErrPinInvalid) {
t.Fatalf("wrong pin should fail, got %v", err)
}
target, err := redir.VerifyPin(ctx, link.Code, "424242")
if err != nil {
t.Fatal(err)
}
if target != "https://acme.com/secret" {
t.Fatalf("unexpected target: %s", target)
}
if rec.count(link.Code) != 1 {
t.Fatalf("expected one click after unlock, got %d", rec.count(link.Code))
}
}
var _ port.LinkRepository = (*countingRepo)(nil)
+124
View File
@@ -0,0 +1,124 @@
package service
import (
"crypto/rand"
"math/big"
"regexp"
"strings"
"github.com/snip/backend/internal/domain"
)
const base62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// ReservedCodes can never be claimed as custom aliases, because these top-level
// paths are owned by the frontend / api commands.
var ReservedCodes = map[string]struct{}{
"": {},
"api": {},
"login": {},
"dashboard": {},
"assets": {},
"static": {},
"healthz": {},
"favicon.svg": {},
"favicon.ico": {},
"robots.txt": {},
"unlock": {},
}
// IsReserved reports whether a code collides with an app-owned path.
func IsReserved(code string) bool {
_, ok := ReservedCodes[strings.ToLower(code)]
return ok
}
func encodeBase62(n int64) string {
if n == 0 {
return string(base62[0])
}
var b strings.Builder
for n > 0 {
b.WriteByte(base62[n%62])
n /= 62
}
// reverse
s := []byte(b.String())
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
return string(s)
}
// scramble spreads sequential ids so codes don't look enumerable, while staying
// short. Reversible multiplicative hash over a 31-bit space.
func scramble(id int64) int64 {
const prime = 2654435761
const mod = 0x7fffffff
return ((id + 1) * prime) % mod
}
// CodeFromSequence turns a DB sequence value into the shortest unique base62
// code (no collision checks needed — the sequence guarantees uniqueness).
func CodeFromSequence(seq int64) string {
return encodeBase62(scramble(seq + 100000))
}
var aliasRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
// ValidateAlias checks a user-supplied custom code.
func ValidateAlias(alias string) error {
if !aliasRe.MatchString(alias) {
return domain.ErrInvalidAlias
}
if IsReserved(alias) {
return domain.ErrReserved
}
return nil
}
var urlRe = regexp.MustCompile(`(?i)^https?://[^\s.]+\.[^\s]{2,}$`)
// NormalizeURL ensures a scheme is present.
func NormalizeURL(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
low := strings.ToLower(s)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return s
}
return "https://" + s
}
// ValidURL validates a normalized destination.
func ValidURL(raw string) bool {
return urlRe.MatchString(raw)
}
var pinRe = regexp.MustCompile(`^\d{6}$`)
// ValidPin reports whether a string is exactly 6 digits.
func ValidPin(pin string) bool { return pinRe.MatchString(pin) }
func pick(arr []string) string {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(arr))))
return arr[n.Int64()]
}
// MemorableSlug builds an easy-to-say "adjective-noun-verb" code.
func MemorableSlug() string {
return pick(adjectives) + "-" + pick(nouns) + "-" + pick(verbs)
}
// newID mints an opaque link id.
func newID() string {
const n = 10
b := make([]byte, n)
for i := range b {
idx, _ := rand.Int(rand.Reader, big.NewInt(62))
b[i] = base62[idx.Int64()]
}
return "l_" + string(b)
}
@@ -0,0 +1,75 @@
package service
import "testing"
func TestCodeFromSequenceUniqueAndShort(t *testing.T) {
seen := map[string]bool{}
for i := int64(1); i <= 5000; i++ {
c := CodeFromSequence(i)
if c == "" {
t.Fatalf("empty code for seq %d", i)
}
if seen[c] {
t.Fatalf("collision at seq %d -> %s", i, c)
}
seen[c] = true
if len(c) > 6 {
t.Fatalf("code unexpectedly long: %s (%d chars)", c, len(c))
}
}
}
func TestValidateAlias(t *testing.T) {
cases := map[string]bool{ // alias -> valid?
"spring-launch": true,
"ab": false, // too short
"with space": false,
"good_one-2": true,
"api": false, // reserved
"dashboard": false, // reserved
"login": false, // reserved
}
for alias, valid := range cases {
err := ValidateAlias(alias)
if valid && err != nil {
t.Errorf("alias %q should be valid, got %v", alias, err)
}
if !valid && err == nil {
t.Errorf("alias %q should be invalid", alias)
}
}
}
func TestReservedBlocksAppPaths(t *testing.T) {
for _, p := range []string{"", "api", "login", "dashboard", "assets"} {
if !IsReserved(p) {
t.Errorf("%q must be reserved so it can't be claimed as a code", p)
}
}
if IsReserved("amber-otter-loop") {
t.Error("normal code should not be reserved")
}
}
func TestNormalizeAndValidateURL(t *testing.T) {
if got := NormalizeURL("acme.com/x"); got != "https://acme.com/x" {
t.Errorf("normalize added scheme wrong: %s", got)
}
if !ValidURL(NormalizeURL("acme.com/spring")) {
t.Error("expected valid url")
}
if ValidURL("not a url") {
t.Error("expected invalid url")
}
}
func TestValidPin(t *testing.T) {
if !ValidPin("123456") {
t.Error("123456 should be valid")
}
for _, bad := range []string{"12345", "1234567", "12a456", ""} {
if ValidPin(bad) {
t.Errorf("%q should be invalid", bad)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package service
// Curated pools for memorable codes, mirroring the frontend's set.
var (
adjectives = []string{
"amber", "brave", "calm", "clever", "cosmic", "crisp", "dawn", "eager",
"fizzy", "gentle", "happy", "honey", "ivory", "jolly", "keen", "lucky",
"mellow", "noble", "olive", "plush", "quartz", "rapid", "sunny", "swift",
"teal", "tidal", "vivid", "warm", "zesty", "zen",
}
nouns = []string{
"otter", "falcon", "maple", "comet", "pixel", "harbor", "meadow", "ember",
"willow", "lantern", "pebble", "cactus", "marble", "puffin", "ledger",
"cobra", "violet", "thistle", "acorn", "domino", "compass", "raven",
"saffron", "juniper", "lotus", "mango", "narwhal", "orchid", "pelican",
"quokka", "robin", "sparrow", "topaz", "umbra", "walrus", "yarrow", "zephyr",
}
verbs = []string{
"loop", "dash", "soar", "drift", "glide", "spark", "leap", "flow",
"zoom", "hop", "skip", "roam", "bounce", "swirl", "dive", "climb",
}
)
+143
View File
@@ -0,0 +1,143 @@
# Example compose: one image (built from ./Dockerfile) run as three services,
# fronted by a gateway that enforces the path rules:
# /api/* -> api
# /, /login, /dashboard, … -> frontend (static SPA + assets)
# everything else -> redirect
#
# Bring it up: docker compose up --build
# Then visit: http://localhost:8080
#
# Anonymous flows work out of the box. For login, set GOOGLE_* / OIDC_* below
# (and add the callback URLs http://localhost:8080/api/v1/auth/<provider>/callback
# in your provider console).
x-backend-env: &backend-env
STORE: postgres
DATABASE_URL: postgres://snip:snip@postgres:5432/snip?sslmode=disable
REDIS_ADDR: redis:6379
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-prod}
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8080}
SHORT_DOMAIN: ${SHORT_DOMAIN:-localhost:8080}
POST_LOGIN_REDIRECT: /dashboard
COOKIE_SECURE: "false"
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
# Generic OIDC points at the bundled mock provider (see mock-oidc service).
# The issuer host:port must be identical from the browser AND the backend, so
# we use the docker service name `mock-oidc:8090` for both. To log in from a
# real browser, add this line to your /etc/hosts: 127.0.0.1 mock-oidc
OIDC_ISSUER: ${OIDC_ISSUER:-http://mock-oidc:8090/default}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-snip-web}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-snip-secret}
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: snip
POSTGRES_PASSWORD: snip
POSTGRES_DB: snip
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U snip"]
interval: 3s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 10
migrate:
image: snip:latest # built by the `api` service above
command: ["migrate"] # runs schema DDL (idempotent) then exits 0
environment: *backend-env
depends_on:
postgres: { condition: service_healthy }
restart: "no"
api:
build: .
image: snip:latest
command: ["api"] # serves /api/v1/* on :8080
environment: *backend-env
depends_on:
migrate: { condition: service_completed_successfully }
redis: { condition: service_healthy }
mock-oidc: { condition: service_started }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/api/v1/healthz"]
interval: 5s
timeout: 3s
retries: 10
frontend:
image: snip:latest # built by the `api` service above
command: ["frontend"] # serves the SPA on :8081
environment: *backend-env
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/healthz"]
interval: 5s
timeout: 3s
retries: 10
redirect:
image: snip:latest # built by the `api` service above
command: ["redirect"] # serves short-code redirects on :8082
environment: *backend-env
depends_on:
migrate: { condition: service_completed_successfully }
redis: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8082/healthz"]
interval: 5s
timeout: 3s
retries: 10
# Mock OpenID Connect provider for local login. It accepts any client and
# auto-issues a token (no real account needed). The service name + port
# (mock-oidc:8090) is used as the issuer by BOTH the backend and the browser,
# which is why /etc/hosts needs `127.0.0.1 mock-oidc` for browser logins.
mock-oidc:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- "8090:8090"
environment:
SERVER_PORT: "8090"
# Non-interactive: issue a token immediately for a demo identity.
JSON_CONFIG: >-
{
"interactiveLogin": false,
"tokenCallbacks": [
{
"issuerId": "default",
"requestMappings": [
{
"requestParam": "grant_type",
"match": "authorization_code",
"claims": { "sub": "demo-user", "email": "[email protected]", "name": "Demo User" }
}
]
}
]
}
gateway:
image: caddy:2-alpine
ports:
- "8080:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
api: { condition: service_healthy }
frontend: { condition: service_healthy }
redirect: { condition: service_healthy }
volumes:
pgdata:
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#16170F" />
<title>snip — A short link service</title>
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
<link
href="https://api.fontshare.com/v2/css?f[]=clash-display@600,700&f[]=general-sans@400,500,600&f[]=space-mono@400,700&display=swap"
rel="stylesheet"
/>
<script>
// Apply theme before paint to avoid flash.
(function () {
try {
var t = localStorage.getItem("snip-theme");
if (t === "dark" || (!t && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
}
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2592
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "snip-shorturl",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"framer-motion": "^11.3.24",
"qrcode.react": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.1"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+4
View File
@@ -0,0 +1,4 @@
<svg width="1133" height="500" viewBox="0 0 1133 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M445.889 24.299C409.722 22.799 319.889 26.199 249.889 51.799C190.35 73.5733 100.788 77.1167 52.8889 77.6895M445.889 24.299C437.685 24.299 428.191 24.4678 417.889 24.854M445.889 24.299C446.722 24.2603 442.289 24.3171 417.889 24.854M1108.89 475.799C980.889 474.299 851.389 448.799 817.889 402.799C744.889 276.299 917.938 126.299 913.389 182.799C907.27 258.799 913.389 254.299 817.889 364.299C747.337 445.562 486.389 487.299 628.889 288.299C742.889 129.099 640.722 250.966 575.389 331.799C473.389 457.799 435.16 453.299 486.389 297.799C537.618 142.299 486.389 236.299 358.389 378.799C255.989 492.799 409.958 183.299 403.889 171.799C394.389 153.799 182.789 505.199 172.389 428.799C147.889 276.799 224.389 105.299 249.889 64.799C267.344 37.0758 356.818 27.1437 417.889 24.854M52.8889 77.6895C44.4732 77.7901 37.3435 77.799 31.8889 77.799C15.8889 77.8576 24.8889 85.799 52.8889 77.6895Z" stroke="white" stroke-width="47" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="437.389" cy="106.299" rx="16.5" ry="15.5" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="1132" height="499" viewBox="0 0 1132 499" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M445.401 23.7971C409.234 22.2971 319.401 25.6971 249.401 51.2971C189.861 73.0714 100.299 76.6148 52.4006 77.1876M445.401 23.7971C437.197 23.7971 427.703 23.9659 417.401 24.3522M445.401 23.7971C446.234 23.7584 441.801 23.8152 417.401 24.3522M1108.4 475.297C980.4 473.797 850.9 448.297 817.4 402.297C744.4 275.797 917.449 125.797 912.9 182.297C906.782 258.297 912.9 253.797 817.4 363.797C746.849 445.061 485.9 486.797 628.4 287.797C742.4 128.597 640.234 250.464 574.9 331.297C472.9 457.297 434.672 452.797 485.901 297.297C537.129 141.797 485.901 235.797 357.901 378.297C255.501 492.297 409.47 182.797 403.401 171.297C393.901 153.297 182.301 504.697 171.901 428.297C147.401 276.297 223.901 104.797 249.401 64.2971C266.856 36.5739 356.33 26.6418 417.401 24.3522M52.4006 77.1876C43.9848 77.2882 36.8552 77.2971 31.4005 77.2971C15.4005 77.3557 24.4006 85.2971 52.4006 77.1876Z" stroke="#203764" stroke-width="47" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="436.901" cy="105.797" rx="16.5" ry="15.5" fill="#203764"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+80
View File
@@ -0,0 +1,80 @@
import { AnimatePresence, motion } from "framer-motion";
import { Route, Routes, useLocation } from "react-router-dom";
import { Navbar } from "./components/Navbar";
import { Home } from "./pages/Home";
import { Login } from "./pages/Login";
import { Dashboard } from "./pages/Dashboard";
function Page({ children }: { children: React.ReactNode }) {
return (
<motion.main
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ type: "spring", stiffness: 320, damping: 30 }}
>
{children}
</motion.main>
);
}
export default function App() {
const location = useLocation();
return (
<div className="app-bg grain min-h-screen">
{/* drifting decorative blobs */}
<div
aria-hidden
className="animate-float pointer-events-none fixed left-[6%] top-[22%] -z-[1] h-40 w-40 rounded-full bg-accent opacity-[0.07] blur-3xl"
/>
<div
aria-hidden
className="animate-float pointer-events-none fixed bottom-[12%] right-[8%] -z-[1] h-56 w-56 rounded-full bg-accent opacity-[0.08] blur-3xl"
style={{ animationDelay: "2s" }}
/>
<div className="px-3 sm:px-5">
<Navbar />
</div>
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route
path="/"
element={
<Page>
<Home />
</Page>
}
/>
<Route
path="/login"
element={
<Page>
<Login />
</Page>
}
/>
<Route
path="/dashboard"
element={
<Page>
<Dashboard />
</Page>
}
/>
<Route
path="*"
element={
<Page>
<Home />
</Page>
}
/>
</Routes>
</AnimatePresence>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { motion } from "framer-motion";
import { useState } from "react";
import type { DayCount } from "../lib/types";
import { weekdayLabel } from "../lib/format";
interface Props {
data: DayCount[];
height?: number;
}
export function BarChart({ data, height = 92 }: Props) {
const [hover, setHover] = useState<number | null>(null);
const max = Math.max(1, ...data.map((d) => d.count));
return (
<div className="flex items-end gap-1.5" style={{ height }}>
{data.map((d, i) => {
const ratio = d.count / max;
const isPeak = d.count === max && max > 0;
const active = hover === i;
return (
<div
key={d.date}
className="group relative flex h-full flex-1 flex-col items-center justify-end gap-1.5"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
>
{/* tooltip */}
<motion.div
initial={false}
animate={{
opacity: active ? 1 : 0,
y: active ? 0 : 6,
scale: active ? 1 : 0.8,
}}
transition={{ type: "spring", stiffness: 520, damping: 22 }}
className="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded-lg bg-[var(--ink)] px-2 py-1 text-[11px] font-semibold text-[var(--bg)]"
>
{d.count} clicks
</motion.div>
<div className="flex w-full flex-1 items-end">
<motion.div
initial={{ height: 0 }}
animate={{ height: `${Math.max(ratio * 100, 4)}%` }}
transition={{
type: "spring",
stiffness: 260,
damping: 18,
delay: i * 0.05,
}}
className={`w-full rounded-md ${
isPeak || active
? "bg-accent"
: "bg-[var(--ring)] group-hover:bg-[var(--muted)]"
}`}
style={{ minHeight: 4 }}
/>
</div>
<span className="text-[10px] font-medium text-muted">
{weekdayLabel(d.date)}
</span>
</div>
);
})}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from "react";
import { api } from "../lib/api";
import type { ShortLink } from "../lib/types";
import { compactNumber, prettyHost, shortUrl } from "../lib/format";
import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink | null;
onClose: () => void;
onDeleted: (id: string, message: string) => void;
}
export function DeleteLinkModal({ link, onClose, onDeleted }: Props) {
const open = Boolean(link);
const [busy, setBusy] = useState(false);
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
async function confirm() {
if (!link) return;
setBusy(true);
try {
await api.deleteLink(link.id);
onDeleted(link.id, "Link deleted");
} catch {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Delete link">
<div className="-mt-2 mb-4 flex items-center gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-red-500/12 text-lg">
🗑
</span>
<div className="min-w-0">
<p className="truncate font-mono text-base font-bold text-ink">
{shortUrl(link.code)}
</p>
<p className="truncate text-[12px] text-muted">
{prettyHost(link.longUrl)}
</p>
</div>
</div>
<p className="text-sm leading-relaxed text-muted">
This permanently deletes the link. Anyone who opens{" "}
<span className="font-mono text-ink">{shortUrl(link.code)}</span> will
hit a dead end, and its{" "}
<span className="font-semibold text-ink">
{compactNumber(link.totalClicks)} clicks
</span>{" "}
of history go with it. This can't be undone.
</p>
<div className="mt-5 flex items-center gap-2">
<Button
onClick={confirm}
disabled={busy}
className="!bg-red-500 !text-white shadow-[0_8px_24px_-8px_rgba(239,68,68,0.6)]"
>
{busy ? "Deleting…" : "Delete link"}
</Button>
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
</div>
</Modal>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import type { ShortLink } from "../lib/types";
import { prettyHost, shortUrl } from "../lib/format";
import { isValidUrl, normalizeUrl } from "../lib/shortcode";
import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink | null;
onClose: () => void;
onSaved: (updated: ShortLink, message: string) => void;
}
export function EditLinkModal({ link, onClose, onSaved }: Props) {
const open = Boolean(link);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset the form whenever a different link opens the modal.
useEffect(() => {
setDraft(link?.longUrl ?? "");
setError(null);
setBusy(false);
}, [link?.id, link?.longUrl]);
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
const changed = normalizeUrl(draft) !== link.longUrl;
async function save() {
if (!link) return;
if (!isValidUrl(draft)) {
setError("That doesn't look like a valid URL.");
return;
}
setBusy(true);
setError(null);
try {
const updated = await api.updateLink(link.id, {
longUrl: normalizeUrl(draft),
});
onSaved(updated, "Link updated");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not save changes.");
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Edit link">
{/* Short code is permanent — shown read-only so existing shares keep working. */}
<div className="-mt-2 mb-4 flex items-center justify-between rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
Short link
</p>
<p className="truncate font-mono text-base font-bold text-ink">
{shortUrl(link.code)}
</p>
</div>
<span className="shrink-0 rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted">
can't change
</span>
</div>
<label className="mb-2 block text-sm font-semibold text-ink">
Destination URL
</label>
<div
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${
error ? "border-red-400/70" : "border-line"
}`}
>
<span className="text-base opacity-50">🔗</span>
<input
autoFocus
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => e.key === "Enter" && save()}
spellCheck={false}
className="h-12 w-full bg-transparent font-mono text-sm text-ink outline-none"
/>
</div>
<p className="mt-2 text-xs text-muted">
Where <span className="font-mono text-ink">{shortUrl(link.code)}</span>{" "}
sends visitors currently {prettyHost(link.longUrl)}.
</p>
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="mt-3 text-sm font-medium text-red-500"
>
{error}
</motion.p>
)}
</AnimatePresence>
<div className="mt-5 flex items-center gap-2">
<Button onClick={save} disabled={busy || !changed}>
{busy ? "Saving…" : "Save changes"}
</Button>
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
</div>
</Modal>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { motion } from "framer-motion";
import { Link } from "react-router-dom";
import { useTheme } from "../context/ThemeContext";
export function Logo({ onClick }: { onClick?: () => void }) {
const { theme } = useTheme();
return (
<Link
to="/"
onClick={onClick}
className="focusable group inline-flex items-center gap-2.5 rounded-xl"
>
<motion.span
whileHover={{ scale: 1.08 }}
transition={{ type: "spring", stiffness: 420, damping: 12 }}
className="flex items-center"
>
<img
src={theme === "dark" ? "/favicon.svg" : "/favicon_dark.svg"}
alt=""
className="h-7 w-auto"
/>
</motion.span>
<span className="font-display text-[22px] font-bold tracking-tight text-ink">
snip
<span className="text-accent">.</span>
</span>
</Link>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { motion } from "framer-motion";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { Logo } from "./Logo";
import { ThemeToggle } from "./ThemeToggle";
import { Button } from "./ui/Button";
export function Navbar() {
const { user, logout } = useAuth();
const { pathname } = useLocation();
const navigate = useNavigate();
return (
<motion.header
initial={{ y: -64, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 320, damping: 28, delay: 0.05 }}
className="sticky top-0 z-50"
>
<div className="mx-auto mt-3 flex max-w-5xl items-center justify-between gap-3 rounded-2xl border-[1.5px] border-line bg-[var(--surface)]/80 px-3.5 py-2.5 backdrop-blur-xl sm:px-4">
<Logo />
<nav className="flex items-center gap-1.5 sm:gap-2">
<ThemeToggle />
{user ? (
<>
{pathname !== "/dashboard" && (
<Link to="/dashboard" className="hidden sm:block">
<Button variant="ghost" size="sm">
Dashboard
</Button>
</Link>
)}
<div className="flex items-center gap-2 rounded-xl border-[1.5px] border-line bg-surface px-1 py-1 pr-2.5">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-accent text-xs font-bold text-accent-ink">
{user.name.slice(0, 1).toUpperCase()}
</span>
<button
onClick={() => logout()}
className="focusable rounded-md text-xs font-medium text-muted hover:text-ink"
>
Sign out
</button>
</div>
</>
) : (
<Button size="sm" variant="dark" onClick={() => navigate("/login")}>
Sign in
</Button>
)}
</nav>
</div>
</motion.header>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { motion } from "framer-motion";
interface Props {
page: number; // 1-based
pageCount: number;
onPage: (page: number) => void;
}
/** Build a windowed page list with ellipses, e.g. 1 … 4 5 6 … 12 */
function pageItems(page: number, count: number): (number | "…")[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i + 1);
const items: (number | "…")[] = [1];
const start = Math.max(2, page - 1);
const end = Math.min(count - 1, page + 1);
if (start > 2) items.push("…");
for (let i = start; i <= end; i++) items.push(i);
if (end < count - 1) items.push("…");
items.push(count);
return items;
}
export function Pagination({ page, pageCount, onPage }: Props) {
if (pageCount <= 1) return null;
const items = pageItems(page, pageCount);
return (
<div className="mt-6 flex items-center justify-center gap-1.5">
<button
onClick={() => onPage(page - 1)}
disabled={page === 1}
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30"
aria-label="Previous page"
>
</button>
{items.map((it, i) =>
it === "…" ? (
<span key={`e${i}`} className="px-1 text-sm text-muted">
</span>
) : (
<button
key={it}
onClick={() => onPage(it)}
aria-current={it === page}
className="focusable relative grid h-9 min-w-9 place-items-center rounded-xl px-2 text-sm font-semibold"
>
{it === page && (
<motion.span
layoutId="page-pill"
transition={{ type: "spring", stiffness: 480, damping: 32 }}
className="absolute inset-0 rounded-xl bg-accent"
/>
)}
<span
className={`relative z-10 ${
it === page ? "text-accent-ink" : "text-muted hover:text-ink"
}`}
>
{it}
</span>
</button>
),
)}
<button
onClick={() => onPage(page + 1)}
disabled={page === pageCount}
className="focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-ink transition-opacity hover:bg-surface-2 disabled:opacity-30"
aria-label="Next page"
>
</button>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { motion } from "framer-motion";
import { useRef } from "react";
interface Props {
value: string;
onChange: (value: string) => void;
length?: number;
}
export function PinInput({ value, onChange, length = 6 }: Props) {
const refs = useRef<(HTMLInputElement | null)[]>([]);
const digits = value.split("");
function setAt(i: number, char: string) {
const next = value.split("");
next[i] = char;
onChange(next.join("").slice(0, length));
}
function handleKey(i: number, e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Backspace" && !digits[i] && i > 0) {
refs.current[i - 1]?.focus();
}
if (e.key === "ArrowLeft" && i > 0) refs.current[i - 1]?.focus();
if (e.key === "ArrowRight" && i < length - 1) refs.current[i + 1]?.focus();
}
function handleChange(i: number, e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value.replace(/\D/g, "");
if (!raw) {
setAt(i, "");
return;
}
if (raw.length > 1) {
// paste
const chars = raw.slice(0, length).split("");
onChange(chars.join(""));
refs.current[Math.min(chars.length, length - 1)]?.focus();
return;
}
setAt(i, raw);
if (i < length - 1) refs.current[i + 1]?.focus();
}
return (
<div className="flex gap-2">
{Array.from({ length }).map((_, i) => (
<motion.input
key={i}
ref={(el) => (refs.current[i] = el)}
value={digits[i] ?? ""}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKey(i, e)}
inputMode="numeric"
maxLength={1}
aria-label={`PIN digit ${i + 1}`}
whileFocus={{ scale: 1.08, y: -2 }}
transition={{ type: "spring", stiffness: 500, damping: 16 }}
className="focusable h-12 w-full rounded-xl border-[1.5px] border-line bg-surface text-center font-mono text-lg font-bold text-ink outline-none"
/>
))}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import type { ShortLink } from "../lib/types";
import { shortUrl } from "../lib/format";
import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button";
import { PinInput } from "./PinInput";
interface Props {
link: ShortLink | null;
onClose: () => void;
onSaved: (updated: ShortLink, message: string) => void;
}
export function PinManager({ link, onClose, onSaved }: Props) {
const open = Boolean(link);
const hasPin = Boolean(link?.hasPin);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState<"save" | "remove" | null>(null);
const [error, setError] = useState<string | null>(null);
// Reset transient state whenever a different link opens the modal.
useEffect(() => {
setDraft("");
setError(null);
setBusy(null);
}, [link?.id]);
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
async function save() {
if (!link) return;
if (draft.length !== 6) {
setError("Enter all 6 digits.");
return;
}
setBusy("save");
setError(null);
try {
const updated = await api.setPin(link.id, draft);
onSaved(updated, hasPin ? "PIN updated" : "PIN protection enabled");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not save PIN.");
setBusy(null);
}
}
async function remove() {
if (!link) return;
setBusy("remove");
try {
const updated = await api.setPin(link.id, null);
onSaved(updated, "PIN protection removed");
} catch (e) {
setError(e instanceof Error ? e.message : "Could not remove PIN.");
setBusy(null);
}
}
return (
<Modal open={open} onClose={onClose} title="PIN protection">
<p className="-mt-2 mb-4 text-sm text-muted">
<span className="font-mono text-ink">{shortUrl(link.code)}</span> {" "}
{hasPin
? "visitors must enter this PIN before being redirected."
: "add a 6-digit PIN that visitors enter before redirect."}
</p>
{/* Current state. The server stores PINs hashed, so an existing value can
never be shown — only replaced or removed. */}
{hasPin && (
<div className="mb-4 flex items-center gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 px-4 py-3">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent text-base text-accent-ink">
🔒
</span>
<div>
<p className="text-sm font-semibold text-ink">PIN is active</p>
<p className="text-[12px] text-muted">
Stored encrypted set a new one below to change it.
</p>
</div>
</div>
)}
<label className="mb-2 block text-sm font-semibold text-ink">
{hasPin ? "Set a new PIN" : "Choose a PIN"}
</label>
<PinInput value={draft} onChange={setDraft} />
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="mt-3 text-sm font-medium text-red-500"
>
{error}
</motion.p>
)}
</AnimatePresence>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Button onClick={save} disabled={busy !== null || draft.length !== 6}>
{busy === "save" ? "Saving…" : hasPin ? "Update PIN" : "Enable PIN"}
</Button>
<Button variant="ghost" onClick={onClose} disabled={busy !== null}>
Cancel
</Button>
{hasPin && (
<Button
variant="ghost"
onClick={remove}
disabled={busy !== null}
className="ml-auto text-muted hover:!text-red-500"
>
{busy === "remove" ? "Removing…" : "Remove PIN"}
</Button>
)}
</div>
</Modal>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useRef, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import type { ShortLink } from "../lib/types";
import { fullShortUrl, prettyHost, shortUrl } from "../lib/format";
import { Modal } from "./ui/Modal";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink | null;
onClose: () => void;
onCopied: () => void;
}
export function QrModal({ link, onClose, onCopied }: Props) {
const open = Boolean(link);
const wrapRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
async function copy() {
if (!link) return;
try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
/* clipboard may be blocked */
}
setCopied(true);
onCopied();
setTimeout(() => setCopied(false), 1800);
}
function download() {
if (!link) return;
const svg = wrapRef.current?.querySelector("svg");
if (!svg) return;
const data = new XMLSerializer().serializeToString(svg);
const blob = new Blob([data], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `snip-${link.code}.svg`;
a.click();
URL.revokeObjectURL(url);
}
return (
<Modal open={open} onClose={onClose} title="QR code">
<div className="flex flex-col items-center">
<div
ref={wrapRef}
className="rounded-3xl border-[1.5px] border-line bg-white p-5 shadow-[0_16px_40px_-20px_rgba(0,0,0,0.5)]"
>
<QRCodeSVG
value={fullShortUrl(link.code)}
size={188}
bgColor="#ffffff"
fgColor="#16170f"
level="M"
/>
</div>
<p className="mt-4 font-mono text-lg font-bold text-ink">
{shortUrl(link.code)}
</p>
<p className="text-[13px] text-muted">
Points to {prettyHost(link.longUrl)}
</p>
<div className="mt-5 flex w-full items-center gap-2">
<Button block onClick={copy}>
{copied ? "Copied ✓" : "Copy link"}
</Button>
<Button block variant="outline" onClick={download}>
Download SVG
</Button>
</div>
</div>
</Modal>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { motion } from "framer-motion";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import type { ShortLink } from "../lib/types";
import { fullShortUrl, prettyHost, shortUrl } from "../lib/format";
import { useTheme } from "../context/ThemeContext";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink;
onReset: () => void;
onCopied: () => void;
}
const modeBadge: Record<string, string> = {
random: "Random code",
memorable: "Memorable words",
custom: "Custom alias",
};
export function ResultCard({ link, onReset, onCopied }: Props) {
const { theme } = useTheme();
const [copied, setCopied] = useState(false);
const [showQr, setShowQr] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
/* clipboard may be blocked in some contexts */
}
setCopied(true);
onCopied();
setTimeout(() => setCopied(false), 1800);
}
return (
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -16, scale: 0.96 }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
className="card relative overflow-hidden p-5 sm:p-7"
>
{/* confetti-ish accent corner */}
<div className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-accent opacity-20 blur-2xl" />
<div className="mb-4 flex items-center gap-2">
<motion.span
initial={{ scale: 0, rotate: -40 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 12, delay: 0.1 }}
className="grid h-7 w-7 place-items-center rounded-full bg-accent text-sm font-bold text-accent-ink"
>
</motion.span>
<span className="text-sm font-semibold text-ink">Your link is live</span>
<span className="ml-auto rounded-full border-[1.5px] border-line px-2.5 py-0.5 text-[11px] font-medium text-muted">
{modeBadge[link.mode]}
</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch">
<div className="flex min-w-0 flex-1 flex-col justify-between gap-3 rounded-2xl border-[1.5px] border-line bg-surface-2 p-4">
<div className="min-w-0">
<p className="truncate text-[13px] text-muted">
{prettyHost(link.longUrl)}
</p>
<p className="mt-1 break-all font-mono text-xl font-bold text-ink sm:text-2xl">
{shortUrl(link.code)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={copy} size="sm" className="min-w-[104px]">
<motion.span
key={copied ? "y" : "n"}
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 16 }}
>
{copied ? "Copied ✓" : "Copy link"}
</motion.span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowQr((v) => !v)}
>
{showQr ? "Hide QR" : "QR code"}
</Button>
{link.hasPin && (
<span className="inline-flex items-center gap-1 rounded-lg bg-[var(--surface)] px-2 py-1 text-[11px] font-medium text-muted">
🔒 PIN protected
</span>
)}
</div>
</div>
{showQr && (
<motion.div
initial={{ opacity: 0, scale: 0.7, rotate: -6 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 18 }}
className="grid place-items-center rounded-2xl border-[1.5px] border-line bg-white p-3"
>
<QRCodeSVG
value={fullShortUrl(link.code)}
size={120}
bgColor="#ffffff"
fgColor={theme === "dark" ? "#16170f" : "#16170f"}
level="M"
/>
</motion.div>
)}
</div>
<button
onClick={onReset}
className="focusable mt-4 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-muted hover:text-ink"
>
<span className="text-base"></span> Shorten another link
</button>
</motion.div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { ReactNode } from "react";
export interface Segment<T extends string> {
value: T;
label: string;
icon?: ReactNode;
hint?: string;
locked?: boolean;
}
interface Props<T extends string> {
segments: Segment<T>[];
value: T;
onChange: (value: T) => void;
layoutId?: string;
}
export function SegmentedControl<T extends string>({
segments,
value,
onChange,
}: Props<T>) {
return (
<div className="flex flex-col gap-1.5 rounded-2xl border-[1.5px] border-line bg-surface-2 p-1.5 sm:flex-row">
{segments.map((seg) => {
const active = seg.value === value;
return (
<button
key={seg.value}
type="button"
onClick={() => onChange(seg.value)}
className={`focusable relative flex-1 rounded-xl px-3 py-2.5 text-left transition-all duration-200 ${
active
? "bg-[var(--surface)] shadow-[0_4px_14px_-6px_rgba(0,0,0,0.3)] ring-[1.5px] ring-[var(--ring)]"
: ""
}`}
>
<span className="relative z-10 flex items-center gap-2">
<span
className={`text-base leading-none transition-transform ${
active ? "scale-110" : "opacity-60"
}`}
>
{seg.icon}
</span>
<span className="min-w-0">
<span
className={`flex items-center gap-1.5 text-sm font-semibold ${
active ? "text-ink" : "text-muted"
}`}
>
{seg.label}
{seg.locked && (
<span className="text-[10px] opacity-70">🔒</span>
)}
</span>
{seg.hint && (
<span className="block truncate text-[11px] text-muted">
{seg.hint}
</span>
)}
</span>
</span>
</button>
);
})}
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api";
import {
isValidUrl,
normalizeUrl,
validateCustomAlias,
} from "../lib/shortcode";
import { getShortDomain } from "../lib/format";
import type { ShortLink, UrlMode } from "../lib/types";
import { Button } from "./ui/Button";
import { useToast } from "./ui/Toast";
import { SegmentedControl, type Segment } from "./SegmentedControl";
import { PinInput } from "./PinInput";
import { ResultCard } from "./ResultCard";
const spring = { type: "spring", stiffness: 300, damping: 26 } as const;
export function ShortenForm() {
const { user } = useAuth();
const toast = useToast();
const navigate = useNavigate();
const [url, setUrl] = useState("");
const [mode, setMode] = useState<UrlMode>("random");
const [alias, setAlias] = useState("");
const [pinOn, setPinOn] = useState(false);
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorInput, setErrorInput] = useState<"url" | "alias" | null>(null);
const [result, setResult] = useState<ShortLink | null>(null);
function showError(msg: string, input: "url" | "alias" | null = null) {
setError(msg);
setErrorInput(input);
}
function clearError() {
setError(null);
setErrorInput(null);
}
const segments: Segment<UrlMode>[] = [
{ value: "random", label: "Random", icon: "🎲", hint: "Shortest code" },
{
value: "memorable",
label: "Memorable",
icon: "🌿",
hint: "Three easy words",
},
{
value: "custom",
label: "Custom",
icon: "✏️",
hint: user ? "You choose it" : "Sign in to use",
locked: !user,
},
];
function onMode(value: UrlMode) {
if (value === "custom" && !user) {
toast("Sign in to create custom links", "info");
navigate("/login");
return;
}
clearError();
setMode(value);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
clearError();
if (!url.trim()) {
showError("Paste a link to shorten.", "url");
return;
}
if (!isValidUrl(url)) {
showError("Hmm, that doesn't look like a valid URL.", "url");
return;
}
if (mode === "custom") {
const aliasErr = validateCustomAlias(alias);
if (aliasErr) {
showError(aliasErr, "alias");
return;
}
}
if (pinOn && pin.length !== 6) {
showError("Your PIN needs all 6 digits.");
return;
}
setBusy(true);
try {
console.log("[snip] createLink start", { mode });
const link = await api.createLink({
longUrl: normalizeUrl(url),
mode,
customAlias: mode === "custom" ? alias : undefined,
pin: pinOn && user ? pin : undefined,
});
console.log("[snip] createLink ok", link.code);
setResult(link);
toast("Link created — ready to share", "success");
} catch (err) {
console.log("[snip] createLink error", err);
const msg = err instanceof Error ? err.message : "Something went wrong.";
showError(msg, mode === "custom" ? "alias" : null);
} finally {
setBusy(false);
}
}
function reset() {
setResult(null);
setUrl("");
setAlias("");
setPin("");
setPinOn(false);
setMode("random");
clearError();
}
return (
<div className="relative">
<AnimatePresence mode="wait">
{result ? (
<ResultCard
key="result"
link={result}
onReset={reset}
onCopied={() => toast("Copied to clipboard", "success")}
/>
) : (
<motion.form
key="form"
onSubmit={submit}
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16, scale: 0.98 }}
transition={spring}
className="card p-4 sm:p-6"
>
{/* URL field */}
<label className="mb-2 block text-sm font-semibold text-ink">
Long URL
</label>
<div
className={`flex items-center gap-2 rounded-2xl border-[1.5px] bg-surface-2 px-3.5 transition-colors ${
errorInput === "url" ? "border-red-400/70" : "border-line"
}`}
>
<span className="text-lg opacity-50">🔗</span>
<input
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (errorInput === "url") clearError();
}}
placeholder="paste a long link, e.g. acme.com/spring/launch…"
autoComplete="off"
spellCheck={false}
className="h-14 w-full bg-transparent font-mono text-[15px] text-ink outline-none placeholder:font-sans placeholder:text-muted"
/>
</div>
{/* Mode selector */}
<div className="mt-5">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-ink">
Link style
</span>
<span className="text-xs text-muted">
{getShortDomain()}/
<span className="text-accent">
{mode === "random"
? "x7Qk"
: mode === "memorable"
? "amber-otter-loop"
: alias || "your-name"}
</span>
</span>
</div>
<SegmentedControl
segments={segments}
value={mode}
onChange={onMode}
layoutId="mode-pill"
/>
</div>
{/* Custom alias */}
<AnimatePresence initial={false}>
{mode === "custom" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={spring}
className="overflow-hidden"
>
<div className={`mt-4 flex items-center gap-0 overflow-hidden rounded-2xl border-[1.5px] bg-surface-2 transition-colors ${errorInput === "alias" ? "border-red-400/70" : "border-line"}`}>
<span className="select-none px-3.5 font-mono text-sm text-muted">
{getShortDomain()}/
</span>
<input
value={alias}
onChange={(e) => {
setAlias(
e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""),
);
if (errorInput === "alias") clearError();
}}
placeholder="spring-launch"
className="h-12 w-full bg-transparent pr-3.5 font-mono text-[15px] text-ink outline-none placeholder:text-muted"
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Authenticated extras: PIN */}
<div className="mt-5 rounded-2xl border-[1.5px] border-dashed border-line bg-surface-2/50 p-3.5">
{user ? (
<>
<button
type="button"
onClick={() => setPinOn((v) => !v)}
className="focusable flex w-full items-center gap-3 rounded-lg text-left"
>
<span
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
pinOn ? "bg-accent" : "bg-[var(--ring)]"
}`}
>
<motion.span
layout
transition={{
type: "spring",
stiffness: 600,
damping: 30,
}}
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow ${
pinOn ? "right-0.5" : "left-0.5"
}`}
/>
</span>
<span>
<span className="block text-sm font-semibold text-ink">
Protect with a 6-digit PIN
</span>
<span className="block text-xs text-muted">
Visitors enter it before they're redirected
</span>
</span>
</button>
<AnimatePresence initial={false}>
{pinOn && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={spring}
className="overflow-hidden"
>
<div className="pt-3.5">
<PinInput value={pin} onChange={setPin} />
</div>
</motion.div>
)}
</AnimatePresence>
</>
) : (
<div className="flex items-center gap-3">
<span className="text-lg opacity-60">🔒</span>
<p className="text-sm text-muted">
<button
type="button"
onClick={() => navigate("/login")}
className="focusable rounded font-semibold text-ink underline decoration-accent decoration-2 underline-offset-2"
>
Sign in
</button>{" "}
to add a custom alias and PIN protection.
</p>
</div>
)}
</div>
{/* Error */}
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, x: -6 }}
animate={{
opacity: 1,
x: [0, -6, 6, -4, 4, 0],
}}
exit={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="mt-3 text-sm font-medium text-red-500"
>
{error}
</motion.p>
)}
</AnimatePresence>
{/* Submit */}
<Button
type="submit"
size="lg"
block
disabled={busy}
className="mt-5"
>
{busy ? (
<span className="inline-block h-5 w-5 animate-spin rounded-full border-[2.5px] border-current border-t-transparent" />
) : (
<>
Shorten it <span className="text-lg"></span>
</>
)}
</Button>
</motion.form>
)}
</AnimatePresence>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { motion } from "framer-motion";
import type { DayCount } from "../lib/types";
interface Props {
data: DayCount[];
width?: number;
height?: number;
}
export function Sparkline({ data, width = 84, height = 30 }: Props) {
const max = Math.max(1, ...data.map((d) => d.count));
const n = data.length;
const stepX = n > 1 ? width / (n - 1) : width;
const pad = 3;
const usable = height - pad * 2;
const pts = data.map((d, i) => {
const x = i * stepX;
const y = pad + usable - (d.count / max) * usable;
return [x, y] as const;
});
const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
const area = `0,${height} ${line} ${width},${height}`;
const last = pts[pts.length - 1];
return (
<svg width={width} height={height} className="overflow-visible">
<polygon points={area} fill="var(--accent)" opacity={0.16} />
<motion.polyline
points={line}
fill="none"
stroke="var(--accent)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 120, damping: 20 }}
/>
{last && (
<motion.circle
cx={last[0]}
cy={last[1]}
r={2.6}
fill="var(--accent)"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.3, type: "spring", stiffness: 500, damping: 14 }}
/>
)}
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { motion } from "framer-motion";
import { useTheme } from "../context/ThemeContext";
export function ThemeToggle() {
const { theme, toggle } = useTheme();
const dark = theme === "dark";
return (
<motion.button
onClick={toggle}
whileTap={{ scale: 0.9 }}
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
className="focusable relative grid h-10 w-10 place-items-center overflow-hidden rounded-xl border-[1.5px] border-line bg-surface"
>
<motion.span
key={theme}
initial={{ y: 18, rotate: -90, opacity: 0, scale: 0.5 }}
animate={{ y: 0, rotate: 0, opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14 }}
className="text-[17px]"
>
{dark ? "🌙" : "☀️"}
</motion.span>
</motion.button>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { motion } from "framer-motion";
import type { ShortLink } from "../lib/types";
import {
compactNumber,
fullShortUrl,
prettyHost,
relativeTime,
shortUrl,
} from "../lib/format";
import { BarChart } from "./BarChart";
import { Button } from "./ui/Button";
interface Props {
link: ShortLink;
onEdit: (link: ShortLink) => void;
onDelete: (link: ShortLink) => void;
onCopy: () => void;
onManagePin: (link: ShortLink) => void;
onShowQr: (link: ShortLink) => void;
}
const modeLabel: Record<string, string> = {
random: "random",
memorable: "memorable",
custom: "custom",
};
export function UrlCard({
link,
onEdit,
onDelete,
onCopy,
onManagePin,
onShowQr,
}: Props) {
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0);
async function copy() {
try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
/* ignore */
}
onCopy();
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 24, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 280, damping: 26 }}
className="card flex flex-col gap-4 p-5 sm:flex-row sm:items-stretch sm:gap-6"
>
{/* Left: identity + actions */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-accent-ink">
{modeLabel[link.mode]}
</span>
{link.hasPin && (
<span className="rounded-full border-[1.5px] border-line px-2 py-0.5 text-[10px] font-medium text-muted">
🔒 PIN
</span>
)}
<span className="ml-auto text-[11px] text-muted">
{relativeTime(link.createdAt)}
</span>
</div>
<a
href={fullShortUrl(link.code)}
onClick={(e) => e.preventDefault()}
className="focusable mt-2 inline-block rounded font-mono text-lg font-bold text-ink hover:text-accent"
>
{shortUrl(link.code)}
</a>
<p
className="mt-1 truncate text-[13px] text-muted"
title={link.longUrl}
>
{prettyHost(link.longUrl)}
<span className="opacity-60">{new URL(link.longUrl).pathname}</span>
</p>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-4">
<Button size="sm" variant="outline" onClick={copy}>
Copy
</Button>
<Button size="sm" variant="ghost" onClick={() => onShowQr(link)}>
QR
</Button>
<Button size="sm" variant="ghost" onClick={() => onEdit(link)}>
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onManagePin(link)}
>
{link.hasPin ? "🔒 PIN" : "Add PIN"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(link)}
className="ml-auto text-muted hover:!text-red-500"
>
Delete
</Button>
</div>
</div>
{/* Right: stats */}
<div className="flex w-full flex-col rounded-2xl border-[1.5px] border-line bg-surface-2 p-4 sm:w-72">
<div className="mb-3 flex items-end justify-between">
<div>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
Total clicks
</p>
<p className="font-display text-2xl font-bold text-ink">
{compactNumber(link.totalClicks)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
Last 7 days
</p>
<p className="font-display text-lg font-bold text-accent-ink">
<span className="rounded-md bg-accent px-1.5">
+{compactNumber(weekTotal)}
</span>
</p>
</div>
</div>
<BarChart data={link.last7Days} />
</div>
</motion.div>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { motion } from "framer-motion";
import type { ShortLink } from "../lib/types";
import {
compactNumber,
fullShortUrl,
prettyHost,
shortUrl,
} from "../lib/format";
import { Sparkline } from "./Sparkline";
interface Props {
link: ShortLink;
onEdit: (link: ShortLink) => void;
onDelete: (link: ShortLink) => void;
onCopy: () => void;
onManagePin: (link: ShortLink) => void;
onShowQr: (link: ShortLink) => void;
}
function QrGlyph() {
return (
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="currentColor"
aria-hidden
>
<path d="M1 1h5v5H1V1zm1 1v3h3V2H2z" />
<path d="M9 1h5v5H9V1zm1 1v3h3V2h-3z" />
<path d="M1 9h5v5H1V9zm1 1v3h3v-3H2z" />
<rect x="9" y="9" width="2" height="2" />
<rect x="12" y="9" width="2" height="2" />
<rect x="9" y="12" width="2" height="2" />
<rect x="12" y="12" width="2" height="2" />
</svg>
);
}
function IconButton({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<motion.button
whileTap={{ scale: 0.88 }}
whileHover={{ y: -2 }}
transition={{ type: "spring", stiffness: 500, damping: 16 }}
onClick={onClick}
title={label}
aria-label={label}
className={`focusable grid h-9 w-9 place-items-center rounded-xl border-[1.5px] border-line bg-surface text-sm ${
danger ? "hover:!border-red-400 hover:text-red-500" : "hover:bg-surface-2"
}`}
>
{children}
</motion.button>
);
}
export function UrlRow({
link,
onEdit,
onDelete,
onCopy,
onManagePin,
onShowQr,
}: Props) {
const weekTotal = link.last7Days.reduce((s, d) => s + d.count, 0);
async function copy() {
try {
await navigator.clipboard.writeText(fullShortUrl(link.code));
} catch {
/* ignore */
}
onCopy();
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
className="card flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
{/* identity */}
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="hidden h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent text-xs font-bold text-accent-ink sm:grid">
{link.mode === "custom" ? "✏️" : link.mode === "memorable" ? "🌿" : "🎲"}
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<a
href={fullShortUrl(link.code)}
onClick={(e) => e.preventDefault()}
className="focusable truncate rounded font-mono text-[15px] font-bold text-ink hover:text-accent"
>
{shortUrl(link.code)}
</a>
{link.hasPin && <span className="text-[11px]">🔒</span>}
</div>
<p className="truncate text-[12px] text-muted" title={link.longUrl}>
{prettyHost(link.longUrl)}
</p>
</div>
</div>
{/* stats */}
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<p className="font-display text-base font-bold leading-none text-ink">
{compactNumber(link.totalClicks)}
</p>
<p className="text-[10px] font-medium uppercase tracking-wide text-muted">
clicks
</p>
</div>
<span className="hidden items-center rounded-md bg-accent/15 px-1 text-[11px] font-bold text-accent-ink sm:inline-flex">
+{compactNumber(weekTotal)}
</span>
<div className="hidden sm:block">
<Sparkline data={link.last7Days} />
</div>
</div>
{/* actions */}
<div className="flex shrink-0 items-center gap-1.5">
<IconButton label="Copy link" onClick={copy}>
</IconButton>
<IconButton label="Show QR code" onClick={() => onShowQr(link)}>
<QrGlyph />
</IconButton>
<IconButton label="Manage PIN" onClick={() => onManagePin(link)}>
🔒
</IconButton>
<IconButton label="Edit destination" onClick={() => onEdit(link)}>
</IconButton>
<IconButton label="Delete link" danger onClick={() => onDelete(link)}>
🗑
</IconButton>
</div>
</motion.div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { motion, type HTMLMotionProps } from "framer-motion";
import { forwardRef, type ReactNode } from "react";
type Variant = "primary" | "ghost" | "outline" | "dark";
type Size = "sm" | "md" | "lg";
interface Props extends Omit<HTMLMotionProps<"button">, "children"> {
variant?: Variant;
size?: Size;
children: ReactNode;
block?: boolean;
}
const sizes: Record<Size, string> = {
sm: "h-9 px-3.5 text-sm gap-1.5",
md: "h-11 px-5 text-[15px] gap-2",
lg: "h-14 px-7 text-base gap-2.5",
};
const variants: Record<Variant, string> = {
primary:
"bg-accent text-accent-ink font-semibold shadow-[0_8px_24px_-8px_var(--glow)] hover:brightness-105",
dark: "bg-[var(--ink)] text-[var(--bg)] font-semibold hover:opacity-90",
outline:
"bg-transparent text-ink font-medium border-[1.5px] border-line hover:bg-surface-2",
ghost: "bg-transparent text-ink font-medium hover:bg-surface-2",
};
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = "primary", size = "md", block, className = "", children, ...rest },
ref,
) {
return (
<motion.button
ref={ref}
whileTap={{ scale: 0.94 }}
whileHover={{ y: -2 }}
transition={{ type: "spring", stiffness: 520, damping: 16 }}
className={`focusable inline-flex select-none items-center justify-center rounded-2xl ${
sizes[size]
} ${variants[variant]} ${block ? "w-full" : ""} ${className}`}
{...rest}
>
{children}
</motion.button>
);
});
+56
View File
@@ -0,0 +1,56 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, type ReactNode } from "react";
interface Props {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-[150] grid place-items-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="absolute inset-0 bg-black/45 backdrop-blur-sm"
onClick={onClose}
/>
<motion.div
role="dialog"
aria-modal="true"
initial={{ opacity: 0, y: 28, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.95 }}
transition={{ type: "spring", stiffness: 360, damping: 26 }}
className="card relative z-10 w-full max-w-md p-6 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.7)]"
>
{title && (
<h2 className="mb-4 font-display text-xl font-bold tracking-tight text-ink">
{title}
</h2>
)}
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { AnimatePresence, motion } from "framer-motion";
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
type ToastKind = "success" | "error" | "info";
interface Toast {
id: number;
message: string;
kind: ToastKind;
}
const Ctx = createContext<(message: string, kind?: ToastKind) => void>(
() => {},
);
let counter = 0;
const icons: Record<ToastKind, ReactNode> = {
success: "✓",
error: "✕",
info: "→",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const push = useCallback((message: string, kind: ToastKind = "success") => {
const id = ++counter;
setToasts((t) => [...t, { id, message, kind }]);
setTimeout(() => {
setToasts((t) => t.filter((x) => x.id !== id));
}, 2800);
}, []);
return (
<Ctx.Provider value={push}>
{children}
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[200] flex flex-col items-center gap-2 px-4">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 28, scale: 0.85 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.85, y: 10 }}
transition={{ type: "spring", stiffness: 480, damping: 24 }}
className="card pointer-events-auto flex items-center gap-2.5 rounded-full px-4 py-2.5 shadow-[0_16px_40px_-18px_rgba(0,0,0,0.6)]"
>
<span
className={`grid h-5 w-5 place-items-center rounded-full text-[11px] font-bold ${
t.kind === "error"
? "bg-red-500/15 text-red-500"
: t.kind === "info"
? "bg-[var(--surface-2)] text-ink"
: "bg-accent text-accent-ink"
}`}
>
{icons[t.kind]}
</span>
<span className="text-sm font-medium text-ink">{t.message}</span>
</motion.div>
))}
</AnimatePresence>
</div>
</Ctx.Provider>
);
}
export function useToast() {
return useContext(Ctx);
}
+62
View File
@@ -0,0 +1,62 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { api } from "../lib/api";
import type { User } from "../lib/types";
interface AuthCtx {
user: User | null;
loading: boolean;
/** Re-fetch the session (e.g. after returning from an OAuth redirect). */
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const Ctx = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const u = await api.me();
setUser(u);
}, []);
useEffect(() => {
let alive = true;
api
.me()
.then((u) => {
if (alive) setUser(u);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
const logout = useCallback(async () => {
await api.logout();
setUser(null);
}, []);
return (
<Ctx.Provider value={{ user, loading, refresh, logout }}>
{children}
</Ctx.Provider>
);
}
export function useAuth() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+48
View File
@@ -0,0 +1,48 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
interface ThemeCtx {
theme: Theme;
toggle: () => void;
}
const Ctx = createContext<ThemeCtx | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() =>
document.documentElement.classList.contains("dark") ? "dark" : "light",
);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
try {
localStorage.setItem("snip-theme", theme);
} catch {
/* ignore */
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "dark" ? "#0e100a" : "#f4f2ea");
}, [theme]);
const toggle = useCallback(
() => setTheme((t) => (t === "dark" ? "light" : "dark")),
[],
);
return <Ctx.Provider value={{ theme, toggle }}>{children}</Ctx.Provider>;
}
export function useTheme() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
+133
View File
@@ -0,0 +1,133 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Light theme — warm paper + ink */
--bg: #f4f2ea;
--bg-grain: #ece9dd;
--surface: #fffdf7;
--surface-2: #f6f3e9;
--ink: #16170f;
--ink-solid: #16170f;
--muted: #6b6c5e;
--line: #e2decf;
--ring: #16170f;
--accent: #c6f24e;
--accent-ink: #16170f;
--glow: rgba(198, 242, 78, 0.45);
--dot: rgba(22, 23, 15, 0.06);
}
.dark {
/* Dark theme — deep moss + lime */
--bg: #0e100a;
--bg-grain: #14160e;
--surface: #181b11;
--surface-2: #1f2317;
--ink: #f1efe3;
--ink-solid: #000000;
--muted: #9b9d8a;
--line: #2c3020;
--ring: #3a3f2a;
--accent: #c6f24e;
--accent-ink: #16170f;
--glow: rgba(198, 242, 78, 0.22);
--dot: rgba(241, 239, 227, 0.05);
}
* {
-webkit-tap-highlight-color: transparent;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background-color: var(--bg);
color: var(--ink);
font-family: "General Sans", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
transition: background-color 0.5s ease, color 0.4s ease;
}
/* Atmospheric layered background: dotted grid + soft lime glows */
.app-bg::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
background-image: radial-gradient(var(--dot) 1.4px, transparent 1.4px);
background-size: 26px 26px;
background-position: -13px -13px;
pointer-events: none;
}
.app-bg::after {
content: "";
position: fixed;
inset: 0;
z-index: -3;
background:
radial-gradient(60% 50% at 12% 8%, var(--glow), transparent 60%),
radial-gradient(50% 40% at 92% 22%, var(--glow), transparent 60%),
radial-gradient(70% 60% at 78% 96%, var(--glow), transparent 65%);
filter: blur(8px);
opacity: 0.9;
pointer-events: none;
transition: opacity 0.5s ease;
}
/* Use CSS vars through Tailwind-friendly utility classes */
.bg-surface { background-color: var(--surface); }
.bg-surface-2 { background-color: var(--surface-2); }
.text-ink { color: var(--ink); }
.text-muted { color: var(--muted); }
.border-line { border-color: var(--line); }
.bg-accent { background-color: var(--accent); }
.text-accent { color: var(--accent); }
.text-accent-ink { color: var(--accent-ink); }
.ring-ink { --tw-ring-color: var(--ring); }
.card {
background-color: var(--surface);
border: 1.5px solid var(--line);
border-radius: 1.5rem;
}
/* Selection */
::selection {
background: var(--accent);
color: var(--accent-ink);
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 99px;
border: 3px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* Focus ring */
.focusable:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--bg), 0 0 0 5.5px var(--accent);
}
/* Grain overlay for texture */
.grain::before {
content: "";
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Real backend client. Talks to the Go service at `/api/v1`.
*
* In production the SPA is served same-origin behind the gateway, so the
* default base of "" works. For cross-origin dev set VITE_API_BASE.
*
* The method surface mirrors the old mock so swapping was a one-import change —
* except auth, which is now a real OAuth/OIDC redirect (no password login), and
* PIN reveal, which the server intentionally cannot do (PINs are hashed).
*/
import type {
CreateLinkInput,
LinkPage,
OwnerStats,
ShortLink,
User,
} from "./types";
const BASE = import.meta.env.VITE_API_BASE ?? "";
const ROOT = `${BASE}/api/v1`;
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(ROOT + path, {
credentials: "include",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
if (!res.ok) {
let message = res.statusText;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
/* non-json error */
}
throw new ApiError(message, res.status);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
// --- auth ---
async me(): Promise<User | null> {
try {
return await req<User>("/auth/me");
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null;
throw e;
}
},
async providers(): Promise<string[]> {
try {
const r = await req<{ providers: string[] }>("/auth/providers");
return r.providers ?? [];
} catch {
return [];
}
},
/** Full-page navigation target that starts the OAuth/OIDC dance. */
loginUrl(provider: string): string {
return `${ROOT}/auth/${provider}/login`;
},
async logout(): Promise<void> {
await req<void>("/auth/logout", { method: "POST" });
},
/** Non-secret server settings (e.g. the short-link domain). */
async config(): Promise<{ shortDomain: string }> {
return req<{ shortDomain: string }>("/config");
},
// --- links ---
/** One page of the caller's links, optionally filtered by `q` — paginated and
* searched server-side so the client never downloads every link. */
async listLinks(params: {
page: number;
pageSize: number;
q?: string;
}): Promise<LinkPage> {
const sp = new URLSearchParams({
page: String(params.page),
pageSize: String(params.pageSize),
});
if (params.q) sp.set("q", params.q);
return req<LinkPage>(`/links?${sp.toString()}`);
},
/** Aggregate dashboard tiles, computed in the DB. */
async stats(): Promise<OwnerStats> {
return req<OwnerStats>("/links/stats");
},
async createLink(input: CreateLinkInput): Promise<ShortLink> {
return req<ShortLink>("/links", {
method: "POST",
body: JSON.stringify({
longUrl: input.longUrl,
mode: input.mode,
customAlias: input.customAlias,
pin: input.pin,
}),
});
},
async updateLink(
id: string,
patch: { longUrl: string },
): Promise<ShortLink> {
return req<ShortLink>(`/links/${id}`, {
method: "PATCH",
body: JSON.stringify({ longUrl: patch.longUrl }),
});
},
/** Set or replace a PIN; pass null to remove it. */
async setPin(id: string, pin: string | null): Promise<ShortLink> {
if (pin === null) {
return req<ShortLink>(`/links/${id}/pin`, { method: "DELETE" });
}
return req<ShortLink>(`/links/${id}/pin`, {
method: "PUT",
body: JSON.stringify({ pin }),
});
},
async deleteLink(id: string): Promise<void> {
await req<void>(`/links/${id}`, { method: "DELETE" });
},
};
+57
View File
@@ -0,0 +1,57 @@
// The short-link domain is configurable, not hardcoded. It defaults to the
// build-time env var and can be overridden at runtime from GET /api/v1/config
// (see setShortDomain, called on boot in main.tsx).
let shortDomain = import.meta.env.VITE_SHORT_DOMAIN || "snip.to";
export function getShortDomain(): string {
return shortDomain;
}
export function setShortDomain(domain: string): void {
if (domain) shortDomain = domain;
}
export function shortUrl(code: string): string {
return `${shortDomain}/${code}`;
}
export function fullShortUrl(code: string): string {
return `https://${shortUrl(code)}`;
}
export function compactNumber(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const sec = Math.round(diff / 1000);
const min = Math.round(sec / 60);
const hr = Math.round(min / 60);
const day = Math.round(hr / 24);
if (sec < 45) return "just now";
if (min < 60) return `${min}m ago`;
if (hr < 24) return `${hr}h ago`;
if (day < 30) return `${day}d ago`;
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
export function weekdayLabel(iso: string): string {
return new Date(iso + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
})[0];
}
export function prettyHost(url: string): string {
try {
return new URL(url).host.replace(/^www\./, "");
} catch {
return url;
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Short-code generation.
*
* Strategy (mirrors what the Go backend will do):
* - "random" → base62 encoding of a monotonically increasing counter. This
* yields the SHORTEST possible string that is still globally
* unique (no collision checks, no wasted length). A counter of
* N needs ceil(log62(N)) chars: 62^4 ≈ 14.7M, 62^5 ≈ 916M.
* To avoid leaking sequence/volume, the counter is passed
* through a reversible bit-scramble before encoding.
* - "memorable" → 3 short, easy words joined by hyphens.
* - "custom" → user supplied (validated, authenticated only).
*/
const BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
export function encodeBase62(num: number): string {
if (num === 0) return BASE62[0];
let n = num;
let out = "";
while (n > 0) {
out = BASE62[n % 62] + out;
n = Math.floor(n / 62);
}
return out;
}
/**
* Reversible scramble so sequential counter values don't produce sequential
* codes. Knuth multiplicative hash over a 31-bit space with a coprime
* multiplier — keeps codes short while looking random.
*/
function scramble(id: number): number {
const PRIME = 2654435761; // 2^32 golden-ratio prime
const MOD = 0x7fffffff; // 2^31 - 1
return ((id + 1) * PRIME) % MOD;
}
export function codeFromCounter(counter: number): string {
// Bump the base so even the first link gets a pleasant 4-char code.
return encodeBase62(scramble(counter + 100_000));
}
const CUSTOM_RE = /^[a-zA-Z0-9_-]{3,32}$/;
export function validateCustomAlias(alias: string): string | null {
if (!alias) return "Pick an alias for your link.";
if (alias.length < 3) return "At least 3 characters.";
if (alias.length > 32) return "Keep it under 32 characters.";
if (!CUSTOM_RE.test(alias))
return "Only letters, numbers, hyphens and underscores.";
return null;
}
const URL_RE = /^https?:\/\/[^\s.]+\.[^\s]{2,}$/i;
export function normalizeUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function isValidUrl(input: string): boolean {
return URL_RE.test(normalizeUrl(input));
}
+50
View File
@@ -0,0 +1,50 @@
export type UrlMode = "random" | "memorable" | "custom";
export interface DayCount {
/** ISO date (YYYY-MM-DD) */
date: string;
count: number;
}
export interface ShortLink {
id: string;
code: string;
longUrl: string;
mode: UrlMode;
/** Whether a PIN protects the destination. PIN itself is never returned by the API. */
hasPin: boolean;
createdAt: string;
totalClicks: number;
/** Click counts for the last 7 days, oldest → newest. */
last7Days: DayCount[];
/** Present only for links the current user owns. */
owned?: boolean;
}
export interface CreateLinkInput {
longUrl: string;
mode: UrlMode;
/** Required when mode === "custom". */
customAlias?: string;
/** 6-digit string, authenticated users only. */
pin?: string;
}
export interface User {
id: string;
email: string;
name: string;
}
export interface OwnerStats {
totalLinks: number;
totalClicks: number;
weekClicks: number;
}
export interface LinkPage {
items: ShortLink[];
total: number;
page: number;
pageSize: number;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Curated word pools for the "easy to remember" mode. Short, concrete,
* unambiguous words → e.g. "amber-otter-loop". 3 words from these pools gives
* ~30 × 38 × 30 ≈ 34k base combinations, expanded with a tiny numeric suffix
* on collision in the real backend.
*/
export const ADJECTIVES = [
"amber", "brave", "calm", "clever", "cosmic", "crisp", "dawn", "eager",
"fizzy", "gentle", "happy", "honey", "ivory", "jolly", "keen", "lucky",
"mellow", "noble", "olive", "plush", "quartz", "rapid", "sunny", "swift",
"teal", "tidal", "vivid", "warm", "zesty", "zen",
];
export const NOUNS = [
"otter", "falcon", "maple", "comet", "pixel", "harbor", "meadow", "ember",
"willow", "lantern", "pebble", "cactus", "marble", "puffin", "ledger",
"cobra", "violet", "thistle", "acorn", "domino", "compass", "raven",
"saffron", "juniper", "lotus", "mango", "narwhal", "orchid", "pelican",
"quokka", "robin", "sparrow", "topaz", "umbra", "walrus", "yarrow", "zephyr",
];
export const VERBS = [
"loop", "dash", "soar", "drift", "glide", "spark", "leap", "flow",
"zoom", "hop", "skip", "roam", "bounce", "swirl", "dive", "climb",
];
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function memorableSlug(): string {
return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${pick(VERBS)}`;
}
+34
View File
@@ -0,0 +1,34 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext";
import { ToastProvider } from "./components/ui/Toast";
import { api } from "./lib/api";
import { setShortDomain } from "./lib/format";
import "./index.css";
function render() {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</StrictMode>,
);
}
// Pull the short-link domain from the server so it isn't hardcoded. Don't block
// first paint on it — render after config resolves or a short timeout.
const config = api
.config()
.then((c) => setShortDomain(c.shortDomain))
.catch(() => {});
Promise.race([config, new Promise((r) => setTimeout(r, 800))]).finally(render);
+410
View File
@@ -0,0 +1,410 @@
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api";
import { compactNumber } from "../lib/format";
import type { OwnerStats, ShortLink } from "../lib/types";
import { UrlCard } from "../components/UrlCard";
import { UrlRow } from "../components/UrlRow";
import { Pagination } from "../components/Pagination";
import { PinManager } from "../components/PinManager";
import { EditLinkModal } from "../components/EditLinkModal";
import { DeleteLinkModal } from "../components/DeleteLinkModal";
import { QrModal } from "../components/QrModal";
import { Button } from "../components/ui/Button";
import { useToast } from "../components/ui/Toast";
type View = "comfortable" | "compact";
const PAGE_SIZE: Record<View, number> = { comfortable: 5, compact: 8 };
const pop = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
show: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 340, damping: 20 },
},
} as const;
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) {
const opts: { value: View; icon: string; label: string }[] = [
{ value: "comfortable", icon: "▦", label: "Comfortable view" },
{ value: "compact", icon: "≣", label: "Compact view" },
];
return (
<div className="flex shrink-0 gap-1 rounded-xl border-[1.5px] border-line bg-surface-2 p-1">
{opts.map((o) => (
<button
key={o.value}
onClick={() => onChange(o.value)}
aria-label={o.label}
aria-pressed={view === o.value}
className="focusable relative grid h-8 w-9 place-items-center rounded-lg text-[15px]"
>
{view === o.value && (
<motion.span
layoutId="view-pill"
transition={{ type: "spring", stiffness: 480, damping: 32 }}
className="absolute inset-0 rounded-lg bg-[var(--surface)] shadow-[0_2px_8px_-3px_rgba(0,0,0,0.3)] ring-[1.5px] ring-[var(--ring)]"
/>
)}
<span
className={`relative z-10 ${view === o.value ? "text-ink" : "text-muted"}`}
>
{o.icon}
</span>
</button>
))}
</div>
);
}
export function Dashboard() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const toast = useToast();
const [view, setView] = useState<View>(
() => (localStorage.getItem("snip-view") as View) || "comfortable",
);
const [searchParams, setSearchParams] = useSearchParams();
const [pinTarget, setPinTarget] = useState<ShortLink | null>(null);
const [editTarget, setEditTarget] = useState<ShortLink | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ShortLink | null>(null);
const [qrTarget, setQrTarget] = useState<ShortLink | null>(null);
// Server-driven page + aggregate stats (the client never loads all links).
const [pageData, setPageData] = useState<{ items: ShortLink[]; total: number } | null>(null);
const [stats, setStats] = useState<OwnerStats | null>(null);
// Search + page live in the URL (?q=&page=) so they survive a refresh / share.
const query = searchParams.get("q") ?? "";
const debouncedQuery = useDebouncedValue(query, 300);
const rawPage = parseInt(searchParams.get("page") ?? "1", 10);
const requestedPage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const pageSize = PAGE_SIZE[view];
useEffect(() => {
if (!authLoading && !user) navigate("/login");
}, [authLoading, user, navigate]);
useEffect(() => {
localStorage.setItem("snip-view", view);
}, [view]);
// Fetch the current page from the API (debounced search, server pagination).
const reqId = useRef(0);
const loadPage = useCallback(async () => {
if (!user) return;
const id = ++reqId.current;
const res = await api.listLinks({
page: requestedPage,
pageSize,
q: debouncedQuery.trim() || undefined,
});
if (id === reqId.current) {
setPageData({ items: res.items, total: res.total });
}
}, [user, requestedPage, pageSize, debouncedQuery]);
const loadStats = useCallback(async () => {
if (!user) return;
setStats(await api.stats());
}, [user]);
useEffect(() => {
loadPage();
}, [loadPage]);
useEffect(() => {
loadStats();
}, [loadStats]);
const pageCount = pageData ? Math.max(1, Math.ceil(pageData.total / pageSize)) : 1;
const safePage = Math.min(requestedPage, pageCount);
// Keep the URL honest if it points past the last page (deletes, ?page=99…).
useEffect(() => {
if (pageData && requestedPage > pageCount) {
const next = new URLSearchParams(searchParams);
if (pageCount <= 1) next.delete("page");
else next.set("page", String(pageCount));
setSearchParams(next, { replace: true });
}
}, [pageData, requestedPage, pageCount, searchParams, setSearchParams]);
function updateSearch(q: string) {
const next = new URLSearchParams(searchParams);
if (q) next.set("q", q);
else next.delete("q");
next.delete("page"); // a new search jumps back to page 1
setSearchParams(next, { replace: true });
}
function changeView(v: View) {
setView(v);
const next = new URLSearchParams(searchParams);
next.delete("page"); // page sizes differ between views
setSearchParams(next, { replace: true });
}
function goToPage(p: number) {
const next = new URLSearchParams(searchParams);
if (p <= 1) next.delete("page");
else next.set("page", String(p));
setSearchParams(next);
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function handleDeleted(_id: string, message: string) {
setDeleteTarget(null);
toast(message, "info");
await Promise.all([loadPage(), loadStats()]);
}
// Edit / PIN: patch the row in place (totals are unaffected).
function applyUpdate(updated: ShortLink, message: string) {
setPageData((pd) =>
pd
? { ...pd, items: pd.items.map((l) => (l.id === updated.id ? { ...l, ...updated } : l)) }
: pd,
);
toast(message, "success");
}
const items = pageData?.items ?? [];
const total = pageData?.total ?? 0;
const totalLinks = stats?.totalLinks ?? 0;
const statTiles = [
{ label: "Active links", value: compactNumber(totalLinks) },
{ label: "Total clicks", value: compactNumber(stats?.totalClicks ?? 0) },
{
label: "Clicks this week",
value: `+${compactNumber(stats?.weekClicks ?? 0)}`,
accent: true,
},
];
const hasAnyLinks = totalLinks > 0;
return (
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8">
{/* header */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 26 }}
className="flex flex-wrap items-end justify-between gap-3"
>
<div>
<h1 className="font-display text-3xl font-bold tracking-tight text-ink sm:text-4xl">
Your links
</h1>
<p className="mt-1 text-sm text-muted">
Search, edit destinations, manage PINs, or retire a link.
</p>
</div>
<Link to="/">
<Button size="md">
<span className="text-lg"></span> New link
</Button>
</Link>
</motion.div>
{/* stat tiles */}
<div className="mt-6 grid grid-cols-3 gap-3">
{statTiles.map((s, i) => (
<motion.div
key={s.label}
initial={{ opacity: 0, y: 18, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 360, damping: 20, delay: 0.05 * i }}
className="card p-4"
>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted sm:text-xs">
{s.label}
</p>
<p
className={`mt-1 font-display text-2xl font-bold sm:text-3xl ${
s.accent ? "text-accent-ink" : "text-ink"
}`}
>
{s.accent ? (
<span className="rounded-lg bg-accent px-2">{s.value}</span>
) : (
s.value
)}
</p>
</motion.div>
))}
</div>
{/* toolbar: search + view toggle */}
{hasAnyLinks && (
<div className="mt-6 flex items-center gap-2.5">
<div className="flex h-11 flex-1 items-center gap-2 rounded-xl border-[1.5px] border-line bg-surface px-3.5">
<span className="text-muted"></span>
<input
value={query}
onChange={(e) => updateSearch(e.target.value)}
placeholder="Search by code or URL…"
className="h-full w-full bg-transparent text-[15px] text-ink outline-none placeholder:text-muted"
/>
{query && (
<button
onClick={() => updateSearch("")}
aria-label="Clear search"
className="focusable grid h-6 w-6 place-items-center rounded-md text-muted hover:bg-surface-2 hover:text-ink"
>
</button>
)}
</div>
<ViewToggle view={view} onChange={changeView} />
</div>
)}
{/* result meta */}
{hasAnyLinks && (
<p className="mt-3 text-xs text-muted">
{query ? (
<>
<span className="font-semibold text-ink">{total}</span> of {totalLinks}{" "}
{totalLinks === 1 ? "link" : "links"} match {query}
</>
) : (
<>
Showing <span className="font-semibold text-ink">{items.length}</span> of{" "}
{totalLinks} {totalLinks === 1 ? "link" : "links"}
</>
)}
</p>
)}
{/* list */}
<div className="mt-4">
{pageData === null ? (
<div className="space-y-4">
{[0, 1, 2].map((i) => (
<div
key={i}
className="card h-28 animate-pulse opacity-60"
style={{ animationDelay: `${i * 120}ms` }}
/>
))}
</div>
) : total === 0 && !debouncedQuery ? (
<motion.div
variants={pop}
initial="hidden"
animate="show"
className="card grid place-items-center gap-3 p-12 text-center"
>
<span className="text-4xl">🪄</span>
<p className="font-display text-xl font-bold text-ink">No links yet</p>
<p className="max-w-xs text-sm text-muted">
Create your first short link and it'll show up here with live click
stats.
</p>
<Link to="/" className="mt-1">
<Button>Create a link</Button>
</Link>
</motion.div>
) : total === 0 ? (
<motion.div
variants={pop}
initial="hidden"
animate="show"
className="card grid place-items-center gap-2 p-10 text-center"
>
<span className="text-3xl">🔍</span>
<p className="font-display text-lg font-bold text-ink">No matches</p>
<p className="text-sm text-muted">Nothing matches {query}.</p>
<button
onClick={() => updateSearch("")}
className="focusable mt-1 rounded-lg text-sm font-semibold text-ink underline decoration-accent decoration-2 underline-offset-2"
>
Clear search
</button>
</motion.div>
) : (
<>
<motion.div
layout
className={view === "compact" ? "space-y-2.5" : "space-y-4"}
>
<AnimatePresence mode="popLayout">
{items.map((link) =>
view === "compact" ? (
<UrlRow
key={link.id}
link={link}
onEdit={setEditTarget}
onDelete={setDeleteTarget}
onCopy={() => toast("Copied to clipboard", "success")}
onManagePin={setPinTarget}
onShowQr={setQrTarget}
/>
) : (
<UrlCard
key={link.id}
link={link}
onEdit={setEditTarget}
onDelete={setDeleteTarget}
onCopy={() => toast("Copied to clipboard", "success")}
onManagePin={setPinTarget}
onShowQr={setQrTarget}
/>
),
)}
</AnimatePresence>
</motion.div>
<Pagination page={safePage} pageCount={pageCount} onPage={goToPage} />
</>
)}
</div>
<EditLinkModal
link={editTarget}
onClose={() => setEditTarget(null)}
onSaved={(updated, message) => {
applyUpdate(updated, message);
setEditTarget(null);
}}
/>
<PinManager
link={pinTarget}
onClose={() => setPinTarget(null)}
onSaved={(updated, message) => {
applyUpdate(updated, message);
setPinTarget(null);
}}
/>
<QrModal
link={qrTarget}
onClose={() => setQrTarget(null)}
onCopied={() => toast("Copied to clipboard", "success")}
/>
<DeleteLinkModal
link={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { motion } from "framer-motion";
import { ShortenForm } from "../components/ShortenForm";
import { useAuth } from "../context/AuthContext";
const container = {
hidden: {},
show: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
};
const pop = {
hidden: { opacity: 0, y: 26, scale: 0.9 },
show: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 360, damping: 18 },
},
} as const;
const features = [
{ icon: "⚡", title: "Instant", text: "Links resolve in milliseconds, cached at the edge." },
{ icon: "🎯", title: "Three styles", text: "Random, memorable words, or fully your own." },
{ icon: "📊", title: "Live stats", text: "See every click with a 7-day breakdown." },
];
export function Home() {
const { user } = useAuth();
return (
<div className="mx-auto max-w-2xl px-4 pb-24 pt-10 sm:pt-16">
<motion.div variants={container} initial="hidden" animate="show">
{/* eyebrow */}
<motion.div variants={pop} className="mb-5 flex justify-center">
<span className="inline-flex items-center gap-2 rounded-full border-[1.5px] border-line bg-surface px-3.5 py-1.5 text-xs font-medium text-muted">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent" />
</span>
{user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"}
</span>
</motion.div>
{/* headline */}
<h1 className="text-center font-display text-5xl font-bold leading-[0.95] tracking-tight text-ink sm:text-7xl">
<motion.span variants={pop} className="block">
Long links,
</motion.span>
<motion.span variants={pop} className="block">
made{" "}
<span className="relative inline-block">
<span className="relative z-10">tiny</span>
<motion.span
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ delay: 0.7, type: "spring", stiffness: 200, damping: 18 }}
className="absolute inset-x-[-2px] bottom-1 z-0 h-2.5 origin-left rounded-full bg-accent sm:bottom-1.5 sm:h-3.5"
/>
</span>
.
</motion.span>
</h1>
<motion.p
variants={pop}
className="mx-auto mt-5 max-w-md text-center text-[15px] leading-relaxed text-muted sm:text-base"
>
Paste a clunky URL and get a clean, shareable link in a tap with
your choice of code, optional PIN, and click stats.
</motion.p>
{/* form */}
<motion.div variants={pop} className="mt-9">
<ShortenForm />
</motion.div>
{/* features */}
<motion.ul
variants={pop}
className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-3"
>
{features.map((f) => (
<motion.li
key={f.title}
whileHover={{ y: -4 }}
transition={{ type: "spring", stiffness: 400, damping: 16 }}
className="card p-4"
>
<span className="text-xl">{f.icon}</span>
<p className="mt-2 text-sm font-semibold text-ink">{f.title}</p>
<p className="mt-0.5 text-[13px] leading-snug text-muted">
{f.text}
</p>
</motion.li>
))}
</motion.ul>
</motion.div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { api } from "../lib/api";
import { Button } from "../components/ui/Button";
const META: Record<string, { label: string; icon: string }> = {
google: { label: "Continue with Google", icon: "🇬" },
oidc: { label: "Continue with SSO", icon: "🔐" },
};
function providerMeta(name: string) {
return META[name] ?? { label: `Continue with ${name}`, icon: "🔐" };
}
export function Login() {
const { user, loading } = useAuth();
const navigate = useNavigate();
const [providers, setProviders] = useState<string[] | null>(null);
useEffect(() => {
if (!loading && user) navigate("/dashboard");
}, [loading, user, navigate]);
useEffect(() => {
api.providers().then(setProviders);
}, []);
return (
<div className="mx-auto grid min-h-[70vh] max-w-md place-items-center px-4 pb-24 pt-10">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 280, damping: 22 }}
className="card w-full p-7 sm:p-8"
>
<motion.div
initial={{ scale: 0, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 14, delay: 0.1 }}
className="mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--ink)]"
>
<svg width="24" height="24" viewBox="0 0 64 64" fill="none">
<path
d="M26 38l12-12M28 20a8 8 0 0 1 11 11l-3 3M36 44a8 8 0 0 1-11-11l3-3"
stroke="var(--accent)"
strokeWidth="5.5"
strokeLinecap="round"
/>
</svg>
</motion.div>
<h1 className="font-display text-3xl font-bold tracking-tight text-ink">
Welcome to snip
</h1>
<p className="mt-1.5 text-sm text-muted">
Sign in to manage your links, lock them with a PIN, and watch the
clicks roll in.
</p>
<div className="mt-6 space-y-2.5">
{providers === null ? (
<>
<div className="h-14 animate-pulse rounded-2xl bg-surface-2" />
<div className="h-14 animate-pulse rounded-2xl bg-surface-2 opacity-60" />
</>
) : providers.length === 0 ? (
<div className="rounded-2xl border-[1.5px] border-dashed border-line bg-surface-2/50 p-4 text-center text-sm text-muted">
No login providers are configured on the server yet. Set{" "}
<span className="font-mono text-ink">GOOGLE_*</span> or{" "}
<span className="font-mono text-ink">OIDC_*</span> env vars to
enable sign-in.
</div>
) : (
providers.map((p, i) => {
const meta = providerMeta(p);
return (
<motion.div
key={p}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 * i, type: "spring", stiffness: 320, damping: 22 }}
>
<Button
variant={i === 0 ? "primary" : "outline"}
size="lg"
block
onClick={() => {
window.location.href = api.loginUrl(p);
}}
>
<span className="text-lg">{meta.icon}</span> {meta.label}
</Button>
</motion.div>
);
})
)}
</div>
<p className="mt-5 text-center text-xs text-muted">
You can still create random &amp; memorable links without an account
sign in only to customize, protect, and track them.
</p>
</motion.div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
readonly VITE_SHORT_DOMAIN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+47
View File
@@ -0,0 +1,47 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
display: ['"Clash Display"', "ui-sans-serif", "system-ui", "sans-serif"],
sans: ['"General Sans"', "ui-sans-serif", "system-ui", "sans-serif"],
mono: ['"Space Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
},
colors: {
// Warm paper + ink, electric lime accent. Deliberately not purple-on-white.
paper: "#F4F2EA",
ink: "#16170F",
lime: {
DEFAULT: "#C6F24E",
400: "#D2F76B",
500: "#C6F24E",
600: "#A9DB2E",
700: "#7FA614",
},
},
boxShadow: {
pop: "0 2px 0 0 var(--ring), 0 10px 30px -12px rgba(0,0,0,0.35)",
hard: "4px 4px 0 0 var(--ink-solid)",
},
borderRadius: {
"4xl": "2rem",
},
keyframes: {
float: {
"0%, 100%": { transform: "translateY(0)" },
"50%": { transform: "translateY(-8px)" },
},
"spin-slow": {
to: { transform: "rotate(360deg)" },
},
},
animation: {
float: "float 6s ease-in-out infinite",
"spin-slow": "spin-slow 18s linear infinite",
},
},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Minimal ambient declaration so we can read an env override without pulling in
// @types/node just for the config file.
declare const process: { env: Record<string, string | undefined> };
// In dev the SPA runs on :5173 while the Go API runs elsewhere (default :8080,
// or the compose gateway). Proxy /api so the browser stays same-origin — no
// CORS, and the session cookie works. Override the target with VITE_API_PROXY.
const apiTarget = process.env.VITE_API_PROXY || "http://localhost:8080";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
"/api": {
target: apiTarget,
changeOrigin: true,
},
},
},
});