mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
backend/bin
|
||||
**/*.log
|
||||
.git
|
||||
.claude
|
||||
.DS_Store
|
||||
memory
|
||||
+26
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
   
|
||||
|
||||
## 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.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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>")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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 →</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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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
@@ -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>
|
||||
Generated
+2592
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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" });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & memorable links without an account —
|
||||
sign in only to customize, protect, and track them.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+10
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user