Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ DEEPLINK_LISTEN_ADDR=:8090
DEEPLINK_BASE_URL=http://localhost:8090
DEEPLINK_REDIS_ADDR=localhost:6379
DEEPLINK_REDIS_PASSWORD=
DEEPLINK_ALLOWED_ORIGINS=
# Comma-separated origins allowed to call the JSON API from a browser.
DEEPLINK_ALLOWED_ORIGINS=http://localhost:5173
DEEPLINK_TEMPLATE_DIR=
DEEPLINK_SKIP_PATHS_FILE=
DEEPLINK_CLICK_BUFFER_SIZE=1024
Expand All @@ -19,3 +20,7 @@ DEEPLINK_API_KEY=
# DEEPLINK_LOCALE=en_US
# DEEPLINK_TWITTER_SITE=@example
# DEEPLINK_FEDIVERSE_CREATOR=@user@instance.tld

# web/ dashboard (Vite)
# VITE_API_URL is the Go service URL
VITE_API_URL=http://localhost:8091
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ seed.sh
/deeplink
/cmd/deeplink/deeplink
/example/custom/custom

# Dashboard SPA
/web/node_modules
/web/dist
/web/*.log
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,14 @@ go run ./cmd/deeplink
| Method | Path | Description |
| --- | --- | --- |
| POST | `/shorten` | Create a short link |
| PATCH | `/{shortID}` | Update mutable fields on a link |
| DELETE | `/{shortID}` | Soft-delete a link (3h grace) |
| GET | `/{shortID}` | Preview page (or 302 redirect) |
| 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 | `/health` | Health check |

The standalone server (`cmd/deeplink`) also registers:

| Method | Path | Description |
| --- | --- | --- |
| GET | `/dashboard` | Read-only link stats page (requires `dashboard.html` in template dir) |

When any store URL is set (`AndroidStoreURL`, `IOSStoreURL`, `WebFallbackURL`), these are also registered:

| Method | Path | Description |
Expand Down Expand Up @@ -194,6 +191,34 @@ curl -X POST http://localhost:8090/shorten \
Preview pages also emit `<meta name="robots" content="noindex,follow">` so
short links do not compete with the destination URL in search rankings.

## 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`.

```bash
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`.

## Development

```bash
Expand Down
105 changes: 5 additions & 100 deletions cmd/deeplink/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package main

import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
Expand Down Expand Up @@ -94,11 +92,6 @@ func run() error {
mux := http.NewServeMux()
mux.Handle("/", service.Handler())

if dashTmpl := loadDashboardTemplate(templateDir); dashTmpl != nil {
mux.HandleFunc("GET /dashboard", handleDashboard(service, dashTmpl, logger))
logger.Info("dashboard enabled at /dashboard")
}

var handler http.Handler = mux
if apiKey := os.Getenv("DEEPLINK_API_KEY"); apiKey != "" {
handler = withAPIKey(handler, apiKey)
Expand Down Expand Up @@ -182,13 +175,14 @@ func discoverTemplateDir(configured string) string {
return ""
}

// withAPIKey protects POST requests with a constant-time token check.
// Accepts both "Authorization: Bearer <key>" and "X-API-Key: <key>".
// GET routes (redirects, previews, dashboard) are not affected.
// withAPIKey protects mutating requests (POST, PATCH, DELETE) with a
// constant-time token check. Accepts both "Authorization: Bearer <key>"
// and "X-API-Key: <key>". GET routes (redirects, previews) are unaffected.
func withAPIKey(next http.Handler, key string) http.Handler {
keyBytes := []byte(key)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
switch r.Method {
case http.MethodPost, http.MethodPatch, http.MethodDelete:
token := r.Header.Get("X-API-Key")
if token == "" {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
Expand All @@ -202,95 +196,6 @@ func withAPIKey(next http.Handler, key string) http.Handler {
})
}

// dashboardMaxLinks caps the number of links loaded into memory per
// request. The dashboard is a single-page HTML view, not a paginated
// API — keeping this bounded avoids memory spikes on large instances.
const dashboardMaxLinks = 500

type dashboardLink struct {
ShortID string
ShortLink string
URL string
Clicks int64
}

type dashboardData struct {
Links []dashboardLink
TotalLinks int
TotalClicks int64
}

func loadDashboardTemplate(templateDir string) *template.Template {
if templateDir == "" {
return nil
}
path := filepath.Join(templateDir, "dashboard.html")
if _, err := os.Stat(path); err != nil {
return nil
}
tmpl, err := template.ParseFiles(path)
if err != nil {
return nil
}
return tmpl
}

func handleDashboard(service *deeplink.Service, tmpl *template.Template, logger *slog.Logger) http.HandlerFunc {
cfg := service.Config()
return func(w http.ResponseWriter, r *http.Request) {
var raw []deeplink.LinkInfo
for _, linkType := range service.Types() {
var cursor uint64
for {
links, next, err := cfg.Store.List(r.Context(), linkType, cursor, 100)
if err != nil {
logger.Error("dashboard: failed to list links", "error", err, "type", linkType)
break
}
raw = append(raw, links...)
if len(raw) >= dashboardMaxLinks {
break
}
if next == 0 {
break
}
cursor = next
}
if len(raw) >= dashboardMaxLinks {
raw = raw[:dashboardMaxLinks]
break
}
}

links := make([]dashboardLink, len(raw))
var totalClicks int64
for i, l := range raw {
links[i] = dashboardLink{
ShortID: l.ShortLink,
ShortLink: cfg.BaseURL + l.ShortLink,
URL: l.URL,
Clicks: l.Clicks,
}
totalClicks += l.Clicks
}

var buf bytes.Buffer
if err := tmpl.Execute(&buf, dashboardData{
Links: links,
TotalLinks: len(links),
TotalClicks: totalClicks,
}); err != nil {
logger.Error("dashboard: template error", "error", err)
http.Error(w, "render error", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "public, max-age=60")
_, _ = w.Write(buf.Bytes())
}
}

func loadSkipPaths(configured string) ([]string, error) {
path := configured
if path == "" {
Expand Down
91 changes: 75 additions & 16 deletions memory_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,24 @@ import (
"maps"
"sort"
"sync"
"time"
)

// MemoryStore implements [Store] using in-memory maps.
// Useful for tests, examples, and local development.
type MemoryStore struct {
mu sync.RWMutex
payload map[string]*Link
clicks map[string]int64
mu sync.RWMutex
payload map[string]*Link
clicks map[string]int64
deletedAt map[string]time.Time
}

// NewMemoryStore creates an in-memory store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
payload: make(map[string]*Link),
clicks: make(map[string]int64),
payload: make(map[string]*Link),
clicks: make(map[string]int64),
deletedAt: make(map[string]time.Time),
}
}

Expand All @@ -30,12 +33,17 @@ func (s *MemoryStore) Save(_ context.Context, id string, payload *Link) error {
cloned := cloneLink(payload)
cloned.ShortID = id
s.payload[id] = cloned
delete(s.deletedAt, id)
return nil
}

func (s *MemoryStore) Get(_ context.Context, id string) (*Link, error) {
s.mu.RLock()
defer s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()

if s.sweepLocked(id) {
return nil, ErrNotFound
}

payload, ok := s.payload[id]
if !ok {
Expand All @@ -45,6 +53,56 @@ func (s *MemoryStore) Get(_ context.Context, id string) (*Link, error) {
return cloneLink(payload), nil
}

func (s *MemoryStore) Delete(_ context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()

if s.sweepLocked(id) {
return ErrNotFound
}

payload, ok := s.payload[id]
if !ok || payload.DeletedAt != "" {
return ErrNotFound
}

now := time.Now().UTC()
payload.DeletedAt = now.Format(time.RFC3339)
s.deletedAt[id] = now
return nil
}

// sweepLocked removes id when its grace window has elapsed. The caller
// must hold s.mu (write lock). Returns true when the record was purged.
func (s *MemoryStore) sweepLocked(id string) bool {
t, ok := s.deletedAt[id]
if !ok {
return false
}
if time.Since(t) < DeleteGracePeriod {
return false
}
delete(s.payload, id)
delete(s.clicks, id)
delete(s.deletedAt, id)
return true
}

// sweepAllLocked snapshots the deleted-IDs map before sweeping so we
// don't mutate the map we're iterating. Caller must hold s.mu.
func (s *MemoryStore) sweepAllLocked() {
if len(s.deletedAt) == 0 {
return
}
ids := make([]string, 0, len(s.deletedAt))
for id := range s.deletedAt {
ids = append(ids, id)
}
for _, id := range ids {
s.sweepLocked(id)
}
}

func (s *MemoryStore) IncrClick(_ context.Context, id string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
Expand All @@ -60,35 +118,36 @@ func (s *MemoryStore) Clicks(_ context.Context, id string) (int64, error) {
return s.clicks[id], nil
}

func (s *MemoryStore) List(_ context.Context, linkType string, cursor uint64, count int64) ([]LinkInfo, uint64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
func (s *MemoryStore) List(_ context.Context, linkType string, cursor uint64, count int64) ([]LinkResponse, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()

if count <= 0 {
count = 100
}

s.sweepAllLocked()

ids := make([]string, 0, len(s.payload))
for id, payload := range s.payload {
if payload.Type == linkType {
if payload.Type == linkType && payload.DeletedAt == "" {
ids = append(ids, id)
}
}
sort.Strings(ids)

if cursor >= uint64(len(ids)) {
return []LinkInfo{}, 0, nil
return []LinkResponse{}, 0, nil
}

start := int(cursor)
end := min(start+int(count), len(ids))

links := make([]LinkInfo, 0, end-start)
links := make([]LinkResponse, 0, end-start)
for _, id := range ids[start:end] {
payload := s.payload[id]
links = append(links, LinkInfo{
links = append(links, LinkResponse{
Link: cloneLink(s.payload[id]),
ShortLink: id,
URL: payload.URL,
Clicks: s.clicks[id],
})
}
Expand Down
Loading
Loading