|
1 | 1 | package main |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
5 | 6 | "crypto/subtle" |
6 | 7 | "encoding/json" |
7 | 8 | "errors" |
8 | 9 | "fmt" |
| 10 | + "html/template" |
9 | 11 | "log/slog" |
10 | 12 | "net/http" |
11 | 13 | "os" |
@@ -85,7 +87,15 @@ func run() error { |
85 | 87 | defer service.Close() //nolint:errcheck |
86 | 88 | service.Register(deeplink.RedirectProcessor{}) |
87 | 89 |
|
88 | | - handler := service.Handler() |
| 90 | + mux := http.NewServeMux() |
| 91 | + mux.Handle("/", service.Handler()) |
| 92 | + |
| 93 | + if dashTmpl := loadDashboardTemplate(templateDir); dashTmpl != nil { |
| 94 | + mux.HandleFunc("GET /dashboard", handleDashboard(service, dashTmpl, logger)) |
| 95 | + logger.Info("dashboard enabled at /dashboard") |
| 96 | + } |
| 97 | + |
| 98 | + var handler http.Handler = mux |
89 | 99 | if apiKey := os.Getenv("DEEPLINK_API_KEY"); apiKey != "" { |
90 | 100 | handler = withAPIKey(handler, apiKey) |
91 | 101 | logger.Info("API key protection enabled for mutating endpoints") |
@@ -188,6 +198,95 @@ func withAPIKey(next http.Handler, key string) http.Handler { |
188 | 198 | }) |
189 | 199 | } |
190 | 200 |
|
| 201 | +// dashboardMaxLinks caps the number of links loaded into memory per |
| 202 | +// request. The dashboard is a single-page HTML view, not a paginated |
| 203 | +// API — keeping this bounded avoids memory spikes on large instances. |
| 204 | +const dashboardMaxLinks = 500 |
| 205 | + |
| 206 | +type dashboardLink struct { |
| 207 | + ShortID string |
| 208 | + ShortLink string |
| 209 | + URL string |
| 210 | + Clicks int64 |
| 211 | +} |
| 212 | + |
| 213 | +type dashboardData struct { |
| 214 | + Links []dashboardLink |
| 215 | + TotalLinks int |
| 216 | + TotalClicks int64 |
| 217 | +} |
| 218 | + |
| 219 | +func loadDashboardTemplate(templateDir string) *template.Template { |
| 220 | + if templateDir == "" { |
| 221 | + return nil |
| 222 | + } |
| 223 | + path := filepath.Join(templateDir, "dashboard.html") |
| 224 | + if _, err := os.Stat(path); err != nil { |
| 225 | + return nil |
| 226 | + } |
| 227 | + tmpl, err := template.ParseFiles(path) |
| 228 | + if err != nil { |
| 229 | + return nil |
| 230 | + } |
| 231 | + return tmpl |
| 232 | +} |
| 233 | + |
| 234 | +func handleDashboard(service *deeplink.Service, tmpl *template.Template, logger *slog.Logger) http.HandlerFunc { |
| 235 | + cfg := service.Config() |
| 236 | + return func(w http.ResponseWriter, r *http.Request) { |
| 237 | + var raw []deeplink.LinkInfo |
| 238 | + for _, linkType := range service.Types() { |
| 239 | + var cursor uint64 |
| 240 | + for { |
| 241 | + links, next, err := cfg.Store.List(r.Context(), linkType, cfg.DefaultEnvironment, cursor, 100) |
| 242 | + if err != nil { |
| 243 | + logger.Error("dashboard: failed to list links", "error", err, "type", linkType) |
| 244 | + break |
| 245 | + } |
| 246 | + raw = append(raw, links...) |
| 247 | + if len(raw) >= dashboardMaxLinks { |
| 248 | + break |
| 249 | + } |
| 250 | + if next == 0 { |
| 251 | + break |
| 252 | + } |
| 253 | + cursor = next |
| 254 | + } |
| 255 | + if len(raw) >= dashboardMaxLinks { |
| 256 | + raw = raw[:dashboardMaxLinks] |
| 257 | + break |
| 258 | + } |
| 259 | + } |
| 260 | + |
| 261 | + links := make([]dashboardLink, len(raw)) |
| 262 | + var totalClicks int64 |
| 263 | + for i, l := range raw { |
| 264 | + links[i] = dashboardLink{ |
| 265 | + ShortID: l.ShortLink, |
| 266 | + ShortLink: cfg.BaseURL + l.ShortLink, |
| 267 | + URL: l.URL, |
| 268 | + Clicks: l.Clicks, |
| 269 | + } |
| 270 | + totalClicks += l.Clicks |
| 271 | + } |
| 272 | + |
| 273 | + var buf bytes.Buffer |
| 274 | + if err := tmpl.Execute(&buf, dashboardData{ |
| 275 | + Links: links, |
| 276 | + TotalLinks: len(links), |
| 277 | + TotalClicks: totalClicks, |
| 278 | + }); err != nil { |
| 279 | + logger.Error("dashboard: template error", "error", err) |
| 280 | + http.Error(w, "render error", http.StatusInternalServerError) |
| 281 | + return |
| 282 | + } |
| 283 | + |
| 284 | + w.Header().Set("Content-Type", "text/html") |
| 285 | + w.Header().Set("Cache-Control", "public, max-age=60") |
| 286 | + _, _ = w.Write(buf.Bytes()) |
| 287 | + } |
| 288 | +} |
| 289 | + |
191 | 290 | func loadSkipPaths(configured string) ([]string, error) { |
192 | 291 | path := configured |
193 | 292 | if path == "" { |
|
0 commit comments