From dda65d8f258b4438f482fa2cfcbc695d5a8593be Mon Sep 17 00:00:00 2001 From: yinebebt Date: Sun, 14 Jun 2026 16:59:08 +0300 Subject: [PATCH] feat: link expiry, click analytics, and pluggable store backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - per-link TTL (expires_at), honored by all stores - analytics folded into Store: platform/referrer/day at /stats - default template handles platform store fallback - Redis + SQLite stores as subpackages; core down to one dependency - env-selectable cmd store (default sqlite) + redis→sqlite migrate tool - web dashboard: TTL field + analytics view; CI: web build job --- .env.example | 6 + .github/workflows/ci.yml | 20 ++ .gitignore | 4 + README.md | 165 +++++++------- click.go | 51 +++-- click_test.go | 20 +- cmd/deeplink/main.go | 68 ++++-- cmd/migrate/main.go | 88 ++++++++ feature_test.go | 280 +++++++++++++++++++++++ go.mod | 10 + go.sum | 51 +++++ memory_store.go | 95 +++++++- model.go | 44 +++- preview.go | 9 + redirect.go | 9 +- redis_store.go | 167 -------------- redisstore/redis.go | 277 +++++++++++++++++++++++ redisstore/redis_test.go | 26 +++ server.go | 49 +++- sqlitestore/sqlite.go | 360 ++++++++++++++++++++++++++++++ sqlitestore/sqlite_test.go | 221 ++++++++++++++++++ store.go | 15 +- templates/default/dashboard.html | 246 -------------------- templates/default/link.html | 26 ++- url.go | 25 ++- useragent.go | 115 ++++++++++ web/src/api.ts | 6 +- web/src/components/LinkDetail.tsx | 60 ++++- web/src/components/LinkForm.tsx | 27 +++ web/src/styles.css | 63 ++++++ web/src/types.ts | 9 + web/vite.config.ts | 2 + 32 files changed, 2030 insertions(+), 584 deletions(-) create mode 100644 cmd/migrate/main.go create mode 100644 feature_test.go delete mode 100644 redis_store.go create mode 100644 redisstore/redis.go create mode 100644 redisstore/redis_test.go create mode 100644 sqlitestore/sqlite.go create mode 100644 sqlitestore/sqlite_test.go delete mode 100644 templates/default/dashboard.html create mode 100644 useragent.go diff --git a/.env.example b/.env.example index bf684d7..0d8f833 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ DEEPLINK_LISTEN_ADDR=:8090 DEEPLINK_BASE_URL=http://localhost:8090 + +# Store backend: sqlite (default, persistent, zero-infra), redis, or memory. +DEEPLINK_STORE=sqlite +DEEPLINK_SQLITE_DSN=deeplink.db + +# Only used when DEEPLINK_STORE=redis. DEEPLINK_REDIS_ADDR=localhost:6379 DEEPLINK_REDIS_PASSWORD= # Comma-separated origins allowed to call the JSON API from a browser. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7167654..53184ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,23 @@ jobs: - name: Test run: go test -race -count=1 ./... + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + run: npm ci + + - name: Build (typecheck + bundle) + run: npm run build diff --git a/.gitignore b/.gitignore index bb448bb..2c9aef3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ seed.sh /web/node_modules /web/dist /web/*.log +.env*.local +*.db +*.db-shm +*.db-wal diff --git a/README.md b/README.md index 9529aa6..0ba5199 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # deeplink -Short link generation, click tracking, and OG preview pages for Go. -Pluggable processors, Redis or in-memory storage, two dependencies. +Short link generation, click analytics, link expiry, and OG preview pages for Go. +Pluggable processors; in-memory, Redis, or SQLite storage; one dependency in the core. [![CI](https://github.com/yinebebt/deeplink/actions/workflows/ci.yml/badge.svg)](https://github.com/yinebebt/deeplink/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/yinebebt/deeplink.svg)](https://pkg.go.dev/github.com/yinebebt/deeplink) @@ -74,6 +74,62 @@ docker compose up -d go run ./cmd/deeplink ``` +## Stores + +Any `Store` implementation works; the destination determines persistence. +All built-in stores carry the full feature set — link expiry and click +analytics included: + +| Store | Import | Persistent | Analytics storage | +| --- | --- | --- | --- | +| `deeplink.NewMemoryStore()` | core | no | aggregate maps | +| `redisstore.New(client)` | `deeplink/redisstore` | yes | aggregate hashes (HINCRBY) | +| `sqlitestore.New(dsn)` | `deeplink/sqlitestore` | yes | per-visit event rows | + +```go +import "github.com/yinebebt/deeplink/sqlitestore" + +store, err := sqlitestore.New("deeplink.db") +``` + +The Redis and SQLite stores live in subpackages, so their drivers +(`go-redis`, `modernc.org/sqlite`) are only pulled in when you import them — +the core stays at a single dependency (`go-nanoid`). Need another backend? +Implement the [`Store`](https://pkg.go.dev/github.com/yinebebt/deeplink#Store) +interface, including the two analytics methods (`RecordEvents` and `Stats`). + +## Link expiry + +Set `expires_at` (RFC 3339, must be in the future) to make a link +self-destruct. After it passes, the link resolves as `404` and stores purge +it (Redis via a native TTL, others lazily on read): + +```bash +curl -X POST http://localhost:8090/shorten \ + -H 'Content-Type: application/json' \ + -d '{"type":"redirect","url":"https://example.com","expires_at":"2026-12-31T23:59:59Z"}' +``` + +Omit `expires_at` for a link that never expires. + +## Click analytics + +Every resolved visit is captured off the redirect path and flushed in the +background — bumping the click counter and recording the event for +breakdowns, which every store supports. Query them at `GET /stats/{shortID}`: + +```json +{ + "short_id": "aBcD…", + "clicks": 1280, + "by_platform": {"android": 700, "ios": 500, "web": 80}, + "by_referrer": {"t.co": 410, "facebook.com": 260}, + "by_day": {"2026-06-13": 640, "2026-06-14": 640} +} +``` + +Platform, device, browser, OS, and referrer are derived from the request. + ## HTTP routes | Method | Path | Description | @@ -85,6 +141,7 @@ go run ./cmd/deeplink | GET | `/links` | All links across types (dashboard data source) | | GET | `/links/{type}` | List links by type | | GET | `/links/{type}/{shortID}` | Link detail with click count | +| GET | `/stats/{shortID}` | Click analytics breakdowns | | GET | `/health` | Health check | When any store URL is set (`AndroidStoreURL`, `IOSStoreURL`, `WebFallbackURL`), these are also registered: @@ -100,53 +157,28 @@ and `assetlinks.json` files in `/.well-known/`. ## Configuration -Environment variables for `cmd/deeplink`: - -| Variable | Default | Description | -| --- | --- | --- | -| `DEEPLINK_LISTEN_ADDR` | `:8090` | Listen address | -| `DEEPLINK_BASE_URL` | `http://localhost:8090/` | Base URL for short links | -| `DEEPLINK_REDIS_ADDR` | `localhost:6379` | Redis address | -| `DEEPLINK_REDIS_PASSWORD` | | Redis password | -| `DEEPLINK_ALLOWED_ORIGINS` | | CORS origins (comma-separated) | -| `DEEPLINK_TEMPLATE_DIR` | `templates/default` | Template directory | -| `DEEPLINK_SKIP_PATHS_FILE` | | Skip-path regex file | -| `DEEPLINK_CLICK_BUFFER_SIZE` | `1024` | Async click event buffer capacity | -| `DEEPLINK_CLICK_FLUSH_INTERVAL` | `1s` | How often buffered clicks are flushed to the store | -| `DEEPLINK_API_KEY` | | Protect mutating endpoints (`Authorization: Bearer ` or `X-API-Key: `) | -| `DEEPLINK_SITE_NAME` | | `og:site_name` on every preview | -| `DEEPLINK_LOCALE` | `en_US` | Default `og:locale` (per-link `locale` overrides) | -| `DEEPLINK_TWITTER_SITE` | | `twitter:site` (e.g. `@example`) | -| `DEEPLINK_FEDIVERSE_CREATOR` | | `fediverse:creator` (e.g. `@user@instance.tld`) | +`cmd/deeplink` reads its configuration from environment variables — see +[`.env.example`](.env.example) for the full list with defaults. Setting +`DEEPLINK_API_KEY` enables auth on the mutating endpoints, sent as +`Authorization: Bearer ` or `X-API-Key`. ## Templates -The default templates in `templates/default/` use these fields from `Link`: +The default templates live in `templates/default/`; copy them and point +`TemplateDir` at the copy to customize. They render the +[preview metadata](#link-preview-metadata) fields plus `{{.ShortURL}}`, +`{{.Lang}}`, and the store-fallback URLs (`{{.AndroidStoreURL}}`, +`{{.IOSStoreURL}}`, `{{.WebFallbackURL}}`). -| Field | Template variable | Used for | -| --- | --- | --- | -| URL | `{{.URL}}` | Redirect target | -| Title | `{{.Title}}` | Page title, og:title, twitter:title | -| Description | `{{.Description}}` | og:description, twitter:description | -| ImageURL | `{{.ImageURL}}` | og:image, twitter:image | -| ImageWidth | `{{.ImageWidth}}` | og:image:width | -| ImageHeight | `{{.ImageHeight}}` | og:image:height | -| ImageAlt | `{{.ImageAlt}}` | og:image:alt, twitter:image:alt | -| OGType | `{{.OGType}}` | og:type (defaults to `website`) | -| Locale | `{{.Locale}}` | og:locale (per-link override) | -| Lang | `{{.Lang}}` | `` (derived from Locale) | -| CreatedAt | `{{.CreatedAt}}` | article:published_time | -| UpdatedAt | `{{.UpdatedAt}}` | og:updated_time, article:modified_time | -| ShortURL | `{{.ShortURL}}` | canonical link, og:url | - -To customize, copy `templates/default/` and set `TemplateDir` in config. +The default `link.html` tries the destination first (so an installed app +opens via its Universal/App Link) and, after a short timeout, falls back to +the right app store by platform — with no store URLs it just forwards to the +destination. ## Link preview metadata -Preview pages emit standard Open Graph, Twitter Card, and fediverse meta -tags consumed by every platform that scrapes link previews. Every tag is -gated on its source value: empty fields are not emitted, so scrapers never -see `content=""` warnings. +Preview pages emit Open Graph, Twitter Card, and fediverse tags. Empty fields +are omitted, so scrapers never see `content=""`. ### Per-link fields (`Link`) @@ -170,54 +202,21 @@ see `content=""` warnings. | `TwitterSite` | `twitter:site` (e.g. `@example`) | | `FediverseCreator` | `fediverse:creator` (e.g. `@user@instance.tld`) | -### Example payload - -```bash -curl -X POST http://localhost:8090/shorten \ - -H 'Content-Type: application/json' \ - -d '{ - "type": "redirect", - "url": "https://example.com/posts/launch", - "title": "We just launched", - "description": "What is new in v2", - "image_url": "https://cdn.example.com/og/launch.png", - "image_width": 1200, - "image_height": 630, - "image_alt": "v2 launch cover", - "og_type": "article" - }' -``` - -Preview pages also emit `` so -short links do not compete with the destination URL in search rankings. +Pages also emit `robots: noindex,follow` so short links don't compete with +the destination URL in search. ## Dashboard -A standalone React + TypeScript dashboard lives in [`web/`](web/). It -talks to the Go service over HTTP and shares the repo's root `.env`. +A React + TypeScript dashboard lives in [`web/`](web/), configured by +`VITE_API_URL` (see [`.env.example`](.env.example)). ```bash -cd web -npm install -npm run dev # http://localhost:5173 +cd web && npm install && npm run dev # http://localhost:5173 ``` -One env var configures it (in root `.env`): - -``` -VITE_API_URL=http://localhost:8091 -``` - -- `npm run dev` uses it as the Vite proxy target only — the browser hits - `:5173`, so there is no CORS and the scheme is optional. -- `npm run build` bakes it into the bundle as an absolute base, so the - static dist can be hosted on any origin. Scheme required. - -The dashboard reads the API key from `localStorage` (`deeplink.apiKey`) -and sends it as `X-API-Key` on mutating requests, so set -`DEEPLINK_API_KEY` server-side to enable enforcement. When the SPA runs -on a different origin in production, add that origin to -`DEEPLINK_ALLOWED_ORIGINS`. +It reads the API key from `localStorage` (`deeplink.apiKey`) and sends it as +`X-API-Key`, so set `DEEPLINK_API_KEY` to enforce auth. For a cross-origin +deploy, add the origin to `DEEPLINK_ALLOWED_ORIGINS`. ## Development diff --git a/click.go b/click.go index fdfa7b8..1532052 100644 --- a/click.go +++ b/click.go @@ -7,12 +7,13 @@ import ( "time" ) -// clickTracker buffers click events and flushes them to the store -// in the background, keeping the redirect path fast. +// clickTracker buffers click events and flushes them to the store in the +// background, keeping the redirect path fast. Each flush bumps the per-link +// click counter and records the raw events for analytics breakdowns. type clickTracker struct { store Store logger *slog.Logger - events chan string + events chan ClickEvent interval time.Duration done chan struct{} wg sync.WaitGroup @@ -22,7 +23,7 @@ func newClickTracker(store Store, logger *slog.Logger, bufSize int, interval tim ct := &clickTracker{ store: store, logger: logger, - events: make(chan string, bufSize), + events: make(chan ClickEvent, bufSize), interval: interval, done: make(chan struct{}), } @@ -33,11 +34,11 @@ func newClickTracker(store Store, logger *slog.Logger, bufSize int, interval tim // track enqueues a click event. If the buffer is full the event is dropped // and a warning is logged — the redirect is never blocked. -func (ct *clickTracker) track(shortID string) { +func (ct *clickTracker) track(ev ClickEvent) { select { - case ct.events <- shortID: + case ct.events <- ev: default: - ct.logger.Warn("click buffer full, dropping event", "shortID", shortID) + ct.logger.Warn("click buffer full, dropping event", "shortID", ev.ShortID) } } @@ -47,7 +48,7 @@ func (ct *clickTracker) stop() { ct.wg.Wait() } -// run is the background loop. It collects IDs into a batch and flushes +// run is the background loop. It collects events into a batch and flushes // either when the batch interval fires or when stop is called. func (ct *clickTracker) run() { defer ct.wg.Done() @@ -55,23 +56,23 @@ func (ct *clickTracker) run() { ticker := time.NewTicker(ct.interval) defer ticker.Stop() - batch := make(map[string]int64) + batch := make([]ClickEvent, 0, 64) for { select { - case id := <-ct.events: - batch[id]++ + case ev := <-ct.events: + batch = append(batch, ev) case <-ticker.C: ct.flush(batch) - batch = make(map[string]int64) + batch = batch[:0] case <-ct.done: // Drain remaining events from the channel. for { select { - case id := <-ct.events: - batch[id]++ + case ev := <-ct.events: + batch = append(batch, ev) default: ct.flush(batch) return @@ -81,8 +82,10 @@ func (ct *clickTracker) run() { } } -// flush writes accumulated counts to the store. -func (ct *clickTracker) flush(batch map[string]int64) { +// flush bumps the counter once per link (aggregating the batch), then records +// the events for breakdowns. The two writes are independent, so breakdown +// sums may drift from the click total — treat breakdowns as approximate. +func (ct *clickTracker) flush(batch []ClickEvent) { if len(batch) == 0 { return } @@ -90,11 +93,17 @@ func (ct *clickTracker) flush(batch map[string]int64) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - for id, count := range batch { - for range count { - if _, err := ct.store.IncrClick(ctx, id); err != nil { - ct.logger.Warn("failed to flush click", "error", err, "shortID", id) - } + counts := make(map[string]int64, len(batch)) + for _, ev := range batch { + counts[ev.ShortID]++ + } + for id, count := range counts { + if _, err := ct.store.IncrClickBy(ctx, id, count); err != nil { + ct.logger.Warn("failed to flush clicks", "error", err, "shortID", id, "count", count) } } + + if err := ct.store.RecordEvents(ctx, batch); err != nil { + ct.logger.Warn("failed to record click events", "error", err, "count", len(batch)) + } } diff --git a/click_test.go b/click_test.go index e100960..ed60358 100644 --- a/click_test.go +++ b/click_test.go @@ -11,14 +11,14 @@ func TestClickTrackerFlushesOnStop(t *testing.T) { t.Parallel() store := NewMemoryStore() - // Save a link so IncrClick has something to increment. + // Save a link so the click flush has something to increment. _ = store.Save(context.Background(), "abc", &Link{Type: "basic", URL: "https://example.com"}) ct := newClickTracker(store, slog.Default(), 64, 10*time.Second) - ct.track("abc") - ct.track("abc") - ct.track("abc") + ct.track(ClickEvent{ShortID: "abc"}) + ct.track(ClickEvent{ShortID: "abc"}) + ct.track(ClickEvent{ShortID: "abc"}) // Stop drains the buffer. ct.stop() @@ -41,8 +41,8 @@ func TestClickTrackerFlushesOnInterval(t *testing.T) { ct := newClickTracker(store, slog.Default(), 64, 50*time.Millisecond) defer ct.stop() - ct.track("xyz") - ct.track("xyz") + ct.track(ClickEvent{ShortID: "xyz"}) + ct.track(ClickEvent{ShortID: "xyz"}) // Wait for at least one flush cycle. time.Sleep(150 * time.Millisecond) @@ -63,12 +63,12 @@ func TestClickTrackerDropsWhenBufferFull(t *testing.T) { // fills deterministically and the third send hits the drop path. ct := &clickTracker{ logger: slog.Default(), - events: make(chan string, 2), + events: make(chan ClickEvent, 2), } - ct.track("full") - ct.track("full") - ct.track("full") // dropped + ct.track(ClickEvent{ShortID: "full"}) + ct.track(ClickEvent{ShortID: "full"}) + ct.track(ClickEvent{ShortID: "full"}) // dropped if got := len(ct.events); got != 2 { t.Fatalf("buffered events = %d, want 2 (third should be dropped)", got) diff --git a/cmd/deeplink/main.go b/cmd/deeplink/main.go index 470a790..125cf0f 100644 --- a/cmd/deeplink/main.go +++ b/cmd/deeplink/main.go @@ -18,6 +18,8 @@ import ( "github.com/redis/go-redis/v9" "github.com/yinebebt/deeplink" + "github.com/yinebebt/deeplink/redisstore" + "github.com/yinebebt/deeplink/sqlitestore" ) func main() { @@ -38,35 +40,21 @@ func run() error { return err } - redisAddr := env("DEEPLINK_REDIS_ADDR", "localhost:6379") - redisPassword := os.Getenv("DEEPLINK_REDIS_PASSWORD") - - redisClient := redis.NewClient(&redis.Options{ - Addr: redisAddr, - Password: redisPassword, - DB: 0, - }) - defer func() { - if err := redisClient.Close(); err != nil { - logger.Warn("failed to close redis client", "error", err) - } - }() - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if err := redisClient.Ping(pingCtx).Err(); err != nil { - return fmt.Errorf("ping redis: %w", err) + store, closeStore, err := buildStore(ctx, logger) + if err != nil { + return err } + defer closeStore() clickBufSize, _ := strconv.Atoi(env("DEEPLINK_CLICK_BUFFER_SIZE", "0")) clickFlushInterval, _ := time.ParseDuration(env("DEEPLINK_CLICK_FLUSH_INTERVAL", "0")) cfg := deeplink.Config{ BaseURL: baseURL, - Store: deeplink.NewRedisStore(redisClient), + Store: store, Logger: logger, TemplateDir: templateDir, SkipPaths: skipPaths, @@ -135,6 +123,48 @@ func run() error { return nil } +// buildStore constructs the store named by DEEPLINK_STORE (default "sqlite"), +// returning it with a cleanup func. SQLite is persistent and zero-infra; +// redis suits multi-instance setups; memory is non-persistent (dev/tests). +func buildStore(ctx context.Context, logger *slog.Logger) (deeplink.Store, func(), error) { + switch backend := env("DEEPLINK_STORE", "sqlite"); backend { + case "sqlite": + dsn := env("DEEPLINK_SQLITE_DSN", "deeplink.db") + store, err := sqlitestore.New(dsn) //nolint:contextcheck // schema init is a one-shot at startup + if err != nil { + return nil, nil, fmt.Errorf("open sqlite store: %w", err) + } + logger.Info("using sqlite store", "dsn", dsn) + return store, func() { _ = store.Close() }, nil + + case "redis": + client := redis.NewClient(&redis.Options{ + Addr: env("DEEPLINK_REDIS_ADDR", "localhost:6379"), + Password: os.Getenv("DEEPLINK_REDIS_PASSWORD"), + DB: 0, + }) + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := client.Ping(pingCtx).Err(); err != nil { + _ = client.Close() + return nil, nil, fmt.Errorf("ping redis: %w", err) + } + logger.Info("using redis store") + return redisstore.New(client), func() { + if err := client.Close(); err != nil { + logger.Warn("failed to close redis client", "error", err) + } + }, nil + + case "memory": + logger.Warn("using in-memory store: links do not survive restart") + return deeplink.NewMemoryStore(), func() {}, nil + + default: + return nil, nil, fmt.Errorf("unknown DEEPLINK_STORE %q (want sqlite, redis, or memory)", backend) + } +} + func newLogger() *slog.Logger { level := slog.LevelInfo if strings.EqualFold(os.Getenv("DEBUG"), "true") { diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..4d45fa4 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,88 @@ +// Command migrate imports a deeplink NDJSON backup into a SQLite store. +// Each input line is one record: {"id":"..","clicks":N,"payload":{}}. +// Used to move Redis-era link data onto the SQLite backend; idempotent +// (re-running upserts the same IDs). +package main + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/yinebebt/deeplink" + "github.com/yinebebt/deeplink/sqlitestore" +) + +type record struct { + ID string `json:"id"` + Clicks int64 `json:"clicks"` + Payload deeplink.Link `json:"payload"` +} + +func main() { + in := flag.String("in", "", "NDJSON backup file (one record per line)") + dsn := flag.String("out", "deeplink.db", "SQLite DSN / file path") + flag.Parse() + + if *in == "" { + fmt.Fprintln(os.Stderr, "usage: migrate -in backup.ndjson -out deeplink.db") + os.Exit(2) + } + if err := run(*in, *dsn); err != nil { + fmt.Fprintln(os.Stderr, "migrate:", err) + os.Exit(1) + } +} + +func run(in, dsn string) error { + f, err := os.Open(in) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + store, err := sqlitestore.New(dsn) + if err != nil { + return err + } + defer func() { _ = store.Close() }() + + ctx := context.Background() + var links, clicks int + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var rec record + if err := json.Unmarshal(line, &rec); err != nil { + return fmt.Errorf("parse line %d: %w", links+1, err) + } + id := rec.ID + if id == "" { + id = rec.Payload.ShortID + } + if err := store.Save(ctx, id, &rec.Payload); err != nil { + return fmt.Errorf("save %s: %w", id, err) + } + if rec.Clicks > 0 { + if _, err := store.IncrClickBy(ctx, id, rec.Clicks); err != nil { + return fmt.Errorf("clicks %s: %w", id, err) + } + clicks += int(rec.Clicks) + } + links++ + } + if err := sc.Err(); err != nil { + return err + } + + fmt.Printf("migrated %d links, %d total clicks -> %s\n", links, clicks, dsn) + return nil +} diff --git a/feature_test.go b/feature_test.go new file mode 100644 index 0000000..69cd1b7 --- /dev/null +++ b/feature_test.go @@ -0,0 +1,280 @@ +package deeplink + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestMemoryStore_ExpiryEviction(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := NewMemoryStore() + + past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + if err := store.Save(ctx, "gone", &Link{Type: "redirect", URL: "https://e.com", ExpiresAt: past}); err != nil { + t.Fatalf("Save() error = %v", err) + } + if _, err := store.Get(ctx, "gone"); err != ErrNotFound { + t.Fatalf("Get(expired) error = %v, want ErrNotFound", err) + } + + future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + if err := store.Save(ctx, "live", &Link{Type: "redirect", URL: "https://e.com", ExpiresAt: future}); err != nil { + t.Fatalf("Save() error = %v", err) + } + if _, err := store.Get(ctx, "live"); err != nil { + t.Fatalf("Get(live) error = %v, want nil", err) + } + + // Expired links must not surface in List either. + links, _, err := store.List(ctx, "redirect", 0, 100) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(links) != 1 || links[0].ShortLink != "live" { + t.Fatalf("List() = %+v, want only [live]", links) + } +} + +func TestShorten_RejectsBadExpiry(t *testing.T) { + t.Parallel() + svc := newPreviewService(t, Config{}) + + cases := map[string]string{ + "unparseable": `{"type":"redirect","url":"https://e.com","expires_at":"not-a-date"}`, + "in the past": fmt.Sprintf(`{"type":"redirect","url":"https://e.com","expires_at":%q}`, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)), + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/shorten", strings.NewReader(body)) + rec := httptest.NewRecorder() + svc.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + }) + } +} + +func TestLinkTemplate_PlatformFallback(t *testing.T) { + t.Parallel() + svc := newPreviewService(t, Config{ + AndroidStoreURL: "https://play.google.com/store/apps/details?id=com.example", + IOSStoreURL: "https://apps.apple.com/app/id123", + WebFallbackURL: "https://example.com/get", + }) + + rec, _ := createAndFetch(t, svc, `{"type":"redirect","url":"https://example.com/deep"}`, "/") + body := rec.Body.String() + + // html/template JS-escapes "/" as "\/" in the script context, so assert + // on the (slash-free) hosts rather than full URLs. + for _, want := range []string{"setTimeout", "play.google.com", "apps.apple.com"} { + if !strings.Contains(body, want) { + t.Fatalf("template body missing %q\n%s", want, body) + } + } +} + +func TestLinkTemplate_NoStoreURLs_PlainForward(t *testing.T) { + t.Parallel() + svc := newPreviewService(t, Config{}) // no store URLs + + rec, _ := createAndFetch(t, svc, `{"type":"redirect","url":"https://example.com/deep"}`, "/") + body := rec.Body.String() + + // The setTimeout is static script text gated at runtime by (fallback !== + // dest); with no store URLs the template vars render empty, so the + // fallback equals the destination and the timer never arms. + for _, want := range []string{`const android = "";`, `const ios = "";`, `const web = "";`} { + if !strings.Contains(body, want) { + t.Fatalf("expected empty store var %q\n%s", want, body) + } + } + if !strings.Contains(body, "example.com") { + t.Fatalf("expected destination host in body\n%s", body) + } +} + +func TestNewClickEvent(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/abc", nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile") + req.Header.Set("Referer", "https://t.co/xyz") + + ev := newClickEvent(req, "abc") + if ev.ShortID != "abc" { + t.Fatalf("ShortID = %q", ev.ShortID) + } + if ev.Platform != "android" || ev.Device != "mobile" { + t.Fatalf("platform/device = %q/%q, want android/mobile", ev.Platform, ev.Device) + } + if ev.Browser != "Chrome" { + t.Fatalf("browser = %q, want Chrome", ev.Browser) + } + if ev.Referrer != "t.co" { + t.Fatalf("referrer = %q, want t.co", ev.Referrer) + } +} + +func TestMemoryStore_Analytics(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := NewMemoryStore() + + day := time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) + events := []ClickEvent{ + {ShortID: "a", Time: day, Platform: "android", Referrer: "t.co"}, + {ShortID: "a", Time: day, Platform: "ios", Referrer: ""}, + {ShortID: "a", Time: day, Platform: "android", Referrer: "t.co"}, + } + if err := store.RecordEvents(ctx, events); err != nil { + t.Fatalf("RecordEvents() error = %v", err) + } + + stats, err := store.Stats(ctx, "a") + if err != nil { + t.Fatalf("Stats() error = %v", err) + } + if stats.ByPlatform["android"] != 2 || stats.ByPlatform["ios"] != 1 { + t.Fatalf("by_platform = %v", stats.ByPlatform) + } + // Empty referrer must not become a "" bucket. + if _, ok := stats.ByReferrer[""]; ok { + t.Fatalf("by_referrer has empty key: %v", stats.ByReferrer) + } + if stats.ByDay["2026-06-14"] != 3 { + t.Fatalf("by_day = %v", stats.ByDay) + } +} + +func TestStatsEndpoint(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := NewMemoryStore() + if err := store.Save(ctx, "abc", &Link{Type: "redirect", URL: "https://e.com"}); err != nil { + t.Fatalf("Save() error = %v", err) + } + if err := store.RecordEvents(ctx, []ClickEvent{{ShortID: "abc", Time: time.Now().UTC(), Platform: "web"}}); err != nil { + t.Fatalf("RecordEvents() error = %v", err) + } + + svc := newPreviewService(t, Config{Store: store}) + + req := httptest.NewRequest(http.MethodGet, "/stats/abc", nil) + rec := httptest.NewRecorder() + svc.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body = %s", rec.Code, rec.Body.String()) + } + + var stats Stats + if err := json.NewDecoder(rec.Body).Decode(&stats); err != nil { + t.Fatalf("decode stats: %v", err) + } + if stats.ByPlatform["web"] != 1 { + t.Fatalf("by_platform = %v", stats.ByPlatform) + } +} + +// TestVisitPipeline_EndToEnd exercises the whole async path: a real resolve +// request → UA parse → click event → background flush → counter + analytics +// → /stats, which no other test covers together. +func TestVisitPipeline_EndToEnd(t *testing.T) { + t.Parallel() + store := NewMemoryStore() + svc := newPreviewService(t, Config{Store: store, ClickFlushInterval: 20 * time.Millisecond}) + + req := httptest.NewRequest(http.MethodPost, "/shorten", strings.NewReader(`{"type":"redirect","url":"https://example.com/x"}`)) + rec := httptest.NewRecorder() + svc.Handler().ServeHTTP(rec, req) + var resp map[string]string + _ = json.NewDecoder(rec.Body).Decode(&resp) + id := strings.TrimPrefix(resp["short_url"], svc.config.BaseURL) + + // Two visits from an Android browser referred by t.co. + for range 2 { + v := httptest.NewRequest(http.MethodGet, "/"+id, nil) + v.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile") + v.Header.Set("Referer", "https://t.co/abc") + svc.Handler().ServeHTTP(httptest.NewRecorder(), v) + } + + // Wait for at least one flush cycle. + deadline := time.Now().Add(2 * time.Second) + var stats Stats + for time.Now().Before(deadline) { + sr := httptest.NewRequest(http.MethodGet, "/stats/"+id, nil) + srec := httptest.NewRecorder() + svc.Handler().ServeHTTP(srec, sr) + _ = json.NewDecoder(srec.Body).Decode(&stats) + if stats.Clicks >= 2 { + break + } + time.Sleep(20 * time.Millisecond) + } + + if stats.Clicks != 2 { + t.Fatalf("clicks = %d, want 2", stats.Clicks) + } + if stats.ByPlatform["android"] != 2 { + t.Fatalf("by_platform = %v, want android:2", stats.ByPlatform) + } + if stats.ByReferrer["t.co"] != 2 { + t.Fatalf("by_referrer = %v, want t.co:2", stats.ByReferrer) + } +} + +func TestParseUserAgent(t *testing.T) { + t.Parallel() + cases := []struct { + name string + ua string + platform, device, os, browser string + }{ + {"empty", "", platformWeb, deviceDesktop, "", ""}, + {"android chrome", "Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile", platformAndroid, deviceMobile, "Android", "Chrome"}, + {"iphone safari", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Version/17.0 Mobile Safari", platformIOS, deviceMobile, "iOS", "Safari"}, + {"ipad", "Mozilla/5.0 (iPad; CPU OS 17_0) Mobile", platformIOS, deviceMobile, "iPadOS", ""}, + {"twitterbot", "Twitterbot/1.0", platformWeb, deviceBot, "", ""}, + {"facebook crawler", "facebookexternalhit/1.1", platformWeb, deviceBot, "", ""}, + {"edge before chrome", "Mozilla/5.0 (Windows NT 10.0) Chrome/120 Edg/120", platformWeb, deviceDesktop, "Windows", "Edge"}, + {"opera", "Mozilla/5.0 (Windows NT 10.0) Chrome/120 OPR/106", platformWeb, deviceDesktop, "Windows", "Opera"}, + {"firefox mac", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15) Firefox/121", platformWeb, deviceDesktop, "macOS", "Firefox"}, + {"chrome on ios (crios)", "Mozilla/5.0 (iPhone) CriOS/120 Mobile", platformIOS, deviceMobile, "iOS", "Chrome"}, + {"firefox on ios (fxios)", "Mozilla/5.0 (iPhone) FxiOS/121 Mobile Safari", platformIOS, deviceMobile, "iOS", "Firefox"}, + {"edge on android (edga)", "Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile EdgA/120", platformAndroid, deviceMobile, "Android", "Edge"}, + {"linux desktop", "Mozilla/5.0 (X11; Linux x86_64) Chrome/120", platformWeb, deviceDesktop, "Linux", "Chrome"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, d, o, b := parseUserAgent(tc.ua) + if p != tc.platform || d != tc.device || o != tc.os || b != tc.browser { + t.Fatalf("parseUserAgent(%q) = (%q,%q,%q,%q), want (%q,%q,%q,%q)", + tc.ua, p, d, o, b, tc.platform, tc.device, tc.os, tc.browser) + } + }) + } +} + +func TestRedirectProcessor_RejectsNonHTTPScheme(t *testing.T) { + t.Parallel() + p := RedirectProcessor{} + for _, bad := range []string{"javascript:alert(1)", "data:text/html,", "vbscript:msgbox(1)"} { + link := &Link{URL: bad} + if err := p.Process(context.Background(), link); err == nil { + t.Fatalf("Process(%q) = nil, want rejection", bad) + } + } + for _, ok := range []string{"https://example.com/x", "http://example.com"} { + if err := p.Process(context.Background(), &Link{URL: ok}); err != nil { + t.Fatalf("Process(%q) = %v, want nil", ok, err) + } + } +} diff --git a/go.mod b/go.mod index f42d528..2d8f951 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,19 @@ go 1.26 require ( github.com/matoous/go-nanoid/v2 v2.1.0 github.com/redis/go-redis/v9 v9.7.0 + modernc.org/sqlite v1.52.0 ) require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index a69e588..10b36df 100644 --- a/go.sum +++ b/go.sum @@ -8,13 +8,64 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/memory_store.go b/memory_store.go index 624ac6c..1e63f30 100644 --- a/memory_store.go +++ b/memory_store.go @@ -8,13 +8,16 @@ import ( "time" ) -// MemoryStore implements [Store] using in-memory maps. -// Useful for tests, examples, and local development. +// MemoryStore implements [Store] using in-memory maps. Useful for tests, +// examples, and local development. Analytics are kept as aggregates (not raw +// events) so memory stays bounded by dimension cardinality rather than visit +// count. type MemoryStore struct { mu sync.RWMutex payload map[string]*Link clicks map[string]int64 deletedAt map[string]time.Time + stats map[string]*memStats } // NewMemoryStore creates an in-memory store. @@ -23,6 +26,7 @@ func NewMemoryStore() *MemoryStore { payload: make(map[string]*Link), clicks: make(map[string]int64), deletedAt: make(map[string]time.Time), + stats: make(map[string]*memStats), } } @@ -49,6 +53,10 @@ func (s *MemoryStore) Get(_ context.Context, id string) (*Link, error) { if !ok { return nil, ErrNotFound } + if linkExpired(payload, time.Now()) { + s.evictLocked(id) + return nil, ErrNotFound + } return cloneLink(payload), nil } @@ -82,10 +90,16 @@ func (s *MemoryStore) sweepLocked(id string) bool { if time.Since(t) < DeleteGracePeriod { return false } + s.evictLocked(id) + return true +} + +// evictLocked removes every trace of id. Caller must hold s.mu. +func (s *MemoryStore) evictLocked(id string) { delete(s.payload, id) delete(s.clicks, id) delete(s.deletedAt, id) - return true + delete(s.stats, id) } // sweepAllLocked snapshots the deleted-IDs map before sweeping so we @@ -103,11 +117,11 @@ func (s *MemoryStore) sweepAllLocked() { } } -func (s *MemoryStore) IncrClick(_ context.Context, id string) (int64, error) { +func (s *MemoryStore) IncrClickBy(_ context.Context, id string, delta int64) (int64, error) { s.mu.Lock() defer s.mu.Unlock() - s.clicks[id]++ + s.clicks[id] += delta return s.clicks[id], nil } @@ -128,9 +142,10 @@ func (s *MemoryStore) List(_ context.Context, linkType string, cursor uint64, co s.sweepAllLocked() + now := time.Now() ids := make([]string, 0, len(s.payload)) for id, payload := range s.payload { - if payload.Type == linkType && payload.DeletedAt == "" { + if payload.Type == linkType && payload.DeletedAt == "" && !linkExpired(payload, now) { ids = append(ids, id) } } @@ -173,3 +188,71 @@ func cloneLink(payload *Link) *Link { return &cloned } + +// linkExpired reports whether payload has an ExpiresAt that is at or before +// now. An empty or unparseable ExpiresAt is treated as "never expires". +func linkExpired(payload *Link, now time.Time) bool { + if payload.ExpiresAt == "" { + return false + } + exp, err := time.Parse(time.RFC3339, payload.ExpiresAt) + if err != nil { + return false + } + return !exp.After(now) +} + +// memStats holds aggregated breakdowns for one link. Total clicks live in +// MemoryStore.clicks; these maps cover only the dimensional splits. +type memStats struct { + byPlatform map[string]int64 + byReferrer map[string]int64 + byDay map[string]int64 +} + +func newMemStats() *memStats { + return &memStats{ + byPlatform: make(map[string]int64), + byReferrer: make(map[string]int64), + byDay: make(map[string]int64), + } +} + +// RecordEvents folds each event into the per-link aggregates. Empty +// dimension values are skipped so breakdowns stay meaningful. +func (s *MemoryStore) RecordEvents(_ context.Context, events []ClickEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + + for _, ev := range events { + agg := s.stats[ev.ShortID] + if agg == nil { + agg = newMemStats() + s.stats[ev.ShortID] = agg + } + incrNonEmpty(agg.byPlatform, ev.Platform) + incrNonEmpty(agg.byReferrer, ev.Referrer) + agg.byDay[ev.Time.UTC().Format(time.DateOnly)]++ + } + return nil +} + +// Stats returns the aggregated breakdowns plus the live total click count. +func (s *MemoryStore) Stats(_ context.Context, shortID string) (Stats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + out := Stats{ShortID: shortID, Clicks: s.clicks[shortID]} + if agg, ok := s.stats[shortID]; ok { + out.ByPlatform = maps.Clone(agg.byPlatform) + out.ByReferrer = maps.Clone(agg.byReferrer) + out.ByDay = maps.Clone(agg.byDay) + } + return out, nil +} + +func incrNonEmpty(m map[string]int64, key string) { + if key != "" { + m[key]++ + } +} diff --git a/model.go b/model.go index e130f9d..fd4826e 100644 --- a/model.go +++ b/model.go @@ -1,9 +1,10 @@ package deeplink -// Link represents a deep link with its metadata. -// -// The core fields (Type, URL, Title, Description, ImageURL) drive the -// built-in preview templates. Processors use Metadata for everything else. +import "time" + +// Link represents a deep link with its metadata. The core fields (Type, URL, +// Title, Description, ImageURL) drive the preview templates; processors use +// Metadata for everything else. type Link struct { // Type identifies the link type (e.g. "redirect"). Type string `json:"type,omitempty"` @@ -33,6 +34,10 @@ type Link struct { // Empty falls back to Config.Locale. Locale string `json:"locale,omitempty"` + // ExpiresAt is an optional RFC 3339 expiry, in the future at creation. + // Once past, the link resolves as not found and is purged. Empty: never. + ExpiresAt string `json:"expires_at,omitempty"` + // CreatedAt is an RFC 3339 timestamp. Set by the service. CreatedAt string `json:"created_at,omitempty"` // UpdatedAt is an RFC 3339 timestamp. Set by the service. @@ -63,15 +68,32 @@ func (l *Link) SetMeta(key string, value any) { l.Metadata[key] = value } -// LinkResponse is the wire shape returned by all link-reading endpoints -// (list, detail, update). It embeds the stored [Link] and adds the -// rendered short URL plus the current click count. -// -// At the store layer, ShortLink carries only the short ID; HTTP handlers -// prefix it with [Config.BaseURL] before responding so callers see a full -// URL. +// LinkResponse is the wire shape from link-reading endpoints: the stored +// [Link] plus its short URL and click count. At the store layer ShortLink +// holds only the short ID; the HTTP layer prefixes it with [Config.BaseURL]. type LinkResponse struct { *Link ShortLink string `json:"short_link"` Clicks int64 `json:"clicks"` } + +// ClickEvent is one resolved-link visit, captured at resolve time and +// recorded asynchronously. +type ClickEvent struct { + ShortID string + Time time.Time + Platform string // "android", "ios", or "web" + Device string // "mobile", "desktop", or "bot" + Browser string // browser family, e.g. "Chrome"; "" if unknown + OS string // e.g. "Android", "iOS"; "" if unknown + Referrer string // Referer host, e.g. "t.co"; "" if absent +} + +// Stats is the aggregated analytics for a link. Empty breakdowns are omitted. +type Stats struct { + ShortID string `json:"short_id"` + Clicks int64 `json:"clicks"` + ByPlatform map[string]int64 `json:"by_platform,omitempty"` + ByReferrer map[string]int64 `json:"by_referrer,omitempty"` + ByDay map[string]int64 `json:"by_day,omitempty"` +} diff --git a/preview.go b/preview.go index b7d6439..fc40f36 100644 --- a/preview.go +++ b/preview.go @@ -30,6 +30,11 @@ type previewView struct { OGType string TwitterSite string FediverseCreator string + // Store URLs feed the link template's store fallback. Empty when not + // configured — the template then just forwards to the destination. + AndroidStoreURL string + IOSStoreURL string + WebFallbackURL string } // buildPreviewData wraps a Link with the config-derived rendering data the @@ -57,6 +62,9 @@ func (s *Service) buildPreviewData(payload *Link) any { OGType: strings.ToLower(strings.TrimSpace(payload.OGType)), TwitterSite: s.config.TwitterSite, FediverseCreator: s.config.FediverseCreator, + AndroidStoreURL: s.config.AndroidStoreURL, + IOSStoreURL: s.config.IOSStoreURL, + WebFallbackURL: s.config.WebFallbackURL, } } @@ -76,6 +84,7 @@ func langFromLocale(locale string) string { // Content-Type and Cache-Control headers used by both preview handlers. func writePreviewHTML(w http.ResponseWriter, body []byte) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", previewCacheControl) w.WriteHeader(http.StatusOK) _, _ = w.Write(body) diff --git a/redirect.go b/redirect.go index 806da4e..682e9a5 100644 --- a/redirect.go +++ b/redirect.go @@ -24,9 +24,16 @@ func (RedirectProcessor) Process(_ context.Context, payload *Link) error { if target == "" { return NewError(fmt.Errorf("missing redirect URL"), http.StatusBadRequest, "url is required") } - if _, err := url.ParseRequestURI(target); err != nil { + u, err := url.ParseRequestURI(target) + if err != nil { return NewError(err, http.StatusBadRequest, "url must be a valid absolute URL") } + // Restrict to http(s): the preview page assigns the destination to + // window.location.href, so a javascript:/data: URL would be stored XSS. + // Universal/App Links are https; custom schemes need a dedicated processor. + if u.Scheme != "http" && u.Scheme != "https" { + return NewError(fmt.Errorf("disallowed url scheme %q", u.Scheme), http.StatusBadRequest, "url must use http or https") + } payload.URL = target if payload.Title == "" { diff --git a/redis_store.go b/redis_store.go deleted file mode 100644 index b1369f9..0000000 --- a/redis_store.go +++ /dev/null @@ -1,167 +0,0 @@ -package deeplink - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/redis/go-redis/v9" -) - -// RedisStore implements [Store] using Redis. -// Link payloads are stored under "dl:{id}" keys. -// Click counts are stored under "dl:{id}:clicks" keys. -type RedisStore struct { - client *redis.Client - prefix string -} - -// NewRedisStore creates a Redis-backed store. -// Keys are prefixed with "dl:" to avoid collisions with other data. -func NewRedisStore(client *redis.Client) *RedisStore { - return &RedisStore{client: client, prefix: "dl:"} -} - -func (s *RedisStore) key(id string) string { return s.prefix + id } -func (s *RedisStore) clicksKey(id string) string { return s.prefix + id + ":clicks" } -func (s *RedisStore) scanPattern() string { return s.prefix + "*" } -func (s *RedisStore) stripPrefix(key string) string { return strings.TrimPrefix(key, s.prefix) } - -func (s *RedisStore) Save(ctx context.Context, id string, payload *Link) error { - data, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal payload: %w", err) - } - // SET without an expiration clears any prior TTL on the key, so a - // re-save during the soft-delete grace window resurrects the link - // without needing a separate PERSIST call. - if err := s.client.Set(ctx, s.key(id), string(data), 0).Err(); err != nil { - return fmt.Errorf("save payload: %w", err) - } - return nil -} - -func (s *RedisStore) Delete(ctx context.Context, id string) error { - payload, err := s.Get(ctx, id) - if err != nil { - return err - } - if payload.DeletedAt != "" { - return ErrNotFound - } - - payload.DeletedAt = time.Now().UTC().Format(time.RFC3339) - data, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal payload: %w", err) - } - - pipe := s.client.Pipeline() - pipe.Set(ctx, s.key(id), string(data), DeleteGracePeriod) - pipe.Expire(ctx, s.clicksKey(id), DeleteGracePeriod) - if _, err := pipe.Exec(ctx); err != nil { - return fmt.Errorf("soft-delete payload: %w", err) - } - return nil -} - -func (s *RedisStore) Get(ctx context.Context, id string) (*Link, error) { - data, err := s.client.Get(ctx, s.key(id)).Result() - if errors.Is(err, redis.Nil) || len(data) == 0 { - return nil, ErrNotFound - } - if err != nil { - return nil, fmt.Errorf("get from redis: %w", err) - } - - var payload Link - if err := json.Unmarshal([]byte(data), &payload); err != nil { - return nil, fmt.Errorf("unmarshal payload: %w", err) - } - if payload.ShortID == "" { - payload.ShortID = id - } - return &payload, nil -} - -func (s *RedisStore) IncrClick(ctx context.Context, id string) (int64, error) { - return s.client.Incr(ctx, s.clicksKey(id)).Result() -} - -func (s *RedisStore) Clicks(ctx context.Context, id string) (int64, error) { - n, err := s.client.Get(ctx, s.clicksKey(id)).Int64() - if errors.Is(err, redis.Nil) { - return 0, nil - } - return n, err -} - -func (s *RedisStore) List(ctx context.Context, linkType string, cursor uint64, count int64) ([]LinkResponse, uint64, error) { - scanCount := max(count, 100) - - keys, nextCursor, err := s.client.Scan(ctx, cursor, s.scanPattern(), scanCount).Result() - if err != nil { - return nil, 0, fmt.Errorf("scan redis: %w", err) - } - - if len(keys) == 0 { - return nil, nextCursor, nil - } - - // Filter to payload keys only (skip :clicks keys) - payloadKeys := make([]string, 0, len(keys)) - for _, key := range keys { - if !strings.HasSuffix(key, ":clicks") { - payloadKeys = append(payloadKeys, key) - } - } - - if len(payloadKeys) == 0 { - return nil, nextCursor, nil - } - - pipe := s.client.Pipeline() - cmds := make(map[string]*redis.StringCmd, len(payloadKeys)) - clickCmds := make(map[string]*redis.StringCmd, len(payloadKeys)) - - for _, key := range payloadKeys { - cmds[key] = pipe.Get(ctx, key) - clickCmds[key] = pipe.Get(ctx, key+":clicks") - } - - _, err = pipe.Exec(ctx) - if err != nil && !errors.Is(err, redis.Nil) { - return nil, 0, fmt.Errorf("exec pipeline: %w", err) - } - - var links []LinkResponse - for key, cmd := range cmds { - val, err := cmd.Result() - if err != nil { - continue - } - - var p Link - if err := json.Unmarshal([]byte(val), &p); err != nil { - continue - } - - if p.Type == linkType && p.DeletedAt == "" { - clicks, _ := clickCmds[key].Int64() - id := s.stripPrefix(key) - if p.ShortID == "" { - p.ShortID = id - } - links = append(links, LinkResponse{ - Link: &p, - ShortLink: id, - Clicks: clicks, - }) - } - } - - return links, nextCursor, nil -} diff --git a/redisstore/redis.go b/redisstore/redis.go new file mode 100644 index 0000000..fc0d575 --- /dev/null +++ b/redisstore/redis.go @@ -0,0 +1,277 @@ +// Package redisstore provides a Redis-backed [deeplink.Store], including the +// click-analytics breakdowns. Importing it pulls in go-redis; the core +// deeplink package does not. +package redisstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/yinebebt/deeplink" +) + +var _ deeplink.Store = (*Store)(nil) + +// Store implements [deeplink.Store] using Redis: +// - "dl:{id}" link payload (JSON) +// - "dl:{id}:clicks" click counter +// - "dl:{id}:stats" breakdown hash, fields ":" (HINCRBY) +// +// All three keys share the link's TTL and soft-delete grace. +type Store struct { + client *redis.Client + prefix string +} + +// New creates a Redis-backed store. Keys are prefixed with "dl:" to avoid +// collisions with other data. +func New(client *redis.Client) *Store { + return &Store{client: client, prefix: "dl:"} +} + +func (s *Store) key(id string) string { return s.prefix + id } +func (s *Store) clicksKey(id string) string { return s.prefix + id + ":clicks" } +func (s *Store) statsKey(id string) string { return s.prefix + id + ":stats" } +func (s *Store) scanPattern() string { return s.prefix + "*" } +func (s *Store) stripPrefix(key string) string { return strings.TrimPrefix(key, s.prefix) } + +func (s *Store) Save(ctx context.Context, id string, payload *deeplink.Link) error { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + // A zero expiration clears any prior TTL on the key, so a re-save during + // the soft-delete grace window resurrects the link without a separate + // PERSIST call. A future ExpiresAt sets a native TTL: Redis evicts the + // key on expiry, after which Get returns ErrNotFound for free. + expiration := payloadTTL(payload) + if err := s.client.Set(ctx, s.key(id), string(data), expiration).Err(); err != nil { + return fmt.Errorf("save payload: %w", err) + } + // Keep the side keys' TTL in lock-step with the link: match a finite + // expiry, or clear a leftover grace TTL on resurrect. + for _, k := range []string{s.clicksKey(id), s.statsKey(id)} { + if expiration > 0 { + s.client.Expire(ctx, k, expiration) + } else { + s.client.Persist(ctx, k) + } + } + return nil +} + +// payloadTTL returns the time until a link's ExpiresAt, or 0 if it has none +// or has already passed. +func payloadTTL(payload *deeplink.Link) time.Duration { + if payload.ExpiresAt == "" { + return 0 + } + exp, err := time.Parse(time.RFC3339, payload.ExpiresAt) + if err != nil { + return 0 + } + d := time.Until(exp) + if d <= 0 { + return 0 + } + return d +} + +func (s *Store) Delete(ctx context.Context, id string) error { + payload, err := s.Get(ctx, id) + if err != nil { + return err + } + if payload.DeletedAt != "" { + return deeplink.ErrNotFound + } + + payload.DeletedAt = time.Now().UTC().Format(time.RFC3339) + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + pipe := s.client.Pipeline() + pipe.Set(ctx, s.key(id), string(data), deeplink.DeleteGracePeriod) + pipe.Expire(ctx, s.clicksKey(id), deeplink.DeleteGracePeriod) + pipe.Expire(ctx, s.statsKey(id), deeplink.DeleteGracePeriod) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("soft-delete payload: %w", err) + } + return nil +} + +func (s *Store) Get(ctx context.Context, id string) (*deeplink.Link, error) { + data, err := s.client.Get(ctx, s.key(id)).Result() + if errors.Is(err, redis.Nil) || len(data) == 0 { + return nil, deeplink.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get from redis: %w", err) + } + + var payload deeplink.Link + if err := json.Unmarshal([]byte(data), &payload); err != nil { + return nil, fmt.Errorf("unmarshal payload: %w", err) + } + if payload.ShortID == "" { + payload.ShortID = id + } + return &payload, nil +} + +func (s *Store) IncrClickBy(ctx context.Context, id string, delta int64) (int64, error) { + return s.client.IncrBy(ctx, s.clicksKey(id), delta).Result() +} + +func (s *Store) Clicks(ctx context.Context, id string) (int64, error) { + n, err := s.client.Get(ctx, s.clicksKey(id)).Int64() + if errors.Is(err, redis.Nil) { + return 0, nil + } + return n, err +} + +func (s *Store) List(ctx context.Context, linkType string, cursor uint64, count int64) ([]deeplink.LinkResponse, uint64, error) { + scanCount := max(count, 100) + + keys, nextCursor, err := s.client.Scan(ctx, cursor, s.scanPattern(), scanCount).Result() + if err != nil { + return nil, 0, fmt.Errorf("scan redis: %w", err) + } + + if len(keys) == 0 { + return nil, nextCursor, nil + } + + // Filter to payload keys only (skip the :clicks and :stats side keys). + payloadKeys := make([]string, 0, len(keys)) + for _, key := range keys { + if !strings.HasSuffix(key, ":clicks") && !strings.HasSuffix(key, ":stats") { + payloadKeys = append(payloadKeys, key) + } + } + + if len(payloadKeys) == 0 { + return nil, nextCursor, nil + } + + pipe := s.client.Pipeline() + cmds := make(map[string]*redis.StringCmd, len(payloadKeys)) + clickCmds := make(map[string]*redis.StringCmd, len(payloadKeys)) + + for _, key := range payloadKeys { + cmds[key] = pipe.Get(ctx, key) + clickCmds[key] = pipe.Get(ctx, key+":clicks") + } + + _, err = pipe.Exec(ctx) + if err != nil && !errors.Is(err, redis.Nil) { + return nil, 0, fmt.Errorf("exec pipeline: %w", err) + } + + var links []deeplink.LinkResponse + for key, cmd := range cmds { + val, err := cmd.Result() + if err != nil { + continue + } + + var p deeplink.Link + if err := json.Unmarshal([]byte(val), &p); err != nil { + continue + } + + if p.Type == linkType && p.DeletedAt == "" { + clicks, _ := clickCmds[key].Int64() + id := s.stripPrefix(key) + if p.ShortID == "" { + p.ShortID = id + } + links = append(links, deeplink.LinkResponse{ + Link: &p, + ShortLink: id, + Clicks: clicks, + }) + } + } + + return links, nextCursor, nil +} + +// RecordEvents increments the per-link stats hash with HINCRBY, one field +// per ":", in a single pipeline. Empty dimensions skipped. +func (s *Store) RecordEvents(ctx context.Context, events []deeplink.ClickEvent) error { + if len(events) == 0 { + return nil + } + + pipe := s.client.Pipeline() + for _, ev := range events { + k := s.statsKey(ev.ShortID) + if ev.Platform != "" { + pipe.HIncrBy(ctx, k, "platform:"+ev.Platform, 1) + } + if ev.Referrer != "" { + pipe.HIncrBy(ctx, k, "referrer:"+ev.Referrer, 1) + } + pipe.HIncrBy(ctx, k, "day:"+ev.Time.UTC().Format(time.DateOnly), 1) + } + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("record events: %w", err) + } + return nil +} + +// Stats reads the total click counter plus the stats hash, routing each +// ":" field into the matching breakdown map. +func (s *Store) Stats(ctx context.Context, shortID string) (deeplink.Stats, error) { + out := deeplink.Stats{ShortID: shortID} + + clicks, err := s.Clicks(ctx, shortID) + if err != nil { + return out, err + } + out.Clicks = clicks + + fields, err := s.client.HGetAll(ctx, s.statsKey(shortID)).Result() + if err != nil { + return out, fmt.Errorf("get stats: %w", err) + } + + for field, val := range fields { + dim, key, ok := strings.Cut(field, ":") + if !ok || key == "" { + continue + } + n, err := strconv.ParseInt(val, 10, 64) + if err != nil { + continue + } + switch dim { + case "platform": + putStat(&out.ByPlatform, key, n) + case "referrer": + putStat(&out.ByReferrer, key, n) + case "day": + putStat(&out.ByDay, key, n) + } + } + return out, nil +} + +// putStat lazily inits the map so empty breakdowns stay nil (omitted from JSON). +func putStat(m *map[string]int64, key string, n int64) { + if *m == nil { + *m = make(map[string]int64) + } + (*m)[key] = n +} diff --git a/redisstore/redis_test.go b/redisstore/redis_test.go new file mode 100644 index 0000000..8429f31 --- /dev/null +++ b/redisstore/redis_test.go @@ -0,0 +1,26 @@ +package redisstore + +import ( + "testing" + "time" + + "github.com/yinebebt/deeplink" +) + +func TestPayloadTTL(t *testing.T) { + t.Parallel() + if got := payloadTTL(&deeplink.Link{}); got != 0 { + t.Fatalf("empty ExpiresAt = %v, want 0", got) + } + if got := payloadTTL(&deeplink.Link{ExpiresAt: "not-a-date"}); got != 0 { + t.Fatalf("unparseable = %v, want 0", got) + } + past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + if got := payloadTTL(&deeplink.Link{ExpiresAt: past}); got != 0 { + t.Fatalf("past = %v, want 0", got) + } + future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + if got := payloadTTL(&deeplink.Link{ExpiresAt: future}); got <= 0 { + t.Fatalf("future = %v, want positive", got) + } +} diff --git a/server.go b/server.go index 083b310..29f62a2 100644 --- a/server.go +++ b/server.go @@ -39,6 +39,8 @@ func (s *Service) Handler() http.Handler { mux.HandleFunc("GET /types", s.handleTypes) mux.HandleFunc("GET /health", handleHealth) + mux.HandleFunc("GET /stats/{shortID}", s.handleStats) + if s.config.hasMobileRoutes() { mux.HandleFunc("GET /.well-known/", s.handleWellKnown) mux.HandleFunc("GET /redirect", s.handleRedirect) @@ -75,13 +77,19 @@ func (s *Service) handleGenerate(w http.ResponseWriter, r *http.Request) { shortURL, err := s.shortenURL(r.Context(), &payload) if err != nil { + // Preserve a deliberate status (e.g. 400 for a bad expires_at); + // wrap anything else as a 500. + if appErr, ok := errors.AsType[*Error](err); ok { + s.respondError(w, appErr) + return + } s.config.Logger.Error("failed to shorten URL", "error", err, "type", payload.Type) s.respondError(w, NewError(err, http.StatusInternalServerError, "failed to shorten URL")) return } respondJSON(w, http.StatusCreated, map[string]string{"short_url": shortURL}) - s.config.Logger.Info("link generated", "shortID", strings.TrimPrefix(shortURL, s.config.BaseURL), "type", payload.Type, "duration", time.Since(start)) + s.config.Logger.Info("link generated", "shortID", strings.TrimPrefix(shortURL, s.config.BaseURL), "type", payload.Type, "duration", time.Since(start).String()) } func (s *Service) handlePreview(w http.ResponseWriter, r *http.Request) { @@ -106,6 +114,8 @@ func (s *Service) handlePreview(w http.ResponseWriter, r *http.Request) { return } + s.clicks.track(newClickEvent(r, shortID)) + tmpl := s.templates["link"] if tmpl == nil { http.Redirect(w, r, payload.URL, http.StatusFound) @@ -121,7 +131,7 @@ func (s *Service) handlePreview(w http.ResponseWriter, r *http.Request) { } writePreviewHTML(w, buf.Bytes()) - s.config.Logger.Info("preview rendered", "shortID", shortID, "type", payload.Type, "duration", time.Since(start)) + s.config.Logger.Info("preview rendered", "shortID", shortID, "type", payload.Type, "duration", time.Since(start).String()) } // handleStaticPreview renders a preview page without auto-redirect. @@ -144,6 +154,8 @@ func (s *Service) handleStaticPreview(w http.ResponseWriter, r *http.Request) { return } + s.clicks.track(newClickEvent(r, shortID)) + previewData := s.buildPreviewData(payload) tmpl := s.templates["preview"] if tmpl == nil { @@ -159,7 +171,7 @@ func (s *Service) handleStaticPreview(w http.ResponseWriter, r *http.Request) { } writePreviewHTML(w, buf.Bytes()) - s.config.Logger.Info("preview rendered (no redirect)", "shortID", shortID, "type", payload.Type, "duration", time.Since(start)) + s.config.Logger.Info("preview rendered (no redirect)", "shortID", shortID, "type", payload.Type, "duration", time.Since(start).String()) } func (s *Service) handleRedirect(w http.ResponseWriter, r *http.Request) { @@ -186,6 +198,27 @@ func (s *Service) handleRedirect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target, http.StatusFound) } +// handleStats serves aggregated click analytics for a link. +func (s *Service) handleStats(w http.ResponseWriter, r *http.Request) { + shortID := r.PathValue("shortID") + + payload, err := s.config.Store.Get(r.Context(), shortID) + if err != nil || payload.DeletedAt != "" { + s.config.Logger.Warn("stats link not found", "shortID", shortID, "error", err) + http.Error(w, "link not found", http.StatusNotFound) + return + } + + stats, err := s.config.Store.Stats(r.Context(), shortID) + if err != nil { + s.config.Logger.Error("failed to fetch stats", "error", err, "shortID", shortID) + http.Error(w, "stats error", http.StatusInternalServerError) + return + } + + respondJSON(w, http.StatusOK, stats) +} + // listLinksByType collects every non-deleted link for linkType, prefixes // the short ID with BaseURL, and stops once the result reaches limit // (pass 0 for no limit). @@ -223,7 +256,7 @@ func (s *Service) handleLinkList(w http.ResponseWriter, r *http.Request) { } respondJSON(w, http.StatusOK, out) - s.config.Logger.Info("links listed", "type", linkType, "count", len(out), "duration", time.Since(start)) + s.config.Logger.Info("links listed", "type", linkType, "count", len(out), "duration", time.Since(start).String()) } func (s *Service) handleLinkDetail(w http.ResponseWriter, r *http.Request) { @@ -250,7 +283,7 @@ func (s *Service) handleLinkDetail(w http.ResponseWriter, r *http.Request) { ShortLink: s.config.BaseURL + shortID, Clicks: clicks, }) - s.config.Logger.Info("link detail fetched", "shortID", shortID, "duration", time.Since(start)) + s.config.Logger.Info("link detail fetched", "shortID", shortID, "duration", time.Since(start).String()) } // linkListAllCap bounds GET /links so a runaway store cannot OOM the @@ -278,7 +311,7 @@ func (s *Service) handleLinkListAll(w http.ResponseWriter, r *http.Request) { } respondJSON(w, http.StatusOK, out) - s.config.Logger.Info("links listed (all)", "count", len(out), "duration", time.Since(start)) + s.config.Logger.Info("links listed (all)", "count", len(out), "duration", time.Since(start).String()) } // linkPatch carries optional updates for PATCH /{shortID}. Pointer fields @@ -369,7 +402,7 @@ func (s *Service) handleUpdate(w http.ResponseWriter, r *http.Request) { ShortLink: s.config.BaseURL + shortID, Clicks: clicks, }) - s.config.Logger.Info("link updated", "shortID", shortID, "duration", time.Since(start)) + s.config.Logger.Info("link updated", "shortID", shortID, "duration", time.Since(start).String()) } func (s *Service) handleDelete(w http.ResponseWriter, r *http.Request) { @@ -392,7 +425,7 @@ func (s *Service) handleDelete(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) - s.config.Logger.Info("link deleted", "shortID", shortID, "duration", time.Since(start)) + s.config.Logger.Info("link deleted", "shortID", shortID, "duration", time.Since(start).String()) } func (s *Service) handleWellKnown(w http.ResponseWriter, r *http.Request) { diff --git a/sqlitestore/sqlite.go b/sqlitestore/sqlite.go new file mode 100644 index 0000000..e72109d --- /dev/null +++ b/sqlitestore/sqlite.go @@ -0,0 +1,360 @@ +// Package sqlitestore provides a persistent [deeplink.Store] backed by SQLite +// (modernc.org/sqlite), including the click-analytics breakdowns. The driver +// is only pulled in when this package is imported. +// +// Retention: click_events is append-only, pruned only when its link expires +// or is purged. For heavy workloads, add an age-based prune (by the day +// column) or roll up into daily aggregates. +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/yinebebt/deeplink" + + _ "modernc.org/sqlite" +) + +var _ deeplink.Store = (*Store)(nil) + +// Store is a SQLite-backed link store with analytics. +type Store struct { + db *sql.DB +} + +// New opens (creating if needed) a SQLite database at dsn and returns a ready +// Store. dsn is a file path, or "file::memory:?cache=shared" for an ephemeral +// database. +func New(dsn string) (*Store, error) { + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + // SQLite serializes writes; one connection avoids lock churn and keeps a + // shared in-memory DB coherent. + db.SetMaxOpenConns(1) + + s := &Store{db: db} + if err := s.init(context.Background()); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +// NewWithDB wraps an existing *sql.DB (e.g. one with custom pragmas) and +// ensures the schema exists. The caller owns the DB lifecycle. +func NewWithDB(ctx context.Context, db *sql.DB) (*Store, error) { + s := &Store{db: db} + if err := s.init(ctx); err != nil { + return nil, err + } + return s, nil +} + +// Close closes the underlying database. +func (s *Store) Close() error { return s.db.Close() } + +const schema = ` +PRAGMA busy_timeout = 5000; +CREATE TABLE IF NOT EXISTS links ( + short_id TEXT PRIMARY KEY, + type TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + deleted_at TEXT, + clicks INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_links_type ON links(type, created_at, short_id); +CREATE TABLE IF NOT EXISTS click_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + short_id TEXT NOT NULL, + platform TEXT, + referrer TEXT, + day TEXT +); +CREATE INDEX IF NOT EXISTS idx_events_short ON click_events(short_id); +` + +func (s *Store) init(ctx context.Context) error { + if _, err := s.db.ExecContext(ctx, schema); err != nil { + return fmt.Errorf("init schema: %w", err) + } + return nil +} + +func nowUTC() string { return time.Now().UTC().Format(time.RFC3339) } + +func nullable(v string) any { + if v == "" { + return nil + } + return v +} + +// Save upserts a link. On conflict it refreshes the mutable columns and +// clears deleted_at (a re-save resurrects a soft-deleted link) while +// preserving the original created_at and the accumulated click count. +func (s *Store) Save(ctx context.Context, id string, payload *deeplink.Link) error { + payload.ShortID = id + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + const q = ` +INSERT INTO links (short_id, type, payload, created_at, expires_at, deleted_at, clicks) +VALUES (?, ?, ?, ?, ?, NULL, 0) +ON CONFLICT(short_id) DO UPDATE SET + type = excluded.type, + payload = excluded.payload, + expires_at = excluded.expires_at, + deleted_at = NULL` + + createdAt := payload.CreatedAt + if createdAt == "" { + createdAt = nowUTC() + } + if _, err := s.db.ExecContext(ctx, q, id, payload.Type, string(data), createdAt, nullable(payload.ExpiresAt)); err != nil { + return fmt.Errorf("save payload: %w", err) + } + return nil +} + +// Get returns a link, transparently purging it when expired or when its +// soft-delete grace window has elapsed. Soft-deleted links still within the +// grace window are returned with DeletedAt set; callers decide what to do. +func (s *Store) Get(ctx context.Context, id string) (*deeplink.Link, error) { + var data string + var expiresAt, deletedAt sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT payload, expires_at, deleted_at FROM links WHERE short_id = ?`, id). + Scan(&data, &expiresAt, &deletedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, deeplink.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get payload: %w", err) + } + + now := time.Now() + if expiresAt.Valid { + if exp, perr := time.Parse(time.RFC3339, expiresAt.String); perr == nil && !exp.After(now) { + s.purge(ctx, id) + return nil, deeplink.ErrNotFound + } + } + if deletedAt.Valid { + if del, perr := time.Parse(time.RFC3339, deletedAt.String); perr == nil && now.Sub(del) >= deeplink.DeleteGracePeriod { + s.purge(ctx, id) + return nil, deeplink.ErrNotFound + } + } + + var payload deeplink.Link + if err := json.Unmarshal([]byte(data), &payload); err != nil { + return nil, fmt.Errorf("unmarshal payload: %w", err) + } + if payload.ShortID == "" { + payload.ShortID = id + } + return &payload, nil +} + +// Delete soft-deletes a link by stamping DeletedAt in both the column and +// the stored payload. Returns [deeplink.ErrNotFound] if it is missing or +// already soft-deleted. +func (s *Store) Delete(ctx context.Context, id string) error { + payload, err := s.Get(ctx, id) + if err != nil { + return err + } + if payload.DeletedAt != "" { + return deeplink.ErrNotFound + } + + payload.DeletedAt = nowUTC() + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + if _, err := s.db.ExecContext(ctx, `UPDATE links SET payload = ?, deleted_at = ? WHERE short_id = ?`, string(data), payload.DeletedAt, id); err != nil { + return fmt.Errorf("soft-delete payload: %w", err) + } + return nil +} + +func (s *Store) purge(ctx context.Context, id string) { + _, _ = s.db.ExecContext(ctx, `DELETE FROM links WHERE short_id = ?`, id) + _, _ = s.db.ExecContext(ctx, `DELETE FROM click_events WHERE short_id = ?`, id) +} + +// IncrClickBy adds delta to the click count and returns the new value. A +// missing row (link expired or purged between resolve and flush) yields 0 +// without error. +func (s *Store) IncrClickBy(ctx context.Context, id string, delta int64) (int64, error) { + var n int64 + err := s.db.QueryRowContext(ctx, `UPDATE links SET clicks = clicks + ? WHERE short_id = ? RETURNING clicks`, delta, id).Scan(&n) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("incr click: %w", err) + } + return n, nil +} + +// Clicks returns the current click count, or 0 if the link is missing. +func (s *Store) Clicks(ctx context.Context, id string) (int64, error) { + var n int64 + err := s.db.QueryRowContext(ctx, `SELECT clicks FROM links WHERE short_id = ?`, id).Scan(&n) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("get clicks: %w", err) + } + return n, nil +} + +// List returns a page of live (non-deleted, non-expired) links of linkType, +// ordered by creation. The cursor is an opaque offset; the returned cursor +// is 0 when no more rows remain. +func (s *Store) List(ctx context.Context, linkType string, cursor uint64, count int64) ([]deeplink.LinkResponse, uint64, error) { + if count <= 0 { + count = 100 + } + + const q = ` +SELECT payload, clicks FROM links +WHERE type = ? + AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) +ORDER BY created_at, short_id +LIMIT ? OFFSET ?` + + rows, err := s.db.QueryContext(ctx, q, linkType, nowUTC(), count, int64(cursor)) + if err != nil { + return nil, 0, fmt.Errorf("list links: %w", err) + } + defer func() { _ = rows.Close() }() + + links := make([]deeplink.LinkResponse, 0, count) + for rows.Next() { + var data string + var clicks int64 + if err := rows.Scan(&data, &clicks); err != nil { + return nil, 0, fmt.Errorf("scan link: %w", err) + } + var p deeplink.Link + if err := json.Unmarshal([]byte(data), &p); err != nil { + continue + } + links = append(links, deeplink.LinkResponse{Link: &p, ShortLink: p.ShortID, Clicks: clicks}) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterate links: %w", err) + } + + var next uint64 + if int64(len(links)) == count { + next = cursor + uint64(count) + } + return links, next, nil +} + +// RecordEvents appends click events in a single transaction. +func (s *Store) RecordEvents(ctx context.Context, events []deeplink.ClickEvent) error { + if len(events) == 0 { + return nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.PrepareContext(ctx, `INSERT INTO click_events (short_id, platform, referrer, day) VALUES (?, ?, ?, ?)`) + if err != nil { + return fmt.Errorf("prepare insert: %w", err) + } + defer func() { _ = stmt.Close() }() + + for _, ev := range events { + day := ev.Time.UTC().Format(time.DateOnly) + if _, err := stmt.ExecContext(ctx, ev.ShortID, nullable(ev.Platform), nullable(ev.Referrer), day); err != nil { + return fmt.Errorf("insert event: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit events: %w", err) + } + return nil +} + +// Stats aggregates click breakdowns for a link. The total click count comes +// from the links table; the breakdowns come from recorded events. +func (s *Store) Stats(ctx context.Context, shortID string) (deeplink.Stats, error) { + out := deeplink.Stats{ShortID: shortID} + + clicks, err := s.Clicks(ctx, shortID) + if err != nil { + return out, err + } + out.Clicks = clicks + + out.ByPlatform, err = s.groupBy(ctx, "platform", shortID) + if err != nil { + return out, err + } + out.ByReferrer, err = s.groupBy(ctx, "referrer", shortID) + if err != nil { + return out, err + } + out.ByDay, err = s.groupBy(ctx, "day", shortID) + if err != nil { + return out, err + } + return out, nil +} + +// groupBy counts events for shortID grouped by a fixed column. The column is +// interpolated into the SQL, so it is guarded against an allowlist: only the +// known breakdown columns are ever accepted, never user input. +func (s *Store) groupBy(ctx context.Context, column, shortID string) (map[string]int64, error) { + switch column { + case "platform", "referrer", "day": + default: + return nil, fmt.Errorf("groupBy: unsupported column %q", column) + } + + q := fmt.Sprintf(`SELECT %s, COUNT(*) FROM click_events WHERE short_id = ? AND %s IS NOT NULL AND %s <> '' GROUP BY %s`, column, column, column, column) + rows, err := s.db.QueryContext(ctx, q, shortID) + if err != nil { + return nil, fmt.Errorf("group by %s: %w", column, err) + } + defer func() { _ = rows.Close() }() + + var out map[string]int64 + for rows.Next() { + var key string + var n int64 + if err := rows.Scan(&key, &n); err != nil { + return nil, fmt.Errorf("scan group: %w", err) + } + if out == nil { + out = make(map[string]int64) + } + out[key] = n + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate groups: %w", err) + } + return out, nil +} diff --git a/sqlitestore/sqlite_test.go b/sqlitestore/sqlite_test.go new file mode 100644 index 0000000..29362bf --- /dev/null +++ b/sqlitestore/sqlite_test.go @@ -0,0 +1,221 @@ +package sqlitestore + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/yinebebt/deeplink" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + dsn := filepath.Join(t.TempDir(), "test.db") + s, err := New(dsn) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func TestSQLite_SaveGetDelete(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + + link := &deeplink.Link{Type: "redirect", URL: "https://example.com", Title: "Hi", CreatedAt: time.Now().UTC().Format(time.RFC3339)} + if err := s.Save(ctx, "abc", link); err != nil { + t.Fatalf("Save() error = %v", err) + } + + got, err := s.Get(ctx, "abc") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.URL != "https://example.com" || got.ShortID != "abc" { + t.Fatalf("Get() = %+v", got) + } + + if err := s.Delete(ctx, "abc"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + // Within grace the link is still returned, but flagged deleted. + got, err = s.Get(ctx, "abc") + if err != nil { + t.Fatalf("Get(after delete) error = %v", err) + } + if got.DeletedAt == "" { + t.Fatalf("expected DeletedAt set after soft-delete") + } + // Double delete is a no-op error. + if err := s.Delete(ctx, "abc"); !errors.Is(err, deeplink.ErrNotFound) { + t.Fatalf("Delete(twice) error = %v, want ErrNotFound", err) + } + // Re-save resurrects and clears DeletedAt. + if err := s.Save(ctx, "abc", &deeplink.Link{Type: "redirect", URL: "https://example.com", CreatedAt: link.CreatedAt}); err != nil { + t.Fatalf("Save(resurrect) error = %v", err) + } + got, _ = s.Get(ctx, "abc") + if got.DeletedAt != "" { + t.Fatalf("re-save should clear DeletedAt, got %q", got.DeletedAt) + } +} + +func TestSQLite_Expiry(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + + past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + if err := s.Save(ctx, "old", &deeplink.Link{Type: "redirect", URL: "https://e.com", ExpiresAt: past}); err != nil { + t.Fatalf("Save() error = %v", err) + } + if _, err := s.Get(ctx, "old"); !errors.Is(err, deeplink.ErrNotFound) { + t.Fatalf("Get(expired) error = %v, want ErrNotFound", err) + } +} + +func TestSQLite_Clicks(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + + if err := s.Save(ctx, "c1", &deeplink.Link{Type: "redirect", URL: "https://e.com"}); err != nil { + t.Fatalf("Save() error = %v", err) + } + for i := int64(1); i <= 3; i++ { + n, err := s.IncrClickBy(ctx, "c1", 1) + if err != nil { + t.Fatalf("IncrClickBy() error = %v", err) + } + if n != i { + t.Fatalf("IncrClickBy() = %d, want %d", n, i) + } + } + // A batched bump (delta > 1) is the common flusher path. + if n, err := s.IncrClickBy(ctx, "c1", 5); err != nil || n != 8 { + t.Fatalf("IncrClickBy(+5) = %d, %v; want 8", n, err) + } + n, _ := s.Clicks(ctx, "c1") + if n != 8 { + t.Fatalf("Clicks() = %d, want 8", n) + } + // Missing link increments to nothing, no error. + if n, err := s.IncrClickBy(ctx, "missing", 1); err != nil || n != 0 { + t.Fatalf("IncrClickBy(missing) = %d, %v", n, err) + } +} + +func TestSQLite_ListPagination(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + + base := time.Now().UTC() + for i := range 5 { + id := string(rune('a' + i)) + // Distinct created_at keeps ordering deterministic. + created := base.Add(time.Duration(i) * time.Second).Format(time.RFC3339) + if err := s.Save(ctx, id, &deeplink.Link{Type: "redirect", URL: "https://e.com", CreatedAt: created}); err != nil { + t.Fatalf("Save() error = %v", err) + } + } + + var seen int + var cursor uint64 + for { + links, next, err := s.List(ctx, "redirect", cursor, 2) + if err != nil { + t.Fatalf("List() error = %v", err) + } + seen += len(links) + if next == 0 { + break + } + cursor = next + } + if seen != 5 { + t.Fatalf("paginated total = %d, want 5", seen) + } +} + +// TestSQLite_ListPaginationExactMultiple covers the boundary where the row +// count is an exact multiple of the page size: the last full page returns a +// non-zero cursor, so a final query yields an empty page before next == 0. +func TestSQLite_ListPaginationExactMultiple(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + + base := time.Now().UTC() + for i := range 4 { + id := string(rune('a' + i)) + created := base.Add(time.Duration(i) * time.Second).Format(time.RFC3339) + if err := s.Save(ctx, id, &deeplink.Link{Type: "redirect", URL: "https://e.com", CreatedAt: created}); err != nil { + t.Fatalf("Save() error = %v", err) + } + } + + var pages, nonEmpty, seen int + var cursor uint64 + for { + links, next, err := s.List(ctx, "redirect", cursor, 2) + if err != nil { + t.Fatalf("List() error = %v", err) + } + pages++ + if len(links) > 0 { + nonEmpty++ + } + seen += len(links) + if next == 0 { + break + } + cursor = next + } + if seen != 4 { + t.Fatalf("total = %d, want 4", seen) + } + if nonEmpty != 2 { + t.Fatalf("non-empty pages = %d, want 2", nonEmpty) + } + if pages != 3 { + t.Fatalf("total pages = %d, want 3 (2 full + 1 trailing empty)", pages) + } +} + +func TestSQLite_Analytics(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + + if err := s.Save(ctx, "a", &deeplink.Link{Type: "redirect", URL: "https://e.com"}); err != nil { + t.Fatalf("Save() error = %v", err) + } + day := time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC) + events := []deeplink.ClickEvent{ + {ShortID: "a", Time: day, Platform: "android", Referrer: "t.co"}, + {ShortID: "a", Time: day, Platform: "android"}, + {ShortID: "a", Time: day, Platform: "web", Referrer: "t.co"}, + } + if err := s.RecordEvents(ctx, events); err != nil { + t.Fatalf("RecordEvents() error = %v", err) + } + + stats, err := s.Stats(ctx, "a") + if err != nil { + t.Fatalf("Stats() error = %v", err) + } + if stats.ByPlatform["android"] != 2 || stats.ByPlatform["web"] != 1 { + t.Fatalf("by_platform = %v", stats.ByPlatform) + } + if stats.ByReferrer["t.co"] != 2 { + t.Fatalf("by_referrer = %v", stats.ByReferrer) + } + if stats.ByDay["2026-06-14"] != 3 { + t.Fatalf("by_day = %v", stats.ByDay) + } +} diff --git a/store.go b/store.go index c5c2fd2..1aef8be 100644 --- a/store.go +++ b/store.go @@ -22,8 +22,10 @@ type Store interface { // purge after [DeleteGracePeriod]. Returns [ErrNotFound] if the id // does not exist or is already past grace. Delete(ctx context.Context, id string) error - // IncrClick increments the click counter for id and returns the new count. - IncrClick(ctx context.Context, id string) (int64, error) + // IncrClickBy adds delta to the click counter for id and returns the new + // count. The async click flusher batches a resolve burst into one call + // per link, so delta is typically > 1. + IncrClickBy(ctx context.Context, id string, delta int64) (int64, error) // Clicks returns the current click count for id. Clicks(ctx context.Context, id string) (int64, error) // List returns links matching linkType. @@ -33,4 +35,13 @@ type Store interface { // Returned [LinkResponse.ShortLink] values contain only the short ID; // the HTTP layer prefixes them with the configured base URL. List(ctx context.Context, linkType string, cursor uint64, count int64) ([]LinkResponse, uint64, error) + + // RecordEvents persists a batch of click events for analytics breakdowns, + // called from the background flusher. The slice is reused after the call + // returns — do not retain it. + RecordEvents(ctx context.Context, events []ClickEvent) error + + // Stats returns aggregated click breakdowns for a short ID. Breakdown + // maps are empty when no events have been recorded. + Stats(ctx context.Context, shortID string) (Stats, error) } diff --git a/templates/default/dashboard.html b/templates/default/dashboard.html deleted file mode 100644 index ffb05dd..0000000 --- a/templates/default/dashboard.html +++ /dev/null @@ -1,246 +0,0 @@ - - - - - - deeplink - - - - - - -
-
-
-
-
- - - - -
-

deeplink

-
-

Short link dashboard

-
- -
-
-
Links
-
{{.TotalLinks}}
-
-
-
Total clicks
-
{{.TotalClicks}}
-
-
- - {{if .Links}} - - - {{else}} - - {{end}} -
-
- - - - diff --git a/templates/default/link.html b/templates/default/link.html index 7eda5fd..3e062b7 100644 --- a/templates/default/link.html +++ b/templates/default/link.html @@ -36,6 +36,30 @@

Redirecting to {{.URL}}.

- + diff --git a/url.go b/url.go index 82e1064..9ec6b00 100644 --- a/url.go +++ b/url.go @@ -3,6 +3,7 @@ package deeplink import ( "context" "fmt" + "net/http" "regexp" "strings" "time" @@ -32,10 +33,23 @@ func (s *Service) shortenURL(ctx context.Context, payload *Link) (string, error) return "", fmt.Errorf("failed to generate ID after %d attempts (check SkipPaths)", maxIDRetries) } + now := time.Now().UTC() + if payload.ExpiresAt != "" { + exp, err := time.Parse(time.RFC3339, payload.ExpiresAt) + if err != nil { + return "", NewError(err, http.StatusBadRequest, "expires_at must be an RFC 3339 timestamp") + } + if !exp.After(now) { + return "", NewError(fmt.Errorf("expires_at %s is not in the future", payload.ExpiresAt), http.StatusBadRequest, "expires_at must be in the future") + } + // Normalize so stores see a consistent UTC form. + payload.ExpiresAt = exp.UTC().Format(time.RFC3339) + } + payload.ShortID = id - now := time.Now().UTC().Format(time.RFC3339) - payload.CreatedAt = now - payload.UpdatedAt = now + stamp := now.Format(time.RFC3339) + payload.CreatedAt = stamp + payload.UpdatedAt = stamp if err := s.config.Store.Save(ctx, id, payload); err != nil { return "", fmt.Errorf("store payload: %w", err) @@ -45,7 +59,8 @@ func (s *Service) shortenURL(ctx context.Context, payload *Link) (string, error) return s.config.BaseURL + id, nil } -// expandURL looks up a short ID, increments clicks, and returns the payload. +// expandURL looks up a short ID. Click tracking is done by the caller (the +// resolve handlers), which hold the request to build a [ClickEvent]. func (s *Service) expandURL(ctx context.Context, shortID string) (*Link, error) { shortID = strings.TrimPrefix(shortID, s.config.BaseURL) @@ -54,8 +69,6 @@ func (s *Service) expandURL(ctx context.Context, shortID string) (*Link, error) return nil, fmt.Errorf("expand URL %s: %w", shortID, err) } - s.clicks.track(shortID) - return payload, nil } diff --git a/useragent.go b/useragent.go new file mode 100644 index 0000000..425632b --- /dev/null +++ b/useragent.go @@ -0,0 +1,115 @@ +package deeplink + +import ( + "net/http" + "net/url" + "strings" + "time" +) + +// Platform and device-class values used in [ClickEvent] and analytics +// breakdowns. +const ( + platformWeb = "web" + platformAndroid = "android" + platformIOS = "ios" + + deviceMobile = "mobile" + deviceDesktop = "desktop" + deviceBot = "bot" +) + +// newClickEvent builds a [ClickEvent] from an incoming resolve request. +// All parsing is heuristic and best-effort; unknown fields are left empty. +func newClickEvent(r *http.Request, shortID string) ClickEvent { + platform, device, os, browser := parseUserAgent(r.UserAgent()) + return ClickEvent{ + ShortID: shortID, + Time: time.Now().UTC(), + Platform: platform, + Device: device, + OS: os, + Browser: browser, + Referrer: referrerHost(r.Referer()), + } +} + +// parseUserAgent derives a coarse platform, device class, OS, and browser +// from a UA string — enough for breakdowns, not a full UA database. +func parseUserAgent(ua string) (platform, device, os, browser string) { + if ua == "" { + return platformWeb, deviceDesktop, "", "" + } + l := strings.ToLower(ua) + + switch { + case isBot(l): + return platformWeb, deviceBot, "", "" + case strings.Contains(l, "android"): + platform, device, os = platformAndroid, deviceMobile, "Android" + case strings.Contains(l, "iphone") || strings.Contains(l, "ipod"): + platform, device, os = platformIOS, deviceMobile, "iOS" + case strings.Contains(l, "ipad"): + platform, device, os = platformIOS, deviceMobile, "iPadOS" + default: + platform, device = platformWeb, deviceDesktop + os = desktopOS(l) + } + + return platform, device, os, browserFamily(l) +} + +func isBot(l string) bool { + for _, sig := range []string{"bot", "crawler", "spider", "facebookexternalhit", "embedly", "slackbot", "telegrambot", "whatsapp", "twitterbot", "discordbot"} { + if strings.Contains(l, sig) { + return true + } + } + return false +} + +func desktopOS(l string) string { + switch { + case strings.Contains(l, "windows"): + return "Windows" + case strings.Contains(l, "mac os") || strings.Contains(l, "macintosh"): + return "macOS" + case strings.Contains(l, "linux"): + return "Linux" + default: + return "" + } +} + +// browserFamily checks Edge/Opera/Firefox before Chrome before Safari, since +// their UA strings are supersets of each other. Mobile brand tokens (EdgA, +// FxiOS, CriOS, …) are matched so iOS/Android variants aren't all collapsed. +func browserFamily(l string) string { + switch { + case strings.Contains(l, "edg/") || strings.Contains(l, "edga/") || strings.Contains(l, "edgios/") || strings.Contains(l, "edge/"): + return "Edge" + case strings.Contains(l, "opr/") || strings.Contains(l, "opt/") || strings.Contains(l, "opera"): + return "Opera" + case strings.Contains(l, "firefox") || strings.Contains(l, "fxios"): + return "Firefox" + case strings.Contains(l, "crios") || strings.Contains(l, "chrome"): + return "Chrome" + case strings.Contains(l, "safari"): + return "Safari" + default: + return "" + } +} + +// referrerHost returns the host of a Referer header value, or "" when the +// header is absent or unparseable. +func referrerHost(referer string) string { + if referer == "" { + return "" + } + u, err := url.Parse(referer) + if err != nil { + return "" + } + return u.Host +} diff --git a/web/src/api.ts b/web/src/api.ts index c709652..3202006 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { LinkDraft, LinkResponse } from "./types"; +import type { LinkDraft, LinkResponse, Stats } from "./types"; const API_KEY_STORAGE = "deeplink.apiKey"; @@ -62,6 +62,10 @@ export function listTypes(): Promise { return request("/types"); } +export function getStats(shortID: string): Promise { + return request(`/stats/${shortID}`); +} + export function createLink(draft: LinkDraft): Promise<{ short_url: string }> { return request<{ short_url: string }>("/shorten", { method: "POST", diff --git a/web/src/components/LinkDetail.tsx b/web/src/components/LinkDetail.tsx index bd8be80..a38c1d2 100644 --- a/web/src/components/LinkDetail.tsx +++ b/web/src/components/LinkDetail.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; -import type { LinkResponse } from "../types"; +import { useEffect, useState } from "react"; +import type { LinkResponse, Stats } from "../types"; +import { getStats } from "../api"; interface Props { link: LinkResponse; @@ -34,8 +35,47 @@ function Field({ label, value, mono, wide }: FieldProps) { ); } +function Breakdown({ title, data }: { title: string; data?: Record }) { + const entries = Object.entries(data ?? {}).sort((a, b) => b[1] - a[1]); + const max = entries.reduce((m, [, n]) => Math.max(m, n), 0); + return ( +
+
{title}
+ {entries.length === 0 ? ( +
+ ) : ( + entries.map(([key, n]) => ( +
+ + {key} + + + + + {n} +
+ )) + )} +
+ ); +} + export function LinkDetail({ link, onEdit, onDelete, onClose }: Props) { const [copied, setCopied] = useState(false); + const [stats, setStats] = useState(null); + const [statsError, setStatsError] = useState(null); + + useEffect(() => { + const id = link.short_id; + if (!id) return; + let active = true; + getStats(id) + .then((s) => active && setStats(s)) + .catch((e) => active && setStatsError(e instanceof Error ? e.message : String(e))); + return () => { + active = false; + }; + }, [link.short_id]); const copy = async () => { try { @@ -105,6 +145,7 @@ export function LinkDetail({ link, onEdit, onDelete, onClose }: Props) { /> + {link.expires_at && } {link.deleted_at && } {link.metadata && Object.keys(link.metadata).length > 0 && ( +
+
Analytics
+ {statsError ? ( +
stats unavailable
+ ) : !stats ? ( +
loading…
+ ) : ( +
+ + + +
+ )} +
+
+ {mode === "create" && ( +
+ + update("expires_at", fromLocalInput(e.target.value))} + /> +
+ )} +