From 3395ab6dd37df4c1624b87ef4bb0af69a8422f34 Mon Sep 17 00:00:00 2001 From: sittichok Ouamsiri Date: Mon, 15 Jun 2026 21:25:57 +0700 Subject: [PATCH] feat: first commit --- .claude/launch.json | 18 + .dockerignore | 8 + .gitignore | 26 + Caddyfile | 28 + Dockerfile | 34 + README.md | 176 ++ backend/README.md | 114 + backend/cmd/snip/main.go | 100 + backend/go.mod | 22 + backend/go.sum | 50 + backend/internal/adapter/identity/oidc.go | 74 + backend/internal/adapter/memory/cache.go | 48 + backend/internal/adapter/memory/link_repo.go | 171 ++ backend/internal/adapter/memory/user_repo.go | 60 + backend/internal/adapter/postgres/db.go | 98 + .../internal/adapter/postgres/link_repo.go | 230 ++ .../internal/adapter/postgres/user_repo.go | 58 + backend/internal/adapter/rediscache/cache.go | 58 + backend/internal/adapter/security/security.go | 74 + backend/internal/config/config.go | 92 + backend/internal/di/container.go | 160 + backend/internal/domain/errors.go | 18 + backend/internal/domain/link.go | 49 + backend/internal/httpx/api/api_test.go | 198 ++ backend/internal/httpx/api/auth.go | 88 + backend/internal/httpx/api/dto.go | 69 + backend/internal/httpx/api/links.go | 148 + backend/internal/httpx/api/server.go | 152 + .../internal/httpx/frontend/frontend_test.go | 57 + backend/internal/httpx/frontend/server.go | 59 + backend/internal/httpx/redirect/notfound.go | 24 + backend/internal/httpx/redirect/pin_page.go | 108 + .../internal/httpx/redirect/redirect_test.go | 99 + backend/internal/httpx/redirect/server.go | 92 + backend/internal/port/ports.go | 85 + backend/internal/service/auth_service.go | 120 + backend/internal/service/clicks.go | 95 + backend/internal/service/link_service.go | 255 ++ backend/internal/service/link_service_test.go | 158 + backend/internal/service/redirect_service.go | 89 + .../internal/service/redirect_service_test.go | 131 + backend/internal/service/shortcode.go | 124 + backend/internal/service/shortcode_test.go | 75 + backend/internal/service/words.go | 22 + docker-compose.yml | 143 + index.html | 30 + package-lock.json | 2592 +++++++++++++++++ package.json | 29 + postcss.config.js | 6 + public/favicon.svg | 4 + public/favicon_dark.svg | 4 + src/App.tsx | 80 + src/components/BarChart.tsx | 68 + src/components/DeleteLinkModal.tsx | 71 + src/components/EditLinkModal.tsx | 118 + src/components/Logo.tsx | 30 + src/components/Navbar.tsx | 55 + src/components/Pagination.tsx | 77 + src/components/PinInput.tsx | 64 + src/components/PinManager.tsx | 125 + src/components/QrModal.tsx | 81 + src/components/ResultCard.tsx | 125 + src/components/SegmentedControl.tsx | 69 + src/components/ShortenForm.tsx | 332 +++ src/components/Sparkline.tsx | 54 + src/components/ThemeToggle.tsx | 26 + src/components/UrlCard.tsx | 142 + src/components/UrlRow.tsx | 156 + src/components/ui/Button.tsx | 47 + src/components/ui/Modal.tsx | 56 + src/components/ui/Toast.tsx | 77 + src/context/AuthContext.tsx | 62 + src/context/ThemeContext.tsx | 48 + src/index.css | 133 + src/lib/api.ts | 141 + src/lib/format.ts | 57 + src/lib/shortcode.ts | 66 + src/lib/types.ts | 50 + src/lib/words.ts | 34 + src/main.tsx | 34 + src/pages/Dashboard.tsx | 410 +++ src/pages/Home.tsx | 101 + src/pages/Login.tsx | 108 + src/vite-env.d.ts | 10 + tailwind.config.js | 47 + tsconfig.json | 21 + tsconfig.node.json | 12 + vite.config.ts | 25 + 88 files changed, 10034 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/cmd/snip/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/adapter/identity/oidc.go create mode 100644 backend/internal/adapter/memory/cache.go create mode 100644 backend/internal/adapter/memory/link_repo.go create mode 100644 backend/internal/adapter/memory/user_repo.go create mode 100644 backend/internal/adapter/postgres/db.go create mode 100644 backend/internal/adapter/postgres/link_repo.go create mode 100644 backend/internal/adapter/postgres/user_repo.go create mode 100644 backend/internal/adapter/rediscache/cache.go create mode 100644 backend/internal/adapter/security/security.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/di/container.go create mode 100644 backend/internal/domain/errors.go create mode 100644 backend/internal/domain/link.go create mode 100644 backend/internal/httpx/api/api_test.go create mode 100644 backend/internal/httpx/api/auth.go create mode 100644 backend/internal/httpx/api/dto.go create mode 100644 backend/internal/httpx/api/links.go create mode 100644 backend/internal/httpx/api/server.go create mode 100644 backend/internal/httpx/frontend/frontend_test.go create mode 100644 backend/internal/httpx/frontend/server.go create mode 100644 backend/internal/httpx/redirect/notfound.go create mode 100644 backend/internal/httpx/redirect/pin_page.go create mode 100644 backend/internal/httpx/redirect/redirect_test.go create mode 100644 backend/internal/httpx/redirect/server.go create mode 100644 backend/internal/port/ports.go create mode 100644 backend/internal/service/auth_service.go create mode 100644 backend/internal/service/clicks.go create mode 100644 backend/internal/service/link_service.go create mode 100644 backend/internal/service/link_service_test.go create mode 100644 backend/internal/service/redirect_service.go create mode 100644 backend/internal/service/redirect_service_test.go create mode 100644 backend/internal/service/shortcode.go create mode 100644 backend/internal/service/shortcode_test.go create mode 100644 backend/internal/service/words.go create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/favicon.svg create mode 100644 public/favicon_dark.svg create mode 100644 src/App.tsx create mode 100644 src/components/BarChart.tsx create mode 100644 src/components/DeleteLinkModal.tsx create mode 100644 src/components/EditLinkModal.tsx create mode 100644 src/components/Logo.tsx create mode 100644 src/components/Navbar.tsx create mode 100644 src/components/Pagination.tsx create mode 100644 src/components/PinInput.tsx create mode 100644 src/components/PinManager.tsx create mode 100644 src/components/QrModal.tsx create mode 100644 src/components/ResultCard.tsx create mode 100644 src/components/SegmentedControl.tsx create mode 100644 src/components/ShortenForm.tsx create mode 100644 src/components/Sparkline.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/UrlCard.tsx create mode 100644 src/components/UrlRow.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/context/ThemeContext.tsx create mode 100644 src/index.css create mode 100644 src/lib/api.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/shortcode.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/words.ts create mode 100644 src/main.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Home.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..75dd4c2 --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4cc1658 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +backend/bin +**/*.log +.git +.claude +.DS_Store +memory diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fb1ef6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..a37440b --- /dev/null +++ b/Caddyfile @@ -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 + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..47d93c5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef38aa7 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# snip — a fast, friendly URL shortener + +A minimal, delightful link shortener. **This phase is frontend-only**, running +entirely on a mock backend (localStorage + simulated latency) so the UI/UX can +be built and reviewed before the Go service exists. + +![stack](https://img.shields.io/badge/react-18-000) ![stack](https://img.shields.io/badge/vite-5-000) ![stack](https://img.shields.io/badge/tailwind-3-000) ![stack](https://img.shields.io/badge/motion-11-000) + +## Run it + +```bash +npm install +npm run dev # http://localhost:5173 +npm run build # typecheck + production build +``` + +There's a seeded demo account — click **Sign in → Continue with demo account** +(any email works, no password). Data persists in `localStorage`; clear it to +reset. + +## What it does + +- **Create short links** on the home page with three code styles: + - **Random** _(default)_ — shortest possible unique code via base62 (see + [`src/lib/shortcode.ts`](src/lib/shortcode.ts)). + - **Memorable** — three easy words, e.g. `amber-otter-loop`. + - **Custom** _(signed-in only)_ — pick your own alias. +- **PIN protection** _(signed-in only)_ — a 6-digit gate before redirect. +- **Result card** — one-tap copy + QR code. +- **Dashboard** — your links with **search**, **pagination**, and a + **comfortable ↔ compact** view toggle (persisted) for large lists. Search and + page are kept in the URL (`?q=&page=`) so they survive a refresh or share, and + paging scrolls back to the top. Each link opens a consistent popup for + **edit**, **delete** (with confirmation), **PIN management** (view / change / + remove), and a **QR code** (copy + download SVG), plus total clicks and a + **7-day click chart** (or sparkline in compact mode). +- **Dark mode** + spring-physics ("bouncy") motion throughout, fully responsive. + +## Design + +- **Type:** Clash Display (headings) · General Sans (body) · Space Mono (codes). +- **Palette:** warm paper / near-black ink with an **electric-lime** accent — + deliberately not the purple-on-white AI default. All colors are CSS variables + in [`src/index.css`](src/index.css) with a `.dark` override. +- **Motion:** `framer-motion` spring transitions — the segmented mode selector + (shared `layoutId`), result-card pop, staggered hero reveal, chart bars, and + toasts. + +## Project structure + +``` +src/ + lib/ + mockApi.ts ← THE BACKEND SEAM. Swap these methods for fetch() calls. + shortcode.ts ← base62 code generation, URL + alias validation + words.ts ← memorable-word pools + types.ts ← shared API types (mirror the Go structs) + format.ts ← display helpers (compact numbers, relative time…) + context/ ← Auth + Theme providers + components/ ← Navbar, ShortenForm, ResultCard, UrlCard, BarChart, PinInput… + pages/ ← Home, Login, Dashboard +``` + +## Swapping in the real backend + +Every server interaction goes through the single `api` object in +[`src/lib/mockApi.ts`](src/lib/mockApi.ts). Replacing each method body with a +`fetch()` to the Go service — keeping the same signatures and the types in +[`src/lib/types.ts`](src/lib/types.ts) — is the entire integration. + +--- + +## Backend + +> **Built** — see [`backend/`](backend/) and [backend/README.md](backend/README.md). +> Go (hexagonal + DI), Postgres, Redis. One binary with three commands +> (`api` / `frontend` / `redirect`), packed into one [Dockerfile](Dockerfile) and +> wired up by [docker-compose.yml](docker-compose.yml) behind a [Caddy](Caddyfile) +> gateway. Run the whole stack with `docker compose up --build`, then open +> http://localhost:8080. The brief's priority — **minimize DB hits, cache in +> Redis, serve the fastest redirect** — is implemented as described below. + +The SPA talks to the **real API** (`src/lib/api.ts`) — there's no mock backend +anymore. Anonymous random/memorable links work immediately; custom aliases, +PINs, the dashboard, and stats require signing in. + +### Logging in locally (mock OIDC) + +Compose bundles a **mock OpenID Connect provider** (`navikt/mock-oauth2-server`) +so login works with no real Google/OIDC credentials. Because an OIDC issuer URL +must be identical from the browser and the backend, add one line to your hosts +file so the browser can resolve the provider's docker name: + +``` +# /etc/hosts +127.0.0.1 mock-oidc +``` + +Then `docker compose up --build`, open http://localhost:8080, click **Sign in → +Continue with SSO**, and you're logged in as the mock "Demo User" (`demo@snip.to`). +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. +``` diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7843394 --- /dev/null +++ b/backend/README.md @@ -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. diff --git a/backend/cmd/snip/main.go b/backend/cmd/snip/main.go new file mode 100644 index 0000000..c5a457e --- /dev/null +++ b/backend/cmd/snip/main.go @@ -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 ") +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..877ce59 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..288c5f1 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/adapter/identity/oidc.go b/backend/internal/adapter/identity/oidc.go new file mode 100644 index 0000000..a6cf334 --- /dev/null +++ b/backend/internal/adapter/identity/oidc.go @@ -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 +} diff --git a/backend/internal/adapter/memory/cache.go b/backend/internal/adapter/memory/cache.go new file mode 100644 index 0000000..edaf743 --- /dev/null +++ b/backend/internal/adapter/memory/cache.go @@ -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 +} diff --git a/backend/internal/adapter/memory/link_repo.go b/backend/internal/adapter/memory/link_repo.go new file mode 100644 index 0000000..80fe681 --- /dev/null +++ b/backend/internal/adapter/memory/link_repo.go @@ -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 +} diff --git a/backend/internal/adapter/memory/user_repo.go b/backend/internal/adapter/memory/user_repo.go new file mode 100644 index 0000000..50fcfd3 --- /dev/null +++ b/backend/internal/adapter/memory/user_repo.go @@ -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) +} diff --git a/backend/internal/adapter/postgres/db.go b/backend/internal/adapter/postgres/db.go new file mode 100644 index 0000000..1de5cba --- /dev/null +++ b/backend/internal/adapter/postgres/db.go @@ -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 +} diff --git a/backend/internal/adapter/postgres/link_repo.go b/backend/internal/adapter/postgres/link_repo.go new file mode 100644 index 0000000..c733272 --- /dev/null +++ b/backend/internal/adapter/postgres/link_repo.go @@ -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 +} diff --git a/backend/internal/adapter/postgres/user_repo.go b/backend/internal/adapter/postgres/user_repo.go new file mode 100644 index 0000000..7b81982 --- /dev/null +++ b/backend/internal/adapter/postgres/user_repo.go @@ -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) +} diff --git a/backend/internal/adapter/rediscache/cache.go b/backend/internal/adapter/rediscache/cache.go new file mode 100644 index 0000000..a189055 --- /dev/null +++ b/backend/internal/adapter/rediscache/cache.go @@ -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() } diff --git a/backend/internal/adapter/security/security.go b/backend/internal/adapter/security/security.go new file mode 100644 index 0000000..ee205ba --- /dev/null +++ b/backend/internal/adapter/security/security.go @@ -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)) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..1fb18be --- /dev/null +++ b/backend/internal/config/config.go @@ -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, + } +} diff --git a/backend/internal/di/container.go b/backend/internal/di/container.go new file mode 100644 index 0000000..04e20cd --- /dev/null +++ b/backend/internal/di/container.go @@ -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 +} diff --git a/backend/internal/domain/errors.go b/backend/internal/domain/errors.go new file mode 100644 index 0000000..b1a18a9 --- /dev/null +++ b/backend/internal/domain/errors.go @@ -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") +) diff --git a/backend/internal/domain/link.go b/backend/internal/domain/link.go new file mode 100644 index 0000000..82da69a --- /dev/null +++ b/backend/internal/domain/link.go @@ -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 +} diff --git a/backend/internal/httpx/api/api_test.go b/backend/internal/httpx/api/api_test.go new file mode 100644 index 0000000..4cc4382 --- /dev/null +++ b/backend/internal/httpx/api/api_test.go @@ -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: "sam@example.com", 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"] != "sam@example.com" { + 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) + } +} diff --git a/backend/internal/httpx/api/auth.go b/backend/internal/httpx/api/auth.go new file mode 100644 index 0000000..8baa48e --- /dev/null +++ b/backend/internal/httpx/api/auth.go @@ -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) +} diff --git a/backend/internal/httpx/api/dto.go b/backend/internal/httpx/api/dto.go new file mode 100644 index 0000000..57e4f1b --- /dev/null +++ b/backend/internal/httpx/api/dto.go @@ -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"` +} diff --git a/backend/internal/httpx/api/links.go b/backend/internal/httpx/api/links.go new file mode 100644 index 0000000..ba02f31 --- /dev/null +++ b/backend/internal/httpx/api/links.go @@ -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) + } +} diff --git a/backend/internal/httpx/api/server.go b/backend/internal/httpx/api/server.go new file mode 100644 index 0000000..70ef7b2 --- /dev/null +++ b/backend/internal/httpx/api/server.go @@ -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) +} diff --git a/backend/internal/httpx/frontend/frontend_test.go b/backend/internal/httpx/frontend/frontend_test.go new file mode 100644 index 0000000..ffd5aa2 --- /dev/null +++ b/backend/internal/httpx/frontend/frontend_test.go @@ -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("
SPA
"), 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) + } +} diff --git a/backend/internal/httpx/frontend/server.go b/backend/internal/httpx/frontend/server.go new file mode 100644 index 0000000..05cacec --- /dev/null +++ b/backend/internal/httpx/frontend/server.go @@ -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) +} diff --git a/backend/internal/httpx/redirect/notfound.go b/backend/internal/httpx/redirect/notfound.go new file mode 100644 index 0000000..a474ad2 --- /dev/null +++ b/backend/internal/httpx/redirect/notfound.go @@ -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 ` + +Link not found

404

+

This short link doesn't exist or was removed.

+Go to ` + h + `
` +} diff --git a/backend/internal/httpx/redirect/pin_page.go b/backend/internal/httpx/redirect/pin_page.go new file mode 100644 index 0000000..6ba817b --- /dev/null +++ b/backend/internal/httpx/redirect/pin_page.go @@ -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(` + + + + + +Enter PIN · {{.ShortHost}}/{{.Code}} + + + +
+
+

This link is protected

+

Enter the 6-digit PIN to continue to {{.ShortHost}}/{{.Code}}.

+
+
+ + + + + + +
+

{{if .HasError}}That PIN didn't match. Try again.{{end}}

+ + +
+
Secured by {{.ShortHost}}
+
+ + +`)) + +// 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) +} diff --git a/backend/internal/httpx/redirect/redirect_test.go b/backend/internal/httpx/redirect/redirect_test.go new file mode 100644 index 0000000..f80ede0 --- /dev/null +++ b/backend/internal/httpx/redirect/redirect_test.go @@ -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 +} diff --git a/backend/internal/httpx/redirect/server.go b/backend/internal/httpx/redirect/server.go new file mode 100644 index 0000000..c6f203d --- /dev/null +++ b/backend/internal/httpx/redirect/server.go @@ -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))) +} diff --git a/backend/internal/port/ports.go b/backend/internal/port/ports.go new file mode 100644 index 0000000..a554eac --- /dev/null +++ b/backend/internal/port/ports.go @@ -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) +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go new file mode 100644 index 0000000..bf45d12 --- /dev/null +++ b/backend/internal/service/auth_service.go @@ -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) {} diff --git a/backend/internal/service/clicks.go b/backend/internal/service/clicks.go new file mode 100644 index 0000000..65394b9 --- /dev/null +++ b/backend/internal/service/clicks.go @@ -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() + } + } +} diff --git a/backend/internal/service/link_service.go b/backend/internal/service/link_service.go new file mode 100644 index 0000000..a2ff761 --- /dev/null +++ b/backend/internal/service/link_service.go @@ -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) +} diff --git a/backend/internal/service/link_service_test.go b/backend/internal/service/link_service_test.go new file mode 100644 index 0000000..60ef0d2 --- /dev/null +++ b/backend/internal/service/link_service_test.go @@ -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) + } +} diff --git a/backend/internal/service/redirect_service.go b/backend/internal/service/redirect_service.go new file mode 100644 index 0000000..7c07b6c --- /dev/null +++ b/backend/internal/service/redirect_service.go @@ -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 +} diff --git a/backend/internal/service/redirect_service_test.go b/backend/internal/service/redirect_service_test.go new file mode 100644 index 0000000..8f7a46c --- /dev/null +++ b/backend/internal/service/redirect_service_test.go @@ -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) diff --git a/backend/internal/service/shortcode.go b/backend/internal/service/shortcode.go new file mode 100644 index 0000000..29a4544 --- /dev/null +++ b/backend/internal/service/shortcode.go @@ -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) +} diff --git a/backend/internal/service/shortcode_test.go b/backend/internal/service/shortcode_test.go new file mode 100644 index 0000000..2853598 --- /dev/null +++ b/backend/internal/service/shortcode_test.go @@ -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) + } + } +} diff --git a/backend/internal/service/words.go b/backend/internal/service/words.go new file mode 100644 index 0000000..61edadc --- /dev/null +++ b/backend/internal/service/words.go @@ -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", + } +) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a09b2e3 --- /dev/null +++ b/docker-compose.yml @@ -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//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": "demo@snip.to", "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: diff --git a/index.html b/index.html new file mode 100644 index 0000000..374254f --- /dev/null +++ b/index.html @@ -0,0 +1,30 @@ + + + + + + + + snip — A short link service + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..802282d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2592 @@ +{ + "name": "snip-shorturl", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "snip-shorturl", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e83710f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..0deb5dc --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/favicon_dark.svg b/public/favicon_dark.svg new file mode 100644 index 0000000..4f33075 --- /dev/null +++ b/public/favicon_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..1f89dd1 --- /dev/null +++ b/src/App.tsx @@ -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 ( + + {children} + + ); +} + +export default function App() { + const location = useLocation(); + + return ( +
+ {/* drifting decorative blobs */} +
+
+ +
+ +
+ + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + +
+ ); +} diff --git a/src/components/BarChart.tsx b/src/components/BarChart.tsx new file mode 100644 index 0000000..f8435e3 --- /dev/null +++ b/src/components/BarChart.tsx @@ -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(null); + const max = Math.max(1, ...data.map((d) => d.count)); + + return ( +
+ {data.map((d, i) => { + const ratio = d.count / max; + const isPeak = d.count === max && max > 0; + const active = hover === i; + return ( +
setHover(i)} + onMouseLeave={() => setHover(null)} + > + {/* tooltip */} + + {d.count} clicks + + +
+ +
+ + {weekdayLabel(d.date)} + +
+ ); + })} +
+ ); +} diff --git a/src/components/DeleteLinkModal.tsx b/src/components/DeleteLinkModal.tsx new file mode 100644 index 0000000..9c86dcc --- /dev/null +++ b/src/components/DeleteLinkModal.tsx @@ -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 ; + + async function confirm() { + if (!link) return; + setBusy(true); + try { + await api.deleteLink(link.id); + onDeleted(link.id, "Link deleted"); + } catch { + setBusy(false); + } + } + + return ( + +
+ + 🗑 + +
+

+ {shortUrl(link.code)} +

+

+ → {prettyHost(link.longUrl)} +

+
+
+ +

+ This permanently deletes the link. Anyone who opens{" "} + {shortUrl(link.code)} will + hit a dead end, and its{" "} + + {compactNumber(link.totalClicks)} clicks + {" "} + of history go with it. This can't be undone. +

+ +
+ + +
+
+ ); +} diff --git a/src/components/EditLinkModal.tsx b/src/components/EditLinkModal.tsx new file mode 100644 index 0000000..6a2b0f2 --- /dev/null +++ b/src/components/EditLinkModal.tsx @@ -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(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 ; + + 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 ( + + {/* Short code is permanent — shown read-only so existing shares keep working. */} +
+
+

+ Short link +

+

+ {shortUrl(link.code)} +

+
+ + can't change + +
+ + +
+ 🔗 + { + 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" + /> +
+

+ Where {shortUrl(link.code)}{" "} + sends visitors — currently {prettyHost(link.longUrl)}. +

+ + + {error && ( + + {error} + + )} + + +
+ + +
+
+ ); +} diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx new file mode 100644 index 0000000..fdeb18b --- /dev/null +++ b/src/components/Logo.tsx @@ -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 ( + + + + + + snip + . + + + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..d80c84b --- /dev/null +++ b/src/components/Navbar.tsx @@ -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 ( + +
+ + + +
+
+ ); +} diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..9865c80 --- /dev/null +++ b/src/components/Pagination.tsx @@ -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 ( +
+ + + {items.map((it, i) => + it === "…" ? ( + + … + + ) : ( + + ), + )} + + +
+ ); +} diff --git a/src/components/PinInput.tsx b/src/components/PinInput.tsx new file mode 100644 index 0000000..fcd8e6d --- /dev/null +++ b/src/components/PinInput.tsx @@ -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) { + 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) { + 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 ( +
+ {Array.from({ length }).map((_, i) => ( + (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" + /> + ))} +
+ ); +} diff --git a/src/components/PinManager.tsx b/src/components/PinManager.tsx new file mode 100644 index 0000000..260bfc9 --- /dev/null +++ b/src/components/PinManager.tsx @@ -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(null); + + // Reset transient state whenever a different link opens the modal. + useEffect(() => { + setDraft(""); + setError(null); + setBusy(null); + }, [link?.id]); + + if (!link) return ; + + 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 ( + +

+ {shortUrl(link.code)} —{" "} + {hasPin + ? "visitors must enter this PIN before being redirected." + : "add a 6-digit PIN that visitors enter before redirect."} +

+ + {/* Current state. The server stores PINs hashed, so an existing value can + never be shown — only replaced or removed. */} + {hasPin && ( +
+ + 🔒 + +
+

PIN is active

+

+ Stored encrypted — set a new one below to change it. +

+
+
+ )} + + + + + + {error && ( + + {error} + + )} + + +
+ + + {hasPin && ( + + )} +
+
+ ); +} diff --git a/src/components/QrModal.tsx b/src/components/QrModal.tsx new file mode 100644 index 0000000..7c82d38 --- /dev/null +++ b/src/components/QrModal.tsx @@ -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(null); + const [copied, setCopied] = useState(false); + + if (!link) return ; + + 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 ( + +
+
+ +
+ +

+ {shortUrl(link.code)} +

+

+ Points to {prettyHost(link.longUrl)} +

+ +
+ + +
+
+
+ ); +} diff --git a/src/components/ResultCard.tsx b/src/components/ResultCard.tsx new file mode 100644 index 0000000..4e2557c --- /dev/null +++ b/src/components/ResultCard.tsx @@ -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 = { + 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 ( + + {/* confetti-ish accent corner */} +
+ +
+ + ✓ + + Your link is live + + {modeBadge[link.mode]} + +
+ +
+
+
+

+ {prettyHost(link.longUrl)} +

+

+ {shortUrl(link.code)} +

+
+
+ + + {link.hasPin && ( + + 🔒 PIN protected + + )} +
+
+ + {showQr && ( + + + + )} +
+ + + + ); +} diff --git a/src/components/SegmentedControl.tsx b/src/components/SegmentedControl.tsx new file mode 100644 index 0000000..1ab00b0 --- /dev/null +++ b/src/components/SegmentedControl.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; + +export interface Segment { + value: T; + label: string; + icon?: ReactNode; + hint?: string; + locked?: boolean; +} + +interface Props { + segments: Segment[]; + value: T; + onChange: (value: T) => void; + layoutId?: string; +} + +export function SegmentedControl({ + segments, + value, + onChange, +}: Props) { + return ( +
+ {segments.map((seg) => { + const active = seg.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/ShortenForm.tsx b/src/components/ShortenForm.tsx new file mode 100644 index 0000000..5cc46bb --- /dev/null +++ b/src/components/ShortenForm.tsx @@ -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("random"); + const [alias, setAlias] = useState(""); + const [pinOn, setPinOn] = useState(false); + const [pin, setPin] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [errorInput, setErrorInput] = useState<"url" | "alias" | null>(null); + const [result, setResult] = useState(null); + + function showError(msg: string, input: "url" | "alias" | null = null) { + setError(msg); + setErrorInput(input); + } + function clearError() { + setError(null); + setErrorInput(null); + } + + const segments: Segment[] = [ + { 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 ( +
+ + {result ? ( + toast("Copied to clipboard", "success")} + /> + ) : ( + + {/* URL field */} + +
+ 🔗 + { + 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" + /> +
+ + {/* Mode selector */} +
+
+ + Link style + + + {getShortDomain()}/ + + {mode === "random" + ? "x7Qk" + : mode === "memorable" + ? "amber-otter-loop" + : alias || "your-name"} + + +
+ +
+ + {/* Custom alias */} + + {mode === "custom" && ( + +
+ + {getShortDomain()}/ + + { + 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" + /> +
+
+ )} +
+ + {/* Authenticated extras: PIN */} +
+ {user ? ( + <> + + + {pinOn && ( + +
+ +
+
+ )} +
+ + ) : ( +
+ 🔒 +

+ {" "} + to add a custom alias and PIN protection. +

+
+ )} +
+ + {/* Error */} + + {error && ( + + {error} + + )} + + + {/* Submit */} + +
+ )} +
+
+ ); +} diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx new file mode 100644 index 0000000..3ed1449 --- /dev/null +++ b/src/components/Sparkline.tsx @@ -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 ( + + + + {last && ( + + )} + + ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..f605cc7 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -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 ( + + + {dark ? "🌙" : "☀️"} + + + ); +} diff --git a/src/components/UrlCard.tsx b/src/components/UrlCard.tsx new file mode 100644 index 0000000..f29f2b9 --- /dev/null +++ b/src/components/UrlCard.tsx @@ -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 = { + 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 ( + + {/* Left: identity + actions */} +
+
+ + {modeLabel[link.mode]} + + {link.hasPin && ( + + 🔒 PIN + + )} + + {relativeTime(link.createdAt)} + +
+ + e.preventDefault()} + className="focusable mt-2 inline-block rounded font-mono text-lg font-bold text-ink hover:text-accent" + > + {shortUrl(link.code)} + + +

+ → {prettyHost(link.longUrl)} + {new URL(link.longUrl).pathname} +

+ +
+ + + + + +
+
+ + {/* Right: stats */} +
+
+
+

+ Total clicks +

+

+ {compactNumber(link.totalClicks)} +

+
+
+

+ Last 7 days +

+

+ + +{compactNumber(weekTotal)} + +

+
+
+ +
+
+ ); +} diff --git a/src/components/UrlRow.tsx b/src/components/UrlRow.tsx new file mode 100644 index 0000000..d843fbf --- /dev/null +++ b/src/components/UrlRow.tsx @@ -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 ( + + + + + + + + + + ); +} + +function IconButton({ + label, + onClick, + danger, + children, +}: { + label: string; + onClick: () => void; + danger?: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +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 ( + + {/* identity */} +
+ + {link.mode === "custom" ? "✏️" : link.mode === "memorable" ? "🌿" : "🎲"} + + +
+ + {/* stats */} +
+
+

+ {compactNumber(link.totalClicks)} +

+

+ clicks +

+
+ + +{compactNumber(weekTotal)} + +
+ +
+
+ + {/* actions */} +
+ + ⧉ + + onShowQr(link)}> + + + onManagePin(link)}> + 🔒 + + onEdit(link)}> + ✏️ + + onDelete(link)}> + 🗑 + +
+
+ ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..612e454 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -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, "children"> { + variant?: Variant; + size?: Size; + children: ReactNode; + block?: boolean; +} + +const sizes: Record = { + 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 = { + 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(function Button( + { variant = "primary", size = "md", block, className = "", children, ...rest }, + ref, +) { + return ( + + {children} + + ); +}); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..b204f18 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -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 ( + + {open && ( + + + + {title && ( +

+ {title} +

+ )} + {children} +
+
+ )} +
+ ); +} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..88f944c --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -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 = { + success: "✓", + error: "✕", + info: "→", +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + 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 ( + + {children} +
+ + {toasts.map((t) => ( + + + {icons[t.kind]} + + {t.message} + + ))} + +
+
+ ); +} + +export function useToast() { + return useContext(Ctx); +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..13176e0 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -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; + logout: () => Promise; +} + +const Ctx = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 0000000..82ca76e --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -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(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState(() => + 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 {children}; +} + +export function useTheme() { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..5e5941a --- /dev/null +++ b/src/index.css @@ -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"); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..e16f582 --- /dev/null +++ b/src/lib/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 { + try { + return await req("/auth/me"); + } catch (e) { + if (e instanceof ApiError && e.status === 401) return null; + throw e; + } + }, + + async providers(): Promise { + 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 { + await req("/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 { + const sp = new URLSearchParams({ + page: String(params.page), + pageSize: String(params.pageSize), + }); + if (params.q) sp.set("q", params.q); + return req(`/links?${sp.toString()}`); + }, + + /** Aggregate dashboard tiles, computed in the DB. */ + async stats(): Promise { + return req("/links/stats"); + }, + + async createLink(input: CreateLinkInput): Promise { + return req("/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 { + return req(`/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 { + if (pin === null) { + return req(`/links/${id}/pin`, { method: "DELETE" }); + } + return req(`/links/${id}/pin`, { + method: "PUT", + body: JSON.stringify({ pin }), + }); + }, + + async deleteLink(id: string): Promise { + await req(`/links/${id}`, { method: "DELETE" }); + }, +}; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..a069867 --- /dev/null +++ b/src/lib/format.ts @@ -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; + } +} diff --git a/src/lib/shortcode.ts b/src/lib/shortcode.ts new file mode 100644 index 0000000..c6af59b --- /dev/null +++ b/src/lib/shortcode.ts @@ -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)); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..55bf9a1 --- /dev/null +++ b/src/lib/types.ts @@ -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; +} diff --git a/src/lib/words.ts b/src/lib/words.ts new file mode 100644 index 0000000..1233ba8 --- /dev/null +++ b/src/lib/words.ts @@ -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(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function memorableSlug(): string { + return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${pick(VERBS)}`; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..8c4233b --- /dev/null +++ b/src/main.tsx @@ -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( + + + + + + + + + + + , + ); +} + +// 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); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..a7a5071 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -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 = { 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(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 ( +
+ {opts.map((o) => ( + + ))} +
+ ); +} + +export function Dashboard() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + const toast = useToast(); + + const [view, setView] = useState( + () => (localStorage.getItem("snip-view") as View) || "comfortable", + ); + const [searchParams, setSearchParams] = useSearchParams(); + const [pinTarget, setPinTarget] = useState(null); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [qrTarget, setQrTarget] = useState(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(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 ( +
+ {/* header */} + +
+

+ Your links +

+

+ Search, edit destinations, manage PINs, or retire a link. +

+
+ + + +
+ + {/* stat tiles */} +
+ {statTiles.map((s, i) => ( + +

+ {s.label} +

+

+ {s.accent ? ( + {s.value} + ) : ( + s.value + )} +

+
+ ))} +
+ + {/* toolbar: search + view toggle */} + {hasAnyLinks && ( +
+
+ + 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 && ( + + )} +
+ +
+ )} + + {/* result meta */} + {hasAnyLinks && ( +

+ {query ? ( + <> + {total} of {totalLinks}{" "} + {totalLinks === 1 ? "link" : "links"} match “{query}” + + ) : ( + <> + Showing {items.length} of{" "} + {totalLinks} {totalLinks === 1 ? "link" : "links"} + + )} +

+ )} + + {/* list */} +
+ {pageData === null ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : total === 0 && !debouncedQuery ? ( + + 🪄 +

No links yet

+

+ Create your first short link and it'll show up here with live click + stats. +

+ + + +
+ ) : total === 0 ? ( + + 🔍 +

No matches

+

Nothing matches “{query}”.

+ +
+ ) : ( + <> + + + {items.map((link) => + view === "compact" ? ( + toast("Copied to clipboard", "success")} + onManagePin={setPinTarget} + onShowQr={setQrTarget} + /> + ) : ( + toast("Copied to clipboard", "success")} + onManagePin={setPinTarget} + onShowQr={setQrTarget} + /> + ), + )} + + + + + + )} +
+ + setEditTarget(null)} + onSaved={(updated, message) => { + applyUpdate(updated, message); + setEditTarget(null); + }} + /> + + setPinTarget(null)} + onSaved={(updated, message) => { + applyUpdate(updated, message); + setPinTarget(null); + }} + /> + + setQrTarget(null)} + onCopied={() => toast("Copied to clipboard", "success")} + /> + + setDeleteTarget(null)} + onDeleted={handleDeleted} + /> +
+ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..2caf2e9 --- /dev/null +++ b/src/pages/Home.tsx @@ -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 ( +
+ + {/* eyebrow */} + + + + + + + {user ? `Welcome back, ${user.name.split(" ")[0]}` : "No account needed to start"} + + + + {/* headline */} +

+ + Long links, + + + made{" "} + + tiny + + + . + +

+ + + Paste a clunky URL and get a clean, shareable link in a tap — with + your choice of code, optional PIN, and click stats. + + + {/* form */} + + + + + {/* features */} + + {features.map((f) => ( + + {f.icon} +

{f.title}

+

+ {f.text} +

+
+ ))} +
+
+
+ ); +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..2c40937 --- /dev/null +++ b/src/pages/Login.tsx @@ -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 = { + 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(null); + + useEffect(() => { + if (!loading && user) navigate("/dashboard"); + }, [loading, user, navigate]); + + useEffect(() => { + api.providers().then(setProviders); + }, []); + + return ( +
+ + + + + + + +

+ Welcome to snip +

+

+ Sign in to manage your links, lock them with a PIN, and watch the + clicks roll in. +

+ +
+ {providers === null ? ( + <> +
+
+ + ) : providers.length === 0 ? ( +
+ No login providers are configured on the server yet. Set{" "} + GOOGLE_* or{" "} + OIDC_* env vars to + enable sign-in. +
+ ) : ( + providers.map((p, i) => { + const meta = providerMeta(p); + return ( + + + + ); + }) + )} +
+ +

+ You can still create random & memorable links without an account — + sign in only to customize, protect, and track them. +

+ +
+ ); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..70c138a --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE?: string; + readonly VITE_SHORT_DOMAIN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..11b869c --- /dev/null +++ b/tailwind.config.js @@ -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: [], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1e64b0a --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..c983dd7 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2adc331 --- /dev/null +++ b/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 }; + +// 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, + }, + }, + }, +});