diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 45ea49b..649ee4f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,7 +17,7 @@ builds: goos: [linux, darwin, windows] goarch: [amd64, arm64] ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X github.com/spoo-me/spoo-cli/internal/api.Version={{.Version}} + - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X github.com/spoo-me/spoo-cli/internal/cmd.Version={{.Version}} archives: - formats: [tar.gz] diff --git a/README.md b/README.md index 732ecc1..facccff 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ - `Analytics Dashboard` - Terminal charts: traffic over time with a previous-period overlay, browser/OS/country/city/referrer panels, drill-down filtering, range expressions, and full mouse support πŸ“Š - `QR Codes` - Render any link as a scannable QR code right in the terminal πŸ“± - `Export` - Download click data as JSON, CSV, XLSX, or XML πŸ“€ -- `API Keys` - Create, list, and revoke keys for scripting πŸ”‘ - `Device-Flow Auth` - No keys to paste β€” sign in through the spoo.me device flow, sessions refresh for 30 days πŸͺͺ - `Pipe-Aware` - `--json` on every command, plain output when piped, `NO_COLOR` honored, self-host friendly 🧰 @@ -59,7 +58,7 @@ Grab a binary for macOS, Linux, or Windows β€” or a `.deb`, `.rpm`, or `.apk` pa # ⌨️ Shell Completion -Tab-completion covers commands, flags, **and live data** β€” your link aliases (`spoo open `), link and key IDs (`spoo links delete `, `spoo keys revoke `), and flag values like `--scopes`, `--domain`, and `--format`. +Tab-completion covers commands, flags, **and live data** β€” your link aliases (`spoo open `), link IDs (`spoo links delete `), and flag values like `--domain` and `--format`. Installed via **Homebrew**? It's wired up automatically β€” just open a new shell. @@ -95,7 +94,6 @@ spoo stats launch # charts in your terminal | `spoo links update/delete ` | Scriptable link management (`delete` requires `--yes`). | | `spoo stats [code]` | Interactive analytics dashboard: time chart with previous-period overlay (`p`), browser/OS/country/city/referrer panels, drill-down filtering (enter or click any row), link switcher (`g`), range expressions (`T` β€” `7d`, `4h`, `now - 2w to now - 1w`, `2026-01-01 to 2026-02-15`), clicks↔unique toggle (`u`), full mouse support. Piped or `--plain` prints a static report; public stats work logged out. | | `spoo export [code]` | Download analytics as JSON, CSV (zip), XLSX, or XML. | -| `spoo keys` | API keys: `create` (token shown once), list, `revoke`. | | `spoo open ` | Open a short link in your browser. | | `spoo inspect ` | See where a link points **without** counting a click. | | `spoo qr ` | Render a link as a scannable QR code in the terminal (also `Q` in `spoo links`). | diff --git a/cmd/spoo-mock/main.go b/cmd/spoo-mock/main.go new file mode 100644 index 0000000..b00fb2d --- /dev/null +++ b/cmd/spoo-mock/main.go @@ -0,0 +1,869 @@ +// Command spoo-mock is a local fake of the spoo.me API that serves rich, +// consistent, screenshot-ready data to every spoo CLI command. +// +// Usage: +// +// go run ./cmd/spoo-mock # serves on :8080 +// export SPOO_API_URL=http://localhost:8080 +// echo spoo_demo | spoo auth login --with-token +// spoo stats # full dashboard, ~287k clicks +// spoo links # long, paginated link browser +// spoo whoami +// +// All data is deterministic (values keyed off labels/dates, not the clock), +// so screenshots are reproducible run to run. Dates are relative to "now" +// so the activity always looks recent. +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + spoo "github.com/spoo-me/spoo-go" +) + +// ── reference data ────────────────────────────────────────────────────────── + +type weighted struct { + label string + w float64 +} + +// West-heavy, with a light touch of Asia / South Asia. +var countries = []weighted{ + {"United States", 38}, {"United Kingdom", 15}, {"Germany", 11}, + {"Canada", 9}, {"France", 7.5}, {"Netherlands", 6}, {"Australia", 5}, + {"Spain", 4}, {"Sweden", 3.5}, {"Ireland", 3}, {"Italy", 2.6}, + {"Switzerland", 2.2}, {"Norway", 1.8}, {"Belgium", 1.6}, + {"India", 4.2}, {"Japan", 2.4}, {"Singapore", 1.5}, {"Brazil", 2}, +} + +var cities = []weighted{ + {"San Francisco", 14}, {"New York", 12}, {"London", 11.5}, {"Berlin", 7}, + {"Toronto", 6}, {"Amsterdam", 5.5}, {"Austin", 5}, {"Seattle", 4.8}, + {"Paris", 4.5}, {"Los Angeles", 4}, {"Chicago", 3.6}, {"Sydney", 3.4}, + {"Dublin", 3}, {"Stockholm", 2.8}, {"Boston", 2.6}, {"Munich", 2.4}, + {"Vancouver", 2.2}, {"Zurich", 2}, {"Manchester", 1.8}, + {"Bangalore", 2.6}, {"Tokyo", 2.1}, {"Singapore", 1.4}, +} + +var browsers = []weighted{ + {"Chrome", 46}, {"Safari", 24}, {"Firefox", 11}, {"Edge", 9}, + {"Brave", 5}, {"Arc", 3}, {"Opera", 1.4}, {"Samsung Internet", 0.9}, +} + +var oses = []weighted{ + {"macOS", 34}, {"Windows", 30}, {"iOS", 16}, {"Android", 11}, + {"Linux", 7}, {"ChromeOS", 2}, +} + +var referrers = []weighted{ + {"Direct", 27}, {"google.com", 18}, {"x.com", 12}, + {"news.ycombinator.com", 9}, {"github.com", 8}, {"reddit.com", 6}, + {"producthunt.com", 5}, {"linkedin.com", 4.5}, {"newsletter.spoo.me", 3.5}, + {"dev.to", 2.6}, {"duckduckgo.com", 2}, {"bing.com", 1.4}, +} + +var weekdays = []weighted{ + {"Monday", 17}, {"Tuesday", 18}, {"Wednesday", 18.5}, {"Thursday", 17.5}, + {"Friday", 14}, {"Saturday", 7.5}, {"Sunday", 7.5}, +} + +// linkSeed is the showcase link list: long, important-looking URLs with +// varied clicks, statuses and flags. +type linkSeed struct { + alias, long string + clicks int + status string + pwd, bots, priv bool + maxClicks int + expireDays int + ageDays, lastDays int +} + +var linkSeeds = []linkSeed{ + {"launch-hq", "https://spoo.me/blog/2026/introducing-spoo-cli-the-fastest-way-to-shorten-and-analyze-links-without-leaving-your-terminal", 51240, "ACTIVE", false, true, false, 0, 0, 78, 0}, + {"hn", "https://news.ycombinator.com/item?id=41928374", 41205, "ACTIVE", false, false, false, 0, 0, 9, 0}, + {"pricing", "https://spoo.me/pricing?utm_source=producthunt&utm_medium=launch&utm_campaign=spoo-cli-2026&ref=top-banner", 33118, "ACTIVE", false, true, false, 0, 0, 64, 0}, + {"ph", "https://www.producthunt.com/posts/spoo-cli?comment=the-official-command-line-client-for-spoo-me-is-finally-here", 27840, "ACTIVE", false, false, false, 0, 0, 12, 0}, + {"docs-api", "https://docs.spoo.me/api-reference/endpoints/create-short-url-with-password-expiry-and-bot-protection", 24317, "ACTIVE", false, false, false, 0, 0, 120, 1}, + {"gh-release", "https://github.com/spoo-me/spoo-cli/releases/tag/v0.1.1", 18793, "ACTIVE", false, false, false, 0, 0, 1, 0}, + {"demo", "https://www.youtube.com/watch?v=8aGhZQkoFbQ&list=PLspoomeDemos&index=2&t=94s&ab_channel=spoome", 15622, "ACTIVE", false, false, false, 0, 0, 30, 0}, + {"discord", "https://discord.com/invite/spoo-me-community-for-link-shorteners-and-analytics-nerds", 12451, "ACTIVE", false, false, false, 0, 0, 210, 0}, + {"changelog", "https://spoo.me/changelog#v0-1-1-remove-gated-custom-domains-and-ship-the-homebrew-cask", 9871, "ACTIVE", false, false, false, 0, 0, 2, 0}, + {"blackfriday", "https://spoo.me/promo/black-friday-2026-pro-plan-fifty-percent-off-the-first-year-limited-time-only", 8654, "INACTIVE", false, true, false, 0, 0, 165, 40}, + {"survey", "https://forms.gle/x7Qd9LmP2vK4nR8s-developer-experience-survey-2026-help-shape-the-roadmap", 7342, "ACTIVE", false, false, false, 0, 0, 48, 1}, + {"newsletter-42", "https://newsletter.spoo.me/archive/issue-42-shipping-the-cli-and-what-we-learned-about-the-oauth-device-flow", 6238, "ACTIVE", false, false, false, 0, 0, 7, 0}, + {"brew", "https://github.com/spoo-me/homebrew-tap/blob/main/Casks/spoo.rb", 5121, "ACTIVE", false, false, false, 0, 0, 1, 0}, + {"careers-pe", "https://spoo.me/careers/senior-platform-engineer-distributed-systems-remote-emea-or-north-america-2026", 4517, "ACTIVE", false, false, false, 0, 0, 53, 2}, + {"status", "https://status.spoo.me/incidents/2026-06-redis-failover-postmortem-and-the-mitigations-we-shipped", 3984, "ACTIVE", false, false, false, 0, 0, 5, 0}, + {"roadmap", "https://github.com/orgs/spoo-me/projects/3/views/1?filterQuery=milestone%3Av0.2-shell-completion-and-upgrade-nudges", 2872, "ACTIVE", false, false, false, 0, 0, 21, 0}, + {"webinar", "https://us02web.zoom.us/webinar/register/WN_self-hosting-spoo-me-on-kubernetes-with-helm-redis-and-mongodb", 2214, "ACTIVE", false, false, false, 5000, 0, 16, 1}, + {"beta", "https://spoo.me/beta/custom-domains-early-access-allowlist-signup-q3-2026-join-the-waitlist", 1923, "ACTIVE", false, false, true, 0, 0, 33, 0}, + {"press", "https://spoo.me/press/media-kit-logos-brand-guidelines-and-high-resolution-product-screenshots.zip", 1346, "ACTIVE", false, false, false, 0, 0, 41, 3}, + {"figma", "https://www.figma.com/file/9aZ2kLpQ/spoo-me-dashboard-redesign-2026?node-id=812%3A4471&t=demo-share", 1188, "ACTIVE", true, false, true, 0, 0, 26, 1}, + {"gist", "https://gist.github.com/spoo-me/3f9c1e7b2a4d6e8f0c5a7b9d1e3f5a7c-deploy-with-docker-compose-and-caddy", 1034, "ACTIVE", false, false, false, 0, 0, 38, 2}, + {"q3-okrs", "https://notion.so/spoome/Q3-2026-OKRs-Growth-Reliability-and-Developer-Experience-1a2b3c4d5e6f7890", 879, "ACTIVE", true, false, true, 0, 0, 35, 4}, + {"stripe", "https://dashboard.stripe.com/test/payment-links/plink_1QspoomeProAnnualUpgradeFlow2026demo", 742, "ACTIVE", true, false, false, 0, 0, 58, 6}, + {"twitter-thread", "https://x.com/spoo_me/status/1798342176590283471-we-rebuilt-the-cli-from-scratch-heres-what-changed", 688, "ACTIVE", false, false, false, 0, 0, 11, 0}, + {"sponsors", "https://github.com/sponsors/spoo-me?frequency=recurring&utm_campaign=readme-footer-cta", 611, "ACTIVE", false, false, false, 0, 0, 90, 5}, + {"calendly", "https://calendly.com/spoo-me/30min-intro-call-self-hosting-and-enterprise-link-management", 540, "ACTIVE", false, false, false, 0, 0, 44, 3}, + {"k8s-guide", "https://docs.spoo.me/self-hosting/kubernetes-deployment-with-horizontal-pod-autoscaling-and-redis-sentinel", 498, "ACTIVE", false, false, false, 0, 0, 62, 2}, + {"talk", "https://www.youtube.com/watch?v=qZ3kP8nL1aA&t=612s-building-a-url-shortener-that-scales-fosdem-2026", 451, "ACTIVE", false, false, false, 0, 0, 73, 4}, + {"raycast", "https://www.raycast.com/spoo-me/spoo-me-shorten-and-manage-links-without-leaving-your-keyboard", 402, "ACTIVE", false, false, false, 0, 0, 51, 1}, + {"swag", "https://spoo.me/store/limited-edition-launch-week-stickers-and-the-terminal-ghost-enamel-pin", 366, "ACTIVE", false, false, false, 2000, 0, 28, 2}, + {"intern", "https://spoo.me/careers/open-source-engineering-intern-summer-2026-remote-stipend-included", 318, "ACTIVE", false, false, false, 0, 0, 47, 7}, + {"postman", "https://www.postman.com/spoo-me/workspace/spoo-me-public-api/collection/url-shortening-and-stats", 274, "ACTIVE", false, false, false, 0, 90, 36, 3}, + {"og-preview", "https://spoo.me/tools/open-graph-preview-debugger-for-shortened-links-with-rich-card-rendering", 233, "ACTIVE", false, false, false, 0, 0, 19, 1}, + {"security", "https://spoo.me/.well-known/security.txt-responsible-disclosure-policy-and-our-bug-bounty-scope", 196, "ACTIVE", false, false, false, 0, 0, 88, 9}, + {"old-promo", "https://spoo.me/promo/spring-2026-early-adopter-discount-code-EARLYBIRD-now-expired-thanks-everyone", 142, "INACTIVE", false, false, false, 0, 0, 132, 70}, + {"qr-batch", "https://spoo.me/tools/bulk-qr-code-generator-export-as-svg-png-and-pdf-for-print-campaigns", 118, "ACTIVE", false, false, false, 0, 0, 24, 5}, + {"affiliate", "https://spoo.me/partners/affiliate-program-terms-and-thirty-percent-recurring-commission-2026", 97, "ACTIVE", true, false, true, 0, 0, 60, 11}, + {"draft", "https://spoo.me/blog/drafts/the-economics-of-running-a-free-url-shortener-at-scale-unpublished", 54, "INACTIVE", true, false, true, 0, 0, 6, 6}, + {"test-link", "https://example.com/a/very/deep/path/that/keeps/going/to/test/truncation/in/the/terminal/ui/nicely", 38, "ACTIVE", false, false, false, 100, 14, 3, 0}, + {"wip", "https://spoo.me/labs/experimental-ai-powered-alias-suggestions-based-on-page-title-and-content", 21, "ACTIVE", false, true, true, 0, 0, 4, 1}, +} + +var ( + demoUser spoo.User + demoLinks []spoo.URLItem + now = time.Now().UTC() +) + +func init() { + demoUser = spoo.User{ + ID: "65f0a1b2c3d4e5f607182930", + Email: "demo@spoo.me", + EmailVerified: true, + Name: "spoo demo", + Plan: "pro", + } + demoLinks = make([]spoo.URLItem, len(linkSeeds)) + for i, s := range linkSeeds { + item := spoo.URLItem{ + ID: fmt.Sprintf("65f0%020x", i+1), + Alias: s.alias, + LongURL: s.long, + TotalClicks: s.clicks, + Status: s.status, + CreatedAt: spoo.Timestamp{Time: now.AddDate(0, 0, -s.ageDays)}, + LastClick: spoo.Timestamp{Time: now.AddDate(0, 0, -s.lastDays)}, + PasswordSet: s.pwd, + BlockBots: s.bots, + PrivateStats: s.priv, + } + if s.maxClicks > 0 { + mc := s.maxClicks + item.MaxClicks = &mc + } + if s.expireDays > 0 { + item.ExpireAfter = spoo.Timestamp{Time: now.AddDate(0, 0, s.expireDays)} + } + demoLinks[i] = item + } +} + +// ── deterministic series + distributions ──────────────────────────────────── + +func hash(s string) uint32 { + var h uint32 = 2166136261 + for i := 0; i < len(s); i++ { + h ^= uint32(s[i]) + h *= 16777619 + } + return h +} + +// uniqueRatio is a stable per-label unique/total ratio in [0.66, 0.80). +func uniqueRatio(label string) float64 { + return 0.66 + float64(hash(label)%14)/100 +} + +// dayShape is a deterministic daily traffic multiplier: an upward trend +// toward today, weekday dips, and a few launch spikes. +func dayShape(d time.Time) float64 { + daysAgo := int(now.Sub(d).Hours()/24 + 0.5) + if daysAgo < 0 { + daysAgo = 0 + } + t := daysAgo + if t > 90 { + t = 90 + } + trend := 1.0 + float64(90-t)/90*1.7 + wd := 1.0 // Tue/Wed/Thu + switch d.Weekday() { + case time.Saturday, time.Sunday: + wd = 0.55 + case time.Monday, time.Friday: + wd = 1.06 + } + spike := 1.0 + switch daysAgo { + case 1, 2: + spike = 2.7 // release week + case 9: + spike = 2.1 // hit the HN front page + case 12: + spike = 1.8 + case 30: + spike = 1.6 + } + return trend * wd * spike +} + +func timeSeries(start, end time.Time, total float64) (clicks, unique []map[string]any) { + if end.Before(start) { + start, end = end, start + } + days := int(end.Sub(start).Hours()/24) + 1 + if days < 1 { + days = 1 + } + if days > 120 { + days = 120 + } + shapes := make([]float64, days) + var sum float64 + for i := range shapes { + shapes[i] = dayShape(start.AddDate(0, 0, i)) + sum += shapes[i] + } + if sum == 0 { + sum = 1 + } + scale := total / sum + for i := range shapes { + label := start.AddDate(0, 0, i).Format("2006-01-02") + c := math.Round(shapes[i] * scale) + clicks = append(clicks, map[string]any{"time": label, "clicks": c}) + unique = append(unique, map[string]any{"time": label, "unique_clicks": math.Round(c * uniqueRatio(label))}) + } + return clicks, unique +} + +func distribute(dim string, items []weighted, total float64) (clicks, unique []map[string]any) { + var wsum float64 + for _, it := range items { + wsum += it.w + } + if wsum == 0 { + wsum = 1 + } + for _, it := range items { + c := math.Round(it.w / wsum * total) + clicks = append(clicks, map[string]any{dim: it.label, "clicks": c}) + unique = append(unique, map[string]any{dim: it.label, "unique_clicks": math.Round(c * uniqueRatio(it.label))}) + } + return clicks, unique +} + +// topLinkSeries builds the account-wide "top links" dimension straight +// from the link list, so the stats panel matches `spoo links`. +func topLinkSeries(n int) (clicks, unique []map[string]any) { + ranked := append([]spoo.URLItem(nil), demoLinks...) + sort.Slice(ranked, func(i, j int) bool { return ranked[i].TotalClicks > ranked[j].TotalClicks }) + if n > len(ranked) { + n = len(ranked) + } + for _, l := range ranked[:n] { + c := float64(l.TotalClicks) + clicks = append(clicks, map[string]any{"short_code": l.Alias, "clicks": c}) + unique = append(unique, map[string]any{"short_code": l.Alias, "unique_clicks": math.Round(c * uniqueRatio(l.Alias))}) + } + return clicks, unique +} + +func buildMetrics(start, end time.Time, total float64, perLink bool) map[string][]map[string]any { + m := map[string][]map[string]any{} + m["clicks_by_time"], m["unique_clicks_by_time"] = timeSeries(start, end, total) + for dim, items := range map[string][]weighted{ + "browser": browsers, "os": oses, "country": countries, + "city": cities, "referrer": referrers, "weekday": weekdays, + } { + m["clicks_by_"+dim], m["unique_clicks_by_"+dim] = distribute(dim, items, total) + } + if !perLink { + m["clicks_by_short_code"], m["unique_clicks_by_short_code"] = topLinkSeries(12) + } + return m +} + +// ── handlers ──────────────────────────────────────────────────────────────── + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func parseRange(q map[string][]string) (start, end time.Time) { + end = now + start = now.AddDate(0, 0, -spoo.MaxRangeDays) + if v := first(q, "start_date"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + start = t + } + } + if v := first(q, "end_date"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + end = t + } + } + return start, end +} + +func first(q map[string][]string, key string) string { + if vs := q[key]; len(vs) > 0 { + return vs[0] + } + return "" +} + +// shapeSum integrates dayShape over [start, end] so range totals scale +// with the traffic curve β€” a previous-period window lands lower on the +// upward trend and the dashboard's period-over-period delta reads real. +func shapeSum(start, end time.Time) float64 { + if end.Before(start) { + start, end = end, start + } + days := int(end.Sub(start).Hours()/24) + 1 + if days < 1 { + days = 1 + } + if days > 120 { + days = 120 + } + var sum float64 + for i := 0; i < days; i++ { + sum += dayShape(start.AddDate(0, 0, i)) + } + return sum +} + +// linkByAlias finds a demo link by its alias; nil when unknown. +func linkByAlias(alias string) *spoo.URLItem { + for i := range demoLinks { + if demoLinks[i].Alias == alias { + return &demoLinks[i] + } + } + return nil +} + +// linkByID finds a demo link by its url id; nil when unknown. +func linkByID(id string) *spoo.URLItem { + for i := range demoLinks { + if demoLinks[i].ID == id { + return &demoLinks[i] + } + } + return nil +} + +func notFound(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"URL not found","code":"not_found"}`)) +} + +// statsHandler serves the account-wide GET /api/v1/stats; short_code +// arrives only as a plain filter (drill-down), never as a target. +func statsHandler(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + code := first(q, "short_code") + + total := 0.0 + for _, l := range demoLinks { + total += float64(l.TotalClicks) + } + if code != "" { + total = 1200 // default if the filter value is unknown + if l := linkByAlias(code); l != nil { + total = float64(l.TotalClicks) + } + } + writeJSON(w, statsResponse(q, total, code != "", nil)) +} + +// linkStatsHandler serves the per-link GET /api/v1/stats/links/{id}. +func linkStatsHandler(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/stats/links/") + l := linkByID(id) + if l == nil { + notFound(w) + return + } + writeJSON(w, statsResponse(r.URL.Query(), float64(l.TotalClicks), true, l)) +} + +// demoLinkPassword unlocks every password-protected demo link. +const demoLinkPassword = "hunter2" + +// publicStatsHandler serves GET/POST /api/v1/public/stats/{code}: the +// same stats wire inside the {generation, link, stats} envelope. +// Password-protected links answer 401 like production does β€” the +// password travels in a POST body only, never the query string. +func publicStatsHandler(w http.ResponseWriter, r *http.Request) { + code := strings.TrimPrefix(r.URL.Path, "/api/v1/public/stats/") + l := linkByAlias(code) + if l == nil || l.PrivateStats { + notFound(w) + return + } + if l.PasswordSet { + var body struct { + Password string `json:"password"` + } + if r.Method == http.MethodPost { + _ = json.NewDecoder(r.Body).Decode(&body) + } + if body.Password != demoLinkPassword { + errCode := "password_required" + if body.Password != "" { + errCode = "invalid_password" + } + w.Header().Set("X-Error-Code", errCode) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"error":"Password required","code":%q}`, errCode) + return + } + } + writeJSON(w, map[string]any{ + "generation": "v2", + "link": map[string]any{ + "alias": l.Alias, + "short_url": "https://spoo.me/" + l.Alias, + "long_url": l.LongURL, + "created_at": l.CreatedAt, + "status": strings.ToLower(l.Status), + "password_protected": l.PasswordSet, + "block_bots": l.BlockBots, + }, + "stats": statsResponse(r.URL.Query(), float64(l.TotalClicks), true, l), + }) +} + +// statsResponse builds the standard stats wire for a date range and +// total; perLink drops the top-links dimension, and link (when set) +// echoes url_id and alias the way the per-link endpoints do. +func statsResponse(q map[string][]string, total float64, perLink bool, link *spoo.URLItem) spoo.StatsResponse { + start, end := parseRange(q) + if base := shapeSum(now.AddDate(0, 0, -spoo.MaxRangeDays), now); base > 0 { + total = math.Round(total * shapeSum(start, end) / base) + } + + uniqueTotal := math.Round(total * 0.713) + resp := spoo.StatsResponse{ + Summary: spoo.StatsSummary{ + TotalClicks: int(total), + UniqueClicks: int(uniqueTotal), + FirstClick: spoo.Timestamp{Time: start}, + LastClick: spoo.Timestamp{Time: now.Add(-37 * time.Minute)}, + AvgRedirectionTime: 28.4, + }, + TimeRange: spoo.StatsTimeRange{ + StartDate: spoo.Timestamp{Time: start}, + EndDate: spoo.Timestamp{Time: end}, + }, + Metrics: buildMetrics(start, end, total, perLink), + ComputedMetrics: map[string]float64{ + "unique_click_rate": 71.3, + "repeat_click_rate": 28.7, + "average_clicks_per_visitor": 1.4, + }, + GeneratedAt: spoo.Timestamp{Time: now}, + } + if link != nil { + resp.URLID, resp.Alias = link.ID, link.Alias + } + return resp +} + +func urlsHandler(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + items := append([]spoo.URLItem(nil), demoLinks...) + + // filter (search + status come through the `filter` JSON blob) + if blob := first(q, "filter"); blob != "" { + var f struct { + Search string `json:"search"` + Status string `json:"status"` + } + _ = json.Unmarshal([]byte(blob), &f) + if f.Search != "" { + needle := strings.ToLower(f.Search) + kept := items[:0] + for _, l := range items { + if strings.Contains(strings.ToLower(l.Alias), needle) || + strings.Contains(strings.ToLower(l.LongURL), needle) { + kept = append(kept, l) + } + } + items = kept + } + if f.Status != "" { + kept := items[:0] + for _, l := range items { + if l.Status == f.Status { + kept = append(kept, l) + } + } + items = kept + } + } + + // sort + desc := first(q, "sortOrder") != "ascending" + switch first(q, "sortBy") { + case "created_at": + sort.SliceStable(items, func(i, j int) bool { return less(items[i].CreatedAt.After(items[j].CreatedAt.Time), desc) }) + case "last_click": + sort.SliceStable(items, func(i, j int) bool { return less(items[i].LastClick.After(items[j].LastClick.Time), desc) }) + default: // total_clicks + sort.SliceStable(items, func(i, j int) bool { return less(items[i].TotalClicks > items[j].TotalClicks, desc) }) + } + + total := len(items) + page := atoiOr(first(q, "page"), 1) + size := atoiOr(first(q, "pageSize"), 20) + if size <= 0 { + size = 20 + } + start := (page - 1) * size + if start < 0 || start > total { + start = total + } + end := start + size + if end > total { + end = total + } + writeJSON(w, spoo.URLPage{ + Items: items[start:end], + Page: page, + PageSize: size, + Total: total, + HasNext: end < total, + }) +} + +func less(naturalDesc, desc bool) bool { + if desc { + return naturalDesc + } + return !naturalDesc +} + +func atoiOr(s string, def int) int { + if n, err := strconv.Atoi(s); err == nil { + return n + } + return def +} + +// ── auth ──────────────────────────────────────────────────────────────────── + +// deviceSession is the mock's single device-flow login: one live JWT +// pair, rotated on every refresh. Presenting a rotated-out refresh +// token answers 401 β€” exactly how a real expired session presents. +type deviceSession struct { + mu sync.Mutex + n int + access string + refresh string +} + +func (s *deviceSession) issue() (access, refresh string) { + s.mu.Lock() + defer s.mu.Unlock() + s.n++ + s.access = fmt.Sprintf("at-%d", s.n) + s.refresh = fmt.Sprintf("rt-%d", s.n) + return s.access, s.refresh +} + +func (s *deviceSession) rotate(refresh string) (string, string, bool) { + s.mu.Lock() + if s.refresh == "" || refresh != s.refresh { + s.mu.Unlock() + return "", "", false + } + s.mu.Unlock() + access, next := s.issue() + return access, next, true +} + +func (s *deviceSession) validAccess(token string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.access != "" && token == s.access +} + +var device deviceSession + +// authorized accepts any spoo_ API key (the mock is single-user) or +// the device session's current access token. A stale access token is +// rejected, which is what drives the CLI's refresh path end to end. +func authorized(r *http.Request) bool { + auth := r.Header.Get("Authorization") + bearer, ok := strings.CutPrefix(auth, "Bearer ") + if !ok || bearer == "" { + return false + } + if strings.HasPrefix(bearer, "spoo_") { + return true + } + return device.validAccess(bearer) +} + +func unauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"authentication required","code":"AUTHENTICATION_ERROR"}`)) +} + +// requireAuth gates the owner endpoints the way production does. +func requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + unauthorized(w) + return + } + next(w, r) + } +} + +// claimTokens holds the outstanding anonymous-creation proofs: +// url id β†’ one-time token, burned on claim. +var ( + claimMu sync.Mutex + claimTokens = map[string]string{} + claimedIDs = map[string]bool{} +) + +// ── handlers wired in main ────────────────────────────────────────────────── + +func deviceTokenHandler(w http.ResponseWriter, r *http.Request) { + var body struct { + AppID string `json:"app_id"` + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.AppID != "spoo-cli" || body.Code == "" || body.CodeVerifier == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid device code exchange","code":"VALIDATION_ERROR"}`)) + return + } + access, refresh := device.issue() + writeJSON(w, map[string]any{ + "access_token": access, "refresh_token": refresh, "user": demoUser, + }) +} + +func deviceRefreshHandler(w http.ResponseWriter, r *http.Request) { + var body struct { + RefreshToken string `json:"refresh_token"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + access, refresh, ok := device.rotate(body.RefreshToken) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid refresh token","code":"AUTHENTICATION_ERROR"}`)) + return + } + writeJSON(w, map[string]any{"access_token": access, "refresh_token": refresh}) +} + +func shortenHandler(w http.ResponseWriter, r *http.Request) { + var req spoo.ShortenRequest + _ = json.NewDecoder(r.Body).Decode(&req) + alias := req.Alias + if alias == "" { + alias = fmt.Sprintf("g%05x", hash(req.LongURL)%0xfffff) + } + res := spoo.ShortURL{ + ID: fmt.Sprintf("65f1%020x", hash(alias)), + ShortURL: "https://spoo.me/" + alias, Alias: alias, + LongURL: req.LongURL, CreatedAt: spoo.Timestamp{Time: now}, Status: "ACTIVE", + } + if authorized(r) { + res.OwnerID = demoUser.ID + } else { + // anonymous creations carry a one-time claim token + res.ClaimToken = fmt.Sprintf("claim-%08x", hash(alias+req.LongURL)) + claimMu.Lock() + claimTokens[res.ID] = res.ClaimToken + claimMu.Unlock() + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, res) +} + +// claimHandler serves POST /api/v1/urls/claim: {url_id, token} pairs, +// max 16, resolved independently β€” per-item outcomes, never a batch +// failure. +func claimHandler(w http.ResponseWriter, r *http.Request) { + var body struct { + Claims []struct { + URLID string `json:"url_id"` + Token string `json:"token"` + } `json:"claims"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if len(body.Claims) == 0 || len(body.Claims) > 16 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"claims must contain 1 to 16 items","code":"VALIDATION_ERROR","field":"claims"}`)) + return + } + claimMu.Lock() + defer claimMu.Unlock() + claimed := 0 + results := make([]map[string]string, 0, len(body.Claims)) + for _, c := range body.Claims { + status := "invalid" + switch { + case claimTokens[c.URLID] != "" && claimTokens[c.URLID] == c.Token: + delete(claimTokens, c.URLID) // token burns on claim + claimedIDs[c.URLID] = true + status = "claimed" + claimed++ + case claimedIDs[c.URLID]: + status = "already_yours" + } + results = append(results, map[string]string{"url_id": c.URLID, "status": status}) + } + writeJSON(w, map[string]any{"results": results, "claimed": claimed}) +} + +// exportHandler serves the unified GET /api/v1/export. A url_id param +// slices the export to one link; an unknown id yields an empty file, +// not a 404 β€” consistent with the slicing filters. +func exportHandler(w http.ResponseWriter, r *http.Request) { + format := first(r.URL.Query(), "format") + if format == "" { + format = "json" + } + ext := format + if format == "csv" { + ext = "zip" + } + name := "spoo-demo-export." + ext + payload := `{"export":"demo","rows":2840,"generated_at":"` + now.Format(time.RFC3339) + `"}` + if id := first(r.URL.Query(), "url_id"); id != "" { + if l := linkByID(id); l != nil { + name = "spoo-" + l.Alias + "-export." + ext + payload = fmt.Sprintf(`{"export":"demo","alias":%q,"rows":%d}`, l.Alias, l.TotalClicks) + } else { + payload = "" // unknown id: an empty slice of the export + } + } + w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) +} + +// urlHandler serves the /api/v1/urls/ subtree: GET {id}, GET +// {domain}/{alias} (resolve), PATCH {id}, PATCH {id}/status, DELETE +// {id}, and POST claim. +func urlHandler(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/api/v1/urls/") + segs := strings.Split(rest, "/") + if rest == "claim" && r.Method == http.MethodPost { + claimHandler(w, r) + return + } + switch { + case r.Method == http.MethodGet && len(segs) == 2: + // GET {domain}/{alias} resolves a link to its id + if alias, err := url.PathUnescape(segs[1]); err == nil { + if l := linkByAlias(alias); l != nil { + writeJSON(w, l) + return + } + } + notFound(w) + case r.Method == http.MethodGet && len(segs) == 1: + if l := linkByID(segs[0]); l != nil { + writeJSON(w, l) + return + } + notFound(w) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPatch && len(segs) == 2 && segs[1] == "status": + var body struct { + Status string `json:"status"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if l := linkByID(segs[0]); l != nil { + l.Status = body.Status // the TUI refetches; keep the list honest + } + writeJSON(w, map[string]any{"id": segs[0], "status": body.Status, "updated_at": now.Unix()}) + default: + // generic PATCH: succeed so the TUI flows work for screenshots + writeJSON(w, map[string]any{"id": segs[0], "status": "ACTIVE", "updated_at": now.Unix()}) + } +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/stats", requireAuth(statsHandler)) + mux.HandleFunc("/api/v1/stats/links/", requireAuth(linkStatsHandler)) + mux.HandleFunc("/api/v1/public/stats/", publicStatsHandler) + mux.HandleFunc("/api/v1/urls", requireAuth(urlsHandler)) + mux.HandleFunc("/api/v1/urls/", requireAuth(urlHandler)) + mux.HandleFunc("/api/v1/export", requireAuth(exportHandler)) + mux.HandleFunc("/auth/me", requireAuth(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"user": demoUser}) + })) + mux.HandleFunc("/auth/device/token", deviceTokenHandler) + mux.HandleFunc("/auth/device/refresh", deviceRefreshHandler) + mux.HandleFunc("/api/v1/shorten/check-alias", func(w http.ResponseWriter, r *http.Request) { + alias := r.URL.Query().Get("alias") + taken := alias == "docs" || alias == "pricing" || alias == "ph" + reason := "" + if taken { + reason = "alias already in use" + } + writeJSON(w, spoo.AliasCheck{Available: !taken, Reason: reason}) + }) + mux.HandleFunc("/api/v1/shorten", shortenHandler) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // serve the redirect edge too, so `spoo inspect` and `spoo open` + // behave: /{alias} answers 302 to the destination + if alias := strings.TrimPrefix(r.URL.Path, "/"); alias != "" { + if l := linkByAlias(alias); l != nil { + http.Redirect(w, r, l.LongURL, http.StatusFound) + return + } + notFound(w) + return + } + writeJSON(w, map[string]any{"ok": true}) + }) + + addr := ":8080" + if p := os.Getenv("PORT"); p != "" { + addr = ":" + p + } + fmt.Fprintf(os.Stderr, "spoo-mock serving rich demo data on http://localhost%s\n", addr) + fmt.Fprintf(os.Stderr, " export SPOO_API_URL=http://localhost%s && echo spoo_demo | spoo auth login --with-token\n", addr) + srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + if err := srv.ListenAndServe(); err != nil { + fmt.Fprintln(os.Stderr, "spoo-mock:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 973c5a8..58b5d64 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,10 @@ require ( github.com/NimbleMarkets/ntcharts/v2 v2.2.0 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/colorprofile v0.4.3 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 + github.com/spoo-me/spoo-go v0.4.0 github.com/zalando/go-keyring v0.2.8 golang.org/x/term v0.44.0 ) @@ -36,7 +38,6 @@ require ( github.com/lrstanley/bubblezone/v2 v2.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect diff --git a/go.sum b/go.sum index 638ed90..44493ef 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spoo-me/spoo-go v0.4.0 h1:4sVce0UDrKZFKViIWkSGs6s+jqA5IybNgvYfQr48fqU= +github.com/spoo-me/spoo-go v0.4.0/go.mod h1:6WuLTT53iUtSH5P1JwU2PR1T/JQgt7iE2kcDeQMNP+g= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/internal/api/auth.go b/internal/api/auth.go deleted file mode 100644 index 5f7d732..0000000 --- a/internal/api/auth.go +++ /dev/null @@ -1,41 +0,0 @@ -package api - -import ( - "context" - "net/http" -) - -type User struct { - ID string `json:"id"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Name string `json:"name"` - Plan string `json:"plan"` -} - -type DeviceTokens struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - User User `json:"user"` -} - -// ExchangeDeviceCode trades a one-time device-auth code for a JWT pair. -// The code is the credential β€” no prior auth is required. The verifier is -// the PKCE code verifier whose S256 challenge was sent on the login URL. -func (c *Client) ExchangeDeviceCode(ctx context.Context, code, verifier string) (*DeviceTokens, error) { - var out DeviceTokens - if err := c.do(ctx, http.MethodPost, "/auth/device/token", nil, map[string]string{"code": code, "code_verifier": verifier}, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) Me(ctx context.Context) (*User, error) { - var out struct { - User User `json:"user"` - } - if err := c.do(ctx, http.MethodGet, "/auth/me", nil, nil, &out); err != nil { - return nil, err - } - return &out.User, nil -} diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go deleted file mode 100644 index f5b026e..0000000 --- a/internal/api/auth_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestExchangeDeviceCode(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/auth/device/token" || r.Method != http.MethodPost { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Errorf("decode body: %v", err) - } - if body["code"] != "onetimecode" { - t.Errorf("code = %q, want onetimecode", body["code"]) - } - if body["code_verifier"] != "theverifier" { - t.Errorf("code_verifier = %q, want theverifier", body["code_verifier"]) - } - w.Write([]byte(`{"access_token":"at","refresh_token":"rt","user":{"id":"1","email":"a@b.c","email_verified":true,"name":"A","plan":"free"}}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - tok, err := c.ExchangeDeviceCode(context.Background(), "onetimecode", "theverifier") - if err != nil { - t.Fatal(err) - } - if tok.AccessToken != "at" || tok.User.Email != "a@b.c" { - t.Fatalf("unexpected: %+v", tok) - } -} - -func TestMe(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"user":{"id":"1","email":"a@b.c","email_verified":true,"name":"A","plan":"free"}}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - u, err := c.Me(context.Background()) - if err != nil { - t.Fatal(err) - } - if u.Email != "a@b.c" || !u.EmailVerified { - t.Fatalf("unexpected user: %+v", u) - } -} diff --git a/internal/api/client.go b/internal/api/client.go deleted file mode 100644 index dbfc05c..0000000 --- a/internal/api/client.go +++ /dev/null @@ -1,194 +0,0 @@ -// Package api is a typed client for the spoo.me HTTP API. -package api - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strings" - "time" - - "github.com/spoo-me/spoo-cli/internal/auth" -) - -// Version is the CLI release, injected by goreleaser via ldflags. -var Version = "dev" - -var versionRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,16}$`) - -// clientHeader identifies the CLI (and its version, when well-formed) to -// the backend so API traffic can be attributed per client. -func clientHeader() string { - if versionRe.MatchString(Version) { - return "cli/" + Version - } - return "cli" -} - -type Client struct { - base string - http *http.Client - store *auth.Store -} - -func New(base string, store *auth.Store) *Client { - return &Client{ - base: strings.TrimRight(base, "/"), - http: &http.Client{ - Timeout: 30 * time.Second, - // Go forwards custom headers on redirects, including - // cross-origin ones. Attribution belongs to the spoo API - // only, so drop it whenever a redirect leaves the original - // host. Go itself strips Authorization on cross-domain hops. - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Host != via[0].URL.Host { - req.Header.Del("X-Spoo-Client") - } - return nil - }, - }, - store: store, - } -} - -// APIError mirrors the backend's error envelope {error, code, detail}. -type APIError struct { - Status int `json:"-"` - Code string `json:"code"` - Message string `json:"error"` - Detail string `json:"detail"` -} - -func (e *APIError) Error() string { - if e.Detail != "" { - return fmt.Sprintf("%s (%s)", e.Message, e.Detail) - } - return e.Message -} - -// IsNotFound reports whether err is an API 404 β€” for the resolve-first -// endpoints that means "no such link, or not yours". -func IsNotFound(err error) bool { - var apiErr *APIError - return errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound -} - -func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error { - resp, err := c.request(ctx, method, path, query, body) - if err != nil { - return err - } - defer resp.Body.Close() - return decode(resp, out) -} - -// request performs an authenticated call and returns the raw response, -// refreshing device tokens once on 401. Callers own the response body. -func (c *Client) request(ctx context.Context, method, path string, query url.Values, body any) (*http.Response, error) { - creds, err := c.store.Load() - if err != nil && !errors.Is(err, auth.ErrNotLoggedIn) { - return nil, err - } - resp, err := c.send(ctx, method, path, query, body, creds) - if err != nil { - return nil, err - } - if resp.StatusCode == http.StatusUnauthorized { - // The public stats endpoint answers 401 for password-protected - // links. That is a property of the link, not of the session, so - // refreshing tokens can't help β€” and the CLI doesn't supply link - // passwords, so say so instead of blaming the login. - switch resp.Header.Get("X-Error-Code") { - case "password_required", "invalid_password": - resp.Body.Close() - return nil, errors.New("this link's stats are password protected") - } - if creds != nil && creds.Mode == auth.ModeDevice && creds.RefreshToken != "" { - resp.Body.Close() - if creds, err = c.refreshTokens(ctx, creds); err != nil { - return nil, err - } - if resp, err = c.send(ctx, method, path, query, body, creds); err != nil { - return nil, err - } - } - } - return resp, nil -} - -func (c *Client) send(ctx context.Context, method, path string, query url.Values, body any, creds *auth.Credentials) (*http.Response, error) { - u := c.base + path - if len(query) > 0 { - u += "?" + query.Encode() - } - var rdr io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, err - } - rdr = bytes.NewReader(data) - } - req, err := http.NewRequestWithContext(ctx, method, u, rdr) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "spoo-cli") - req.Header.Set("X-Spoo-Client", clientHeader()) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if creds != nil { - switch creds.Mode { - case auth.ModeAPIKey: - req.Header.Set("Authorization", "Bearer "+creds.APIKey) - case auth.ModeDevice: - req.Header.Set("Authorization", "Bearer "+creds.AccessToken) - } - } - return c.http.Do(req) -} - -// refreshTokens exchanges the refresh token for a new pair and persists it. -// The backend rotates refresh tokens, so the stored pair must be replaced. -func (c *Client) refreshTokens(ctx context.Context, creds *auth.Credentials) (*auth.Credentials, error) { - resp, err := c.send(ctx, http.MethodPost, "/auth/device/refresh", nil, - map[string]string{"refresh_token": creds.RefreshToken}, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var out struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - } - if err := decode(resp, &out); err != nil { - return nil, fmt.Errorf("session expired β€” run `spoo auth login` again: %w", err) - } - updated := *creds - updated.AccessToken = out.AccessToken - updated.RefreshToken = out.RefreshToken - if err := c.store.Save(updated); err != nil { - return nil, err - } - return &updated, nil -} - -func decode(resp *http.Response, out any) error { - if resp.StatusCode >= 400 { - apiErr := &APIError{Status: resp.StatusCode, Message: resp.Status} - data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - _ = json.Unmarshal(data, apiErr) - return apiErr - } - if out == nil { - return nil - } - return json.NewDecoder(resp.Body).Decode(out) -} diff --git a/internal/api/client_test.go b/internal/api/client_test.go deleted file mode 100644 index 885ad5c..0000000 --- a/internal/api/client_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "github.com/zalando/go-keyring" - - "github.com/spoo-me/spoo-cli/internal/auth" -) - -func newTestStore(t *testing.T, c *auth.Credentials) *auth.Store { - t.Helper() - keyring.MockInit() - _ = keyring.Delete("spoo-cli", "credentials") - s := auth.NewStore(t.TempDir()) - if c != nil { - if err := s.Save(*c); err != nil { - t.Fatal(err) - } - } - return s -} - -func TestDoSendsBearerToken(t *testing.T) { - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - w.Write([]byte(`{}`)) - })) - defer srv.Close() - - store := newTestStore(t, &auth.Credentials{Mode: auth.ModeDevice, AccessToken: "tok123", RefreshToken: "rt"}) - c := New(srv.URL, store) - if err := c.do(context.Background(), http.MethodGet, "/auth/me", nil, nil, nil); err != nil { - t.Fatal(err) - } - if gotAuth != "Bearer tok123" { - t.Fatalf("Authorization = %q, want Bearer tok123", gotAuth) - } -} - -func TestDoSendsClientHeader(t *testing.T) { - var gotClient string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotClient = r.Header.Get("X-Spoo-Client") - w.Write([]byte(`{}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - if err := c.do(context.Background(), http.MethodGet, "/auth/me", nil, nil, nil); err != nil { - t.Fatal(err) - } - if gotClient != "cli/dev" { - t.Fatalf("X-Spoo-Client = %q, want cli/dev", gotClient) - } -} - -func TestClientHeaderRejectsMalformedVersion(t *testing.T) { - orig := Version - defer func() { Version = orig }() - for version, want := range map[string]string{ - "1.2.3": "cli/1.2.3", - "0.2.0-SNAPSHOT-697203b": "cli", // >16 chars - "1.0+meta": "cli", // invalid charset - "": "cli", - } { - Version = version - if got := clientHeader(); got != want { - t.Errorf("clientHeader() with Version=%q = %q, want %q", version, got, want) - } - } -} - -func TestDoParsesErrorEnvelope(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusConflict) - w.Write([]byte(`{"error":"alias already taken","code":"CONFLICT_ERROR","detail":"try another"}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - err := c.do(context.Background(), http.MethodPost, "/api/v1/shorten", nil, map[string]string{}, nil) - var apiErr *APIError - if !errors.As(err, &apiErr) { - t.Fatalf("err = %v, want *APIError", err) - } - if apiErr.Status != 409 || apiErr.Code != "CONFLICT_ERROR" || apiErr.Message != "alias already taken" { - t.Fatalf("unexpected APIError: %+v", apiErr) - } -} - -func TestDoRefreshesOn401AndRetries(t *testing.T) { - var calls atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/auth/device/refresh": - w.Write([]byte(`{"access_token":"newAT","refresh_token":"newRT"}`)) - case "/auth/me": - if r.Header.Get("Authorization") == "Bearer newAT" { - w.Write([]byte(`{"user":{"id":"1"}}`)) - return - } - calls.Add(1) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"token expired","code":"AUTHENTICATION_ERROR"}`)) - } - })) - defer srv.Close() - - store := newTestStore(t, &auth.Credentials{Mode: auth.ModeDevice, AccessToken: "staleAT", RefreshToken: "oldRT"}) - c := New(srv.URL, store) - if err := c.do(context.Background(), http.MethodGet, "/auth/me", nil, nil, nil); err != nil { - t.Fatal(err) - } - if calls.Load() != 1 { - t.Fatalf("expected exactly one 401 before refresh, got %d", calls.Load()) - } - // rotated tokens must be persisted - got, err := store.Load() - if err != nil { - t.Fatal(err) - } - if got.AccessToken != "newAT" || got.RefreshToken != "newRT" { - t.Fatalf("store not updated after refresh: %+v", got) - } -} - -func TestClientHeaderStrippedOnCrossOriginRedirect(t *testing.T) { - gotClient := "unset" - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotClient = r.Header.Get("X-Spoo-Client") - w.Write([]byte(`{}`)) - })) - defer target.Close() - - redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, target.URL+"/final", http.StatusFound) - })) - defer redirector.Close() - - c := New(redirector.URL, newTestStore(t, nil)) - if err := c.do(context.Background(), http.MethodGet, "/start", nil, nil, nil); err != nil { - t.Fatal(err) - } - if gotClient != "" { - t.Fatalf("X-Spoo-Client forwarded cross-origin = %q, want empty", gotClient) - } -} - -func TestClientHeaderKeptOnSameHostRedirect(t *testing.T) { - gotClient := "unset" - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/start" { - http.Redirect(w, r, "/final", http.StatusFound) - return - } - gotClient = r.Header.Get("X-Spoo-Client") - w.Write([]byte(`{}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - if err := c.do(context.Background(), http.MethodGet, "/start", nil, nil, nil); err != nil { - t.Fatal(err) - } - if gotClient != "cli/dev" { - t.Fatalf("X-Spoo-Client after same-host redirect = %q, want cli/dev", gotClient) - } -} - -// a password-protected link answers 401 too, but that's about the link, -// not the session β€” no token refresh, and an honest error instead of -// "session expired". -func TestDo401PasswordRequiredSkipsRefresh(t *testing.T) { - var refreshCalls atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/auth/device/refresh" { - refreshCalls.Add(1) - w.Write([]byte(`{"access_token":"newAT","refresh_token":"newRT"}`)) - return - } - w.Header().Set("X-Error-Code", "password_required") - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"Password required","code":"password_required"}`)) - })) - defer srv.Close() - - store := newTestStore(t, &auth.Credentials{Mode: auth.ModeDevice, AccessToken: "goodAT", RefreshToken: "goodRT"}) - c := New(srv.URL, store) - err := c.do(context.Background(), http.MethodGet, "/api/v1/public/stats/secret", nil, nil, nil) - if err == nil || !strings.Contains(err.Error(), "password protected") { - t.Fatalf("err = %v, want a password-protected explanation", err) - } - if refreshCalls.Load() != 0 { - t.Fatalf("refresh called %d times, want 0", refreshCalls.Load()) - } - // the healthy session must survive untouched - got, loadErr := store.Load() - if loadErr != nil { - t.Fatal(loadErr) - } - if got.AccessToken != "goodAT" || got.RefreshToken != "goodRT" { - t.Fatalf("tokens rotated pointlessly: %+v", got) - } -} diff --git a/internal/api/expiry.go b/internal/api/expiry.go deleted file mode 100644 index 71e11e9..0000000 --- a/internal/api/expiry.go +++ /dev/null @@ -1,27 +0,0 @@ -package api - -import ( - "fmt" - "strconv" - "time" -) - -// ParseExpiry normalizes user expiry input to RFC 3339, which the -// backend always accepts. Durations ("30m", "72h") are relative to -// now; bare epoch seconds are converted; anything else passes through -// as ISO 8601. Empty input yields an empty string (no expiry change). -func ParseExpiry(raw string, now time.Time) (string, error) { - if raw == "" { - return "", nil - } - if d, err := time.ParseDuration(raw); err == nil { - if d <= 0 { - return "", fmt.Errorf("expiry duration must be positive, got %q", raw) - } - return now.Add(d).UTC().Format(time.RFC3339), nil - } - if epoch, err := strconv.ParseInt(raw, 10, 64); err == nil { - return time.Unix(epoch, 0).UTC().Format(time.RFC3339), nil - } - return raw, nil -} diff --git a/internal/api/export.go b/internal/api/export.go deleted file mode 100644 index f9df48e..0000000 --- a/internal/api/export.go +++ /dev/null @@ -1,51 +0,0 @@ -package api - -import ( - "context" - "fmt" - "io" - "mime" - "net/http" - "net/url" -) - -// Export downloads account-wide stats in the given format (json, csv, -// xlsx, xml). Auth is required β€” anonymous export no longer exists. -// Returns the server-suggested filename and the file contents. -// csv arrives as a ZIP archive (one CSV per dimension). -func (c *Client) Export(ctx context.Context, q StatsQuery, format string) (string, []byte, error) { - return c.export(ctx, "/api/v1/export", q, format) -} - -// ExportLink downloads one owned link's stats by its url id (resolve -// an alias with ResolveAlias first). Unknown and foreign ids both 404. -func (c *Client) ExportLink(ctx context.Context, urlID string, q StatsQuery, format string) (string, []byte, error) { - return c.export(ctx, "/api/v1/export/links/"+url.PathEscape(urlID), q, format) -} - -func (c *Client) export(ctx context.Context, path string, q StatsQuery, format string) (string, []byte, error) { - v := q.values() - v.Set("format", format) - resp, err := c.request(ctx, http.MethodGet, path, v, nil) - if err != nil { - return "", nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return "", nil, decode(resp, nil) - } - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", nil, err - } - filename := fmt.Sprintf("spoo-export.%s", format) - if format == "csv" { - filename = "spoo-export.zip" - } - if _, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition")); err == nil { - if name := params["filename"]; name != "" { - filename = name - } - } - return filename, data, nil -} diff --git a/internal/api/keys.go b/internal/api/keys.go deleted file mode 100644 index a0acf79..0000000 --- a/internal/api/keys.go +++ /dev/null @@ -1,36 +0,0 @@ -package api - -import ( - "context" - "net/http" - "net/url" - "strconv" -) - -type APIKey struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Scopes []string `json:"scopes"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` - Revoked bool `json:"revoked"` - TokenPrefix string `json:"token_prefix"` -} - -func (c *Client) ListKeys(ctx context.Context) ([]APIKey, error) { - var out struct { - Keys []APIKey `json:"keys"` - } - if err := c.do(ctx, http.MethodGet, "/api/v1/keys", nil, nil, &out); err != nil { - return nil, err - } - return out.Keys, nil -} - -// DeleteKey removes a key. With revoke=true it is soft-revoked (kept in -// the list, unusable); with revoke=false the record is hard-deleted. -func (c *Client) DeleteKey(ctx context.Context, id string, revoke bool) error { - q := url.Values{"revoke": {strconv.FormatBool(revoke)}} - return c.do(ctx, http.MethodDelete, "/api/v1/keys/"+id, q, nil, nil) -} diff --git a/internal/api/keys_test.go b/internal/api/keys_test.go deleted file mode 100644 index 40b3822..0000000 --- a/internal/api/keys_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package api - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" -) - -func TestListKeys(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"keys":[{"id":"k1","name":"CI","scopes":["shorten:create"],"token_prefix":"abc12345","revoked":false}]}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - keys, err := c.ListKeys(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(keys) != 1 || keys[0].TokenPrefix != "abc12345" { - t.Fatalf("keys = %+v", keys) - } -} - -func TestInspectDoesNotFollowRedirect(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodHead { - t.Errorf("method = %s, want HEAD (no click tracking)", r.Method) - } - w.Header().Set("Location", "https://example.com/destination") - w.WriteHeader(http.StatusFound) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - res, err := c.Inspect(context.Background(), "abc") - if err != nil { - t.Fatal(err) - } - if res.Status != http.StatusFound || res.Destination != "https://example.com/destination" { - t.Fatalf("unexpected: %+v", res) - } -} diff --git a/internal/api/shorten.go b/internal/api/shorten.go deleted file mode 100644 index dc377c8..0000000 --- a/internal/api/shorten.go +++ /dev/null @@ -1,53 +0,0 @@ -package api - -import ( - "context" - "net/http" - "net/url" -) - -type ShortenRequest struct { - LongURL string `json:"long_url"` - Alias string `json:"alias,omitempty"` - Password string `json:"password,omitempty"` - BlockBots bool `json:"block_bots,omitempty"` - MaxClicks int `json:"max_clicks,omitempty"` - ExpireAfter string `json:"expire_after,omitempty"` // ISO 8601 or epoch seconds - PrivateStats bool `json:"private_stats,omitempty"` - Domain string `json:"domain,omitempty"` -} - -// ShortURL mirrors UrlResponse (POST /api/v1/shorten); created_at is -// Unix seconds in this response. -type ShortURL struct { - ShortURL string `json:"short_url"` - Alias string `json:"alias"` - LongURL string `json:"long_url"` - CreatedAt int64 `json:"created_at"` - Status string `json:"status"` -} - -func (c *Client) Shorten(ctx context.Context, req ShortenRequest) (*ShortURL, error) { - var out ShortURL - if err := c.do(ctx, http.MethodPost, "/api/v1/shorten", nil, req, &out); err != nil { - return nil, err - } - return &out, nil -} - -type AliasCheck struct { - Available bool `json:"available"` - Reason string `json:"reason"` -} - -func (c *Client) CheckAlias(ctx context.Context, alias, domain string) (*AliasCheck, error) { - q := url.Values{"alias": {alias}} - if domain != "" { - q.Set("domain", domain) - } - var out AliasCheck - if err := c.do(ctx, http.MethodGet, "/api/v1/shorten/check-alias", q, nil, &out); err != nil { - return nil, err - } - return &out, nil -} diff --git a/internal/api/shorten_test.go b/internal/api/shorten_test.go deleted file mode 100644 index 02fe54f..0000000 --- a/internal/api/shorten_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestShorten(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/shorten" || r.Method != http.MethodPost { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - var req map[string]any - json.NewDecoder(r.Body).Decode(&req) - if req["long_url"] != "https://example.com" || req["alias"] != "mylink" { - t.Errorf("unexpected body: %v", req) - } - if _, ok := req["password"]; ok { - t.Error("empty optional fields must be omitted") - } - w.WriteHeader(http.StatusCreated) - w.Write([]byte(`{"id":"x","short_url":"https://spoo.me/mylink","alias":"mylink","long_url":"https://example.com","status":"ACTIVE"}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - res, err := c.Shorten(context.Background(), ShortenRequest{LongURL: "https://example.com", Alias: "mylink"}) - if err != nil { - t.Fatal(err) - } - if res.ShortURL != "https://spoo.me/mylink" { - t.Fatalf("ShortURL = %q", res.ShortURL) - } -} - -func TestCheckAlias(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/shorten/check-alias" { - t.Errorf("path = %s", r.URL.Path) - } - if r.URL.Query().Get("alias") != "taken1" { - t.Errorf("alias = %q", r.URL.Query().Get("alias")) - } - w.Write([]byte(`{"available":false,"reason":"taken"}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - res, err := c.CheckAlias(context.Background(), "taken1", "") - if err != nil { - t.Fatal(err) - } - if res.Available || res.Reason != "taken" { - t.Fatalf("unexpected: %+v", res) - } -} diff --git a/internal/api/stats.go b/internal/api/stats.go deleted file mode 100644 index 74cb4d8..0000000 --- a/internal/api/stats.go +++ /dev/null @@ -1,146 +0,0 @@ -package api - -import ( - "context" - "maps" - "net/http" - "net/url" - "slices" - "strings" -) - -// StatsQuery parameterizes the authed stats endpoints. The account -// endpoint accepts every field; the per-link endpoint rejects a -// short_code group_by (the link is already picked by the path). -type StatsQuery struct { - StartDate string - EndDate string - GroupBy []string // time, browser, os, country, city, referrer; account-only: short_code - Timezone string // IANA name - // Filters narrows results server-side; keys are the filterable - // dimensions (browser, os, country, city, referrer, short_code). - Filters map[string][]string -} - -func (q StatsQuery) values() url.Values { - v := url.Values{} - if q.StartDate != "" { - v.Set("start_date", q.StartDate) - } - if q.EndDate != "" { - v.Set("end_date", q.EndDate) - } - if len(q.GroupBy) > 0 { - v.Set("group_by", strings.Join(q.GroupBy, ",")) - } - if q.Timezone != "" { - v.Set("timezone", q.Timezone) - } - for _, dim := range slices.Sorted(maps.Keys(q.Filters)) { - if vals := q.Filters[dim]; len(vals) > 0 { - v.Set(dim, strings.Join(vals, ",")) - } - } - return v -} - -type StatsSummary struct { - TotalClicks int `json:"total_clicks"` - UniqueClicks int `json:"unique_clicks"` - FirstClick string `json:"first_click"` - LastClick string `json:"last_click"` - AvgRedirectionTime float64 `json:"avg_redirection_time"` -} - -type StatsTimeRange struct { - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` -} - -// StatsResponse keeps Metrics loosely typed: keys are dynamic -// ("clicks_by_browser", "unique_clicks_by_time", ...) and each point -// carries its dimension label under the dimension's own name. -// The wire still carries a legacy "scope" key β€” tolerated, never read. -type StatsResponse struct { - URLID string `json:"url_id"` // per-link responses echo the link - Alias string `json:"alias"` - Summary StatsSummary `json:"summary"` - TimeRange StatsTimeRange `json:"time_range"` - Metrics map[string][]map[string]any `json:"metrics"` - ComputedMetrics map[string]float64 `json:"computed_metrics"` - GeneratedAt string `json:"generated_at"` -} - -// MaxRangeDays is the widest window the stats endpoint accepts; without -// explicit dates it defaults to only the LAST 7 DAYS, so clients that -// want "all recent activity" should request this window explicitly. -const MaxRangeDays = 90 - -type MetricPoint struct { - Label string - Value float64 -} - -// Points extracts (label, value) pairs from the loosely typed metrics -// payload for one dimension/metric pair, e.g. ("browser", "clicks") β†’ -// the "clicks_by_browser" series with labels from the "browser" key. -func (r *StatsResponse) Points(dimension, metric string) []MetricPoint { - pts := r.Metrics[metric+"_by_"+dimension] - out := make([]MetricPoint, 0, len(pts)) - for _, p := range pts { - label, _ := p[dimension].(string) - value, ok := p[metric].(float64) - if label == "" || !ok { - continue - } - out = append(out, MetricPoint{Label: label, Value: value}) - } - return out -} - -// Stats aggregates clicks across every link the account owns. -// Auth is required β€” anonymous stats live on PublicStats. -func (c *Client) Stats(ctx context.Context, q StatsQuery) (*StatsResponse, error) { - var out StatsResponse - if err := c.do(ctx, http.MethodGet, "/api/v1/stats", q.values(), nil, &out); err != nil { - return nil, err - } - return &out, nil -} - -// LinkStats returns stats for one owned link by its url id (resolve an -// alias with ResolveAlias first). Unknown and foreign ids both 404. -func (c *Client) LinkStats(ctx context.Context, urlID string, q StatsQuery) (*StatsResponse, error) { - var out StatsResponse - path := "/api/v1/stats/links/" + url.PathEscape(urlID) - if err := c.do(ctx, http.MethodGet, path, q.values(), nil, &out); err != nil { - return nil, err - } - return &out, nil -} - -// PublicStats returns anyone's per-link stats without auth. The -// endpoint takes only a date range and timezone β€” no group_by β€” and -// answers with every dimension at once; private links 404 and -// password-protected ones 401. The {generation, link, stats} envelope -// is unwrapped to the standard stats wire. -func (c *Client) PublicStats(ctx context.Context, shortCode, startDate, endDate, timezone string) (*StatsResponse, error) { - v := url.Values{} - if startDate != "" { - v.Set("start_date", startDate) - } - if endDate != "" { - v.Set("end_date", endDate) - } - if timezone != "" { - v.Set("timezone", timezone) - } - var out struct { - Stats StatsResponse `json:"stats"` - } - path := "/api/v1/public/stats/" + url.PathEscape(shortCode) - if err := c.do(ctx, http.MethodGet, path, v, nil, &out); err != nil { - return nil, err - } - return &out.Stats, nil -} diff --git a/internal/api/stats_test.go b/internal/api/stats_test.go deleted file mode 100644 index caed7ec..0000000 --- a/internal/api/stats_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package api - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" -) - -func TestStatsQueryAndDecode(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("group_by") != "time,browser" || q.Get("timezone") != "UTC" { - t.Errorf("unexpected query: %v", q) - } - if q.Has("scope") { - t.Errorf("scope param must not be sent: %v", q) - } - w.Write([]byte(`{ - "scope": "all", - "summary": {"total_clicks": 100, "unique_clicks": 60, "avg_redirection_time": 0.12}, - "metrics": { - "clicks_by_browser": [{"browser": "Chrome", "clicks": 70, "clicks_percentage": 70.0}], - "clicks_by_time": [{"date": "2026-06-01", "clicks": 10}] - }, - "computed_metrics": {"unique_click_rate": 0.6} - }`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - res, err := c.Stats(context.Background(), StatsQuery{ - GroupBy: []string{"time", "browser"}, Timezone: "UTC", - }) - if err != nil { - t.Fatal(err) - } - if res.Summary.TotalClicks != 100 || res.Summary.UniqueClicks != 60 { - t.Fatalf("summary = %+v", res.Summary) - } - points := res.Metrics["clicks_by_browser"] - if len(points) != 1 || points[0]["browser"] != "Chrome" { - t.Fatalf("metrics = %+v", res.Metrics) - } - if res.ComputedMetrics["unique_click_rate"] != 0.6 { - t.Fatalf("computed = %+v", res.ComputedMetrics) - } -} - -func TestLinkStatsHitsPerLinkPath(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/stats/links/65f0abc123" { - t.Errorf("path = %s", r.URL.Path) - } - if q := r.URL.Query(); q.Has("scope") || q.Has("short_code") { - t.Errorf("legacy params must not be sent: %v", q) - } - w.Write([]byte(`{ - "url_id": "65f0abc123", "alias": "launch", "scope": "all", - "summary": {"total_clicks": 42, "unique_clicks": 30}, - "metrics": {} - }`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - res, err := c.LinkStats(context.Background(), "65f0abc123", StatsQuery{GroupBy: []string{"time"}}) - if err != nil { - t.Fatal(err) - } - if res.URLID != "65f0abc123" || res.Alias != "launch" || res.Summary.TotalClicks != 42 { - t.Fatalf("res = %+v", res) - } -} - -func TestPublicStatsUnwrapsEnvelope(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/public/stats/launch" { - t.Errorf("path = %s", r.URL.Path) - } - q := r.URL.Query() - if q.Get("start_date") != "2026-01-01T00:00:00Z" || q.Get("timezone") != "UTC" { - t.Errorf("unexpected query: %v", q) - } - if q.Has("group_by") || q.Has("scope") { - t.Errorf("public endpoint takes only a range and timezone: %v", q) - } - w.Write([]byte(`{ - "generation": "v2", - "link": {"alias": "launch", "domain": "spoo.me"}, - "stats": { - "scope": "anon", - "summary": {"total_clicks": 9, "unique_clicks": 5}, - "metrics": {"clicks_by_browser": [{"browser": "Chrome", "clicks": 9}]} - } - }`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - res, err := c.PublicStats(context.Background(), "launch", "2026-01-01T00:00:00Z", "", "UTC") - if err != nil { - t.Fatal(err) - } - if res.Summary.TotalClicks != 9 || len(res.Metrics["clicks_by_browser"]) != 1 { - t.Fatalf("res = %+v", res) - } -} - -func TestExportReturnsFilenameAndBytes(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/export" || r.URL.Query().Get("format") != "xlsx" { - t.Errorf("unexpected request: %s %v", r.URL.Path, r.URL.Query()) - } - if r.URL.Query().Has("scope") { - t.Errorf("scope param must not be sent: %v", r.URL.Query()) - } - w.Header().Set("Content-Disposition", `attachment; filename="stats-launch.xlsx"`) - w.Write([]byte("FAKEXLSX")) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - name, data, err := c.Export(context.Background(), StatsQuery{}, "xlsx") - if err != nil { - t.Fatal(err) - } - if name != "stats-launch.xlsx" || string(data) != "FAKEXLSX" { - t.Fatalf("name=%q data=%q", name, data) - } -} - -func TestExportLinkHitsPerLinkPath(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/export/links/65f0abc123" { - t.Errorf("path = %s", r.URL.Path) - } - w.Header().Set("Content-Disposition", `attachment; filename="stats-launch.zip"`) - w.Write([]byte("FAKEZIP")) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - name, data, err := c.ExportLink(context.Background(), "65f0abc123", StatsQuery{}, "csv") - if err != nil { - t.Fatal(err) - } - if name != "stats-launch.zip" || string(data) != "FAKEZIP" { - t.Fatalf("name=%q data=%q", name, data) - } -} diff --git a/internal/api/urls.go b/internal/api/urls.go deleted file mode 100644 index 3801d6d..0000000 --- a/internal/api/urls.go +++ /dev/null @@ -1,136 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/url" - "strconv" -) - -// URLItem is a row from GET /api/v1/urls. The envelope is camelCase -// (pageSize, hasNext) but items are snake_case; expire_after is a Unix -// timestamp β€” see UrlListItem in the backend's schemas/dto/responses/url.py. -type URLItem struct { - ID string `json:"id"` - Alias string `json:"alias"` - LongURL string `json:"long_url"` - CreatedAt string `json:"created_at"` - LastClick string `json:"last_click"` - TotalClicks int `json:"total_clicks"` - Status string `json:"status"` - PasswordSet bool `json:"password_set"` - MaxClicks *int `json:"max_clicks"` - ExpireAfter *int64 `json:"expire_after"` // Unix seconds, null when unset - PrivateStats bool `json:"private_stats"` - BlockBots bool `json:"block_bots"` - Domain string `json:"domain"` -} - -type URLPage struct { - Items []URLItem `json:"items"` - Page int `json:"page"` - PageSize int `json:"pageSize"` - Total int `json:"total"` - HasNext bool `json:"hasNext"` -} - -type ListURLsOptions struct { - Page int - PageSize int - SortBy string // created_at | last_click | total_clicks - SortOrder string // ascending | descending - Search string - Status string // ACTIVE | INACTIVE | BLOCKED | EXPIRED - Domain string -} - -func (c *Client) ListURLs(ctx context.Context, opts ListURLsOptions) (*URLPage, error) { - q := url.Values{} - if opts.Page > 0 { - q.Set("page", strconv.Itoa(opts.Page)) - } - if opts.PageSize > 0 { - q.Set("pageSize", strconv.Itoa(opts.PageSize)) - } - if opts.SortBy != "" { - q.Set("sortBy", opts.SortBy) - } - if opts.SortOrder != "" { - q.Set("sortOrder", opts.SortOrder) - } - if opts.Domain != "" { - q.Set("domain", opts.Domain) - } - filter := map[string]any{} - if opts.Search != "" { - filter["search"] = opts.Search - } - if opts.Status != "" { - filter["status"] = opts.Status - } - if len(filter) > 0 { - data, err := json.Marshal(filter) - if err != nil { - return nil, err - } - q.Set("filter", string(data)) - } - var out URLPage - if err := c.do(ctx, http.MethodGet, "/api/v1/urls", q, nil, &out); err != nil { - return nil, err - } - return &out, nil -} - -// ResolveAlias looks up an owned link by alias via GET -// /api/v1/urls/{domain}/{alias}, mainly to obtain its url id for the -// per-link stats and export endpoints. An empty domain defaults to the -// API base URL's hostname, which covers spoo.me links; pass one of the -// user's custom domains to resolve links living there. Unknown and -// foreign aliases both answer 404 (no ownership oracle). -func (c *Client) ResolveAlias(ctx context.Context, alias, domain string) (*URLItem, error) { - if domain == "" { - base, err := url.Parse(c.base) - if err != nil { - return nil, err - } - domain = base.Hostname() - } - path := "/api/v1/urls/" + url.PathEscape(domain) + "/" + url.PathEscape(alias) - var out URLItem - if err := c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil { - return nil, err - } - return &out, nil -} - -// UpdatedURL mirrors UpdateUrlResponse β€” unlike the shorten response it -// carries no short_url, and timestamps are Unix seconds. -type UpdatedURL struct { - ID string `json:"id"` - Alias string `json:"alias"` - LongURL string `json:"long_url"` - Status string `json:"status"` - PasswordSet bool `json:"password_set"` - MaxClicks *int `json:"max_clicks"` - ExpireAfter *int64 `json:"expire_after"` - BlockBots bool `json:"block_bots"` - PrivateStats bool `json:"private_stats"` - Domain string `json:"domain"` - UpdatedAt int64 `json:"updated_at"` -} - -// UpdateURL patches the given fields (snake_case keys per the API: -// long_url, alias, password, max_clicks, expire_after, status, ...). -func (c *Client) UpdateURL(ctx context.Context, id string, fields map[string]any) (*UpdatedURL, error) { - var out UpdatedURL - if err := c.do(ctx, http.MethodPatch, "/api/v1/urls/"+id, nil, fields, &out); err != nil { - return nil, err - } - return &out, nil -} - -func (c *Client) DeleteURL(ctx context.Context, id string) error { - return c.do(ctx, http.MethodDelete, "/api/v1/urls/"+id, nil, nil, nil) -} diff --git a/internal/api/urls_test.go b/internal/api/urls_test.go deleted file mode 100644 index bb0f9a0..0000000 --- a/internal/api/urls_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestListURLsBuildsQueryAndFilter(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("page") != "2" || q.Get("pageSize") != "50" || q.Get("sortBy") != "total_clicks" { - t.Errorf("unexpected query: %v", q) - } - var filter map[string]any - if err := json.Unmarshal([]byte(q.Get("filter")), &filter); err != nil { - t.Errorf("filter not JSON: %v", err) - } - if filter["search"] != "launch" || filter["status"] != "ACTIVE" { - t.Errorf("unexpected filter: %v", filter) - } - w.Write([]byte(`{"items":[{"id":"a1","alias":"launch","long_url":"https://x.com","total_clicks":42,"status":"ACTIVE","password_set":false}],"page":2,"pageSize":50,"total":51,"hasNext":false}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - page, err := c.ListURLs(context.Background(), ListURLsOptions{ - Page: 2, PageSize: 50, SortBy: "total_clicks", Search: "launch", Status: "ACTIVE", - }) - if err != nil { - t.Fatal(err) - } - if len(page.Items) != 1 || page.Items[0].TotalClicks != 42 || page.Items[0].LongURL != "https://x.com" { - t.Fatalf("unexpected page: %+v", page) - } -} - -func TestResolveAliasUsesAPIHostAndEscapes(t *testing.T) { - var gotPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.EscapedPath() - w.Write([]byte(`{"id":"65f0abc123","alias":"πŸš€","long_url":"https://x.com","status":"ACTIVE"}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - u, err := c.ResolveAlias(context.Background(), "πŸš€", "") - if err != nil { - t.Fatal(err) - } - // httptest serves on 127.0.0.1, and the emoji alias must arrive - // percent-encoded - if gotPath != "/api/v1/urls/127.0.0.1/%F0%9F%9A%80" { - t.Fatalf("path = %q", gotPath) - } - if u.ID != "65f0abc123" { - t.Fatalf("id = %q", u.ID) - } -} - -func TestResolveAliasNotFound(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"error":"URL not found","code":"not_found"}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - _, err := c.ResolveAlias(context.Background(), "nope", "") - if !IsNotFound(err) { - t.Fatalf("err = %v, want IsNotFound", err) - } -} - -// a custom domain replaces the API host in the resolve path, so links -// on the user's own domains resolve to their real url ids. -func TestResolveAliasCustomDomain(t *testing.T) { - var gotPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.EscapedPath() - w.Write([]byte(`{"id":"65f0abc123","alias":"promo","long_url":"https://x.com","status":"ACTIVE"}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - if _, err := c.ResolveAlias(context.Background(), "promo", "links.example.com"); err != nil { - t.Fatal(err) - } - if gotPath != "/api/v1/urls/links.example.com/promo" { - t.Fatalf("path = %q, want the custom domain in the path", gotPath) - } -} - -func TestUpdateURLSendsPatch(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPatch || r.URL.Path != "/api/v1/urls/abc123" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - if body["status"] != "INACTIVE" { - t.Errorf("unexpected body: %v", body) - } - w.Write([]byte(`{"id":"abc123","alias":"x","status":"INACTIVE","password_set":false,"updated_at":1781524800}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - res, err := c.UpdateURL(context.Background(), "abc123", map[string]any{"status": "INACTIVE"}) - if err != nil { - t.Fatal(err) - } - if res.Status != "INACTIVE" { - t.Fatalf("status = %q", res.Status) - } -} - -func TestDeleteURL(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete || r.URL.Path != "/api/v1/urls/abc123" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - w.Write([]byte(`{"message":"deleted","id":"abc123"}`)) - })) - defer srv.Close() - - c := New(srv.URL, newTestStore(t, nil)) - if err := c.DeleteURL(context.Background(), "abc123"); err != nil { - t.Fatal(err) - } -} diff --git a/internal/auth/device.go b/internal/auth/device.go index ce74a48..a54ee6f 100644 --- a/internal/auth/device.go +++ b/internal/auth/device.go @@ -2,16 +2,13 @@ package auth import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" "errors" "fmt" "io" "net" "net/http" - "net/url" + + spoo "github.com/spoo-me/spoo-go" ) const ( @@ -28,9 +25,12 @@ const successHTML = ` of `keys revoke`, described by the key -// name and skipping already-revoked keys. -func completeKeyID(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - d, err := newDeps() - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - ctx, cancel := completionContext(cmd) - defer cancel() - keys, err := d.client.ListKeys(ctx) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - var out []string - for _, k := range keys { - if k.Revoked || !strings.HasPrefix(k.ID, toComplete) { - continue - } - out = append(out, k.ID+"\t"+k.Name) - } - return out, cobra.ShellCompDirectiveNoFileComp -} - // completeDomain completes --domain with the distinct custom domains that // already appear on your links. There's no domains-list endpoint, so this // is the practical best-effort source (a domain you've never used yet diff --git a/internal/cmd/completion_test.go b/internal/cmd/completion_test.go index 33e131e..96f5a1c 100644 --- a/internal/cmd/completion_test.go +++ b/internal/cmd/completion_test.go @@ -75,28 +75,6 @@ func TestCompleteLinkIDDescribedByAlias(t *testing.T) { } } -func TestCompleteKeyIDDescribedByName(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/keys" { - t.Errorf("unexpected path %s", r.URL.Path) - } - w.Write([]byte(`{"keys":[ - {"id":"k1","name":"ci-bot","revoked":false}, - {"id":"k2","name":"old","revoked":true} - ]}`)) - })) - defer srv.Close() - pointDepsAt(t, srv.URL) - - out := complete(t, "keys", "revoke", "") - if !strings.Contains(out, "k1\tci-bot") { - t.Fatalf("revoke should complete live key ids by name:\n%s", out) - } - if strings.Contains(out, "k2") { - t.Fatalf("revoked keys should be omitted:\n%s", out) - } -} - func TestCompleteFixedFlags(t *testing.T) { pointDepsAt(t, "http://unused.invalid") out := complete(t, "export", "--format", "") diff --git a/internal/cmd/expiry.go b/internal/cmd/expiry.go deleted file mode 100644 index 7197eea..0000000 --- a/internal/cmd/expiry.go +++ /dev/null @@ -1,14 +0,0 @@ -package cmd - -import ( - "time" - - "github.com/spoo-me/spoo-cli/internal/api" -) - -// parseExpiry normalizes --expires input to RFC 3339. Thin delegate to -// api.ParseExpiry so the shared form code (TUI) and the commands agree -// on the format. -func parseExpiry(raw string, now time.Time) (string, error) { - return api.ParseExpiry(raw, now) -} diff --git a/internal/cmd/expiry_test.go b/internal/cmd/expiry_test.go deleted file mode 100644 index ee7dc14..0000000 --- a/internal/cmd/expiry_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package cmd - -import ( - "testing" - "time" -) - -func TestParseExpiryPassthroughISO(t *testing.T) { - got, err := parseExpiry("2027-01-02T15:04:05Z", time.Now()) - if err != nil || got != "2027-01-02T15:04:05Z" { - t.Fatalf("got %q, %v", got, err) - } -} - -func TestParseExpiryDuration(t *testing.T) { - now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) - got, err := parseExpiry("72h", now) - if err != nil { - t.Fatal(err) - } - if got != "2026-06-13T12:00:00Z" { - t.Fatalf("got %q, want 2026-06-13T12:00:00Z", got) - } -} - -// Bare epoch input is normalized to RFC 3339: the backend only parses -// epoch when it arrives as a JSON number, and we send a string field. -func TestParseExpiryEpochString(t *testing.T) { - got, err := parseExpiry("1781524800", time.Now()) - if err != nil { - t.Fatal(err) - } - if got != "2026-06-15T12:00:00Z" { - t.Fatalf("got %q, want 2026-06-15T12:00:00Z", got) - } -} - -func TestParseExpiryRejectsNegativeDuration(t *testing.T) { - if _, err := parseExpiry("-5m", time.Now()); err == nil { - t.Fatal("want error for negative duration") - } -} - -func TestParseExpiryEmpty(t *testing.T) { - got, err := parseExpiry("", time.Now()) - if err != nil || got != "" { - t.Fatalf("got %q, %v", got, err) - } -} diff --git a/internal/cmd/export.go b/internal/cmd/export.go index 8b2914a..d37eb27 100644 --- a/internal/cmd/export.go +++ b/internal/cmd/export.go @@ -3,11 +3,12 @@ package cmd import ( "errors" "fmt" + "io" "os" "github.com/spf13/cobra" + spoo "github.com/spoo-me/spoo-go" - "github.com/spoo-me/spoo-cli/internal/api" "github.com/spoo-me/spoo-cli/internal/auth" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -42,12 +43,23 @@ workbook with one sheet per dimension.`, if _, err := d.store.Load(); errors.Is(err, auth.ErrNotLoggedIn) { return fmt.Errorf("export requires login β€” run `spoo auth login`") } - q := api.StatsQuery{StartDate: from, EndDate: to} - var name string - var data []byte + fromT, err := parseDate(from) + if err != nil { + return err + } + toT, err := parseDate(to) + if err != nil { + return err + } + q := spoo.StatsQuery{StartDate: fromT, EndDate: toT} + var file *spoo.ExportFile if len(args) == 1 { - u, err := d.client.ResolveAlias(cmd.Context(), args[0], domain) - if api.IsNotFound(err) { + resolveDomain := domain + if resolveDomain == "" { + resolveDomain = apiHost(d.cfg.APIBase) + } + u, err := d.client.ResolveAlias(cmd.Context(), args[0], resolveDomain) + if spoo.IsNotFound(err) { where := args[0] if domain != "" { where += " on " + domain @@ -57,25 +69,35 @@ workbook with one sheet per dimension.`, if err != nil { return err } - name, data, err = d.client.ExportLink(cmd.Context(), u.ID, q, format) - if err != nil { + if file, err = d.client.ExportLink(cmd.Context(), u.ID, q, format); err != nil { return err } - } else if name, data, err = d.client.Export(cmd.Context(), q, format); err != nil { + } else if file, err = d.client.Export(cmd.Context(), q, format); err != nil { return err } + defer file.Body.Close() + if output == "-" { - _, err := cmd.OutOrStdout().Write(data) + _, err := io.Copy(cmd.OutOrStdout(), file.Body) return err } + name := file.Filename if output != "" { name = output } - if err := os.WriteFile(name, data, 0o644); err != nil { + out, err := os.Create(name) + if err != nil { + return err + } + written, err := io.Copy(out, file.Body) + if closeErr := out.Close(); err == nil { + err = closeErr + } + if err != nil { return err } fmt.Fprintln(prettyOut(cmd), ui.OK.Render("βœ“ exported ")+name+ - ui.Dim.Render(fmt.Sprintf(" (%d bytes)", len(data)))) + ui.Dim.Render(fmt.Sprintf(" (%d bytes)", written))) return nil }, } diff --git a/internal/cmd/export_test.go b/internal/cmd/export_test.go index b704e5d..b2d8e0b 100644 --- a/internal/cmd/export_test.go +++ b/internal/cmd/export_test.go @@ -51,12 +51,14 @@ func TestExportAccountWide(t *testing.T) { // export endpoint. func TestExportOwnedLink(t *testing.T) { var paths []string + var gotURLID string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) if strings.HasPrefix(r.URL.Path, "/api/v1/urls/") { w.Write([]byte(`{"id":"65f0abc123","alias":"launch","long_url":"https://x.com","status":"ACTIVE"}`)) return } + gotURLID = r.URL.Query().Get("url_id") w.Write([]byte(`{"export":"ok"}`)) })) defer srv.Close() @@ -74,6 +76,9 @@ func TestExportOwnedLink(t *testing.T) { if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] { t.Fatalf("paths = %v, want %v", paths, want) } + if gotURLID != "" { + t.Fatalf("url_id = %q, want no query param on the per-link route", gotURLID) + } } // anonymous export died with the scope param; a foreign code has no diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index da252f6..4709a4d 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "io" "net/url" "strings" @@ -8,6 +9,7 @@ import ( "github.com/charmbracelet/colorprofile" "github.com/spf13/cobra" + spoo "github.com/spoo-me/spoo-go" ) // apiHost is the hostname of the configured API base β€” the system @@ -22,6 +24,31 @@ func apiHost(base string) string { // timeNow is a seam for tests that need deterministic expiry math. var timeNow = time.Now +// dateLayouts are the shapes --from/--to accept: full RFC 3339 or a +// bare date. +var dateLayouts = []string{time.RFC3339, "2006-01-02"} + +// parseDate reads a --from/--to flag; empty input means "not set". +func parseDate(raw string) (time.Time, error) { + if raw == "" { + return time.Time{}, nil + } + for _, layout := range dateLayouts { + if t, err := time.Parse(layout, raw); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unrecognized date %q (want ISO 8601, e.g. 2026-01-15)", raw) +} + +// day renders a timestamp as YYYY-MM-DD, empty when unset. +func day(t spoo.Timestamp) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02") +} + func normalizeStatus(s string) string { return strings.ToUpper(strings.TrimSpace(s)) } // prettyOut wraps stdout in a color-profile writer: full color on diff --git a/internal/api/inspect.go b/internal/cmd/inspect.go similarity index 56% rename from internal/api/inspect.go rename to internal/cmd/inspect.go index 0be840f..43bec29 100644 --- a/internal/api/inspect.go +++ b/internal/cmd/inspect.go @@ -1,4 +1,4 @@ -package api +package cmd import ( "context" @@ -6,29 +6,32 @@ import ( "time" ) +// InspectResult is what a HEAD probe of a short link revealed. type InspectResult struct { ShortURL string `json:"short_url"` Status int `json:"status"` Destination string `json:"destination,omitempty"` } -// Inspect resolves where a short code points without recording a click: -// the backend skips click tracking on HEAD requests, and redirects are -// not followed so the destination never gets hit either. -func (c *Client) Inspect(ctx context.Context, shortCode string) (*InspectResult, error) { +// inspectLink resolves where a short code points without recording a +// click: the backend skips click tracking on HEAD requests, and +// redirects are not followed so the destination never gets hit either. +// This probes the redirect edge, not the API, so it stays a plain HTTP +// call in the CLI instead of an SDK method. +func inspectLink(ctx context.Context, base, shortCode string) (*InspectResult, error) { noFollow := &http.Client{ Timeout: 15 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } - u := c.base + "/" + shortCode + u := base + "/" + shortCode req, err := http.NewRequestWithContext(ctx, http.MethodHead, u, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "spoo-cli") - req.Header.Set("X-Spoo-Client", clientHeader()) + req.Header.Set("X-Spoo-Client", clientTag()) resp, err := noFollow.Do(req) if err != nil { return nil, err diff --git a/internal/cmd/keys.go b/internal/cmd/keys.go deleted file mode 100644 index cc0f707..0000000 --- a/internal/cmd/keys.go +++ /dev/null @@ -1,88 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "strings" - "text/tabwriter" - "time" - - "github.com/spf13/cobra" - - "github.com/spoo-me/spoo-cli/internal/ui" -) - -func newKeysCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "keys", - Short: "Manage API keys", - RunE: func(cmd *cobra.Command, args []string) error { - return runKeysList(cmd) - }, - } - cmd.AddCommand(newKeysRevokeCmd()) - return cmd -} - -func runKeysList(cmd *cobra.Command) error { - d, err := newDeps() - if err != nil { - return err - } - keys, err := d.client.ListKeys(cmd.Context()) - if err != nil { - return err - } - if asJSON, _ := cmd.Flags().GetBool("json"); asJSON { - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(keys) - } - if len(keys) == 0 { - fmt.Fprintln(prettyOut(cmd), ui.Dim.Render("no API keys β€” create one at https://spoo.me/dashboard/keys")) - return nil - } - // cells stay unstyled: ANSI codes would skew tabwriter's column math - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "ID\tPREFIX\tNAME\tSCOPES\tCREATED\tSTATE") - for _, k := range keys { - state := "active" - if k.Revoked { - state = "revoked" - } - created := "" - if k.CreatedAt > 0 { - created = time.Unix(k.CreatedAt, 0).UTC().Format("2006-01-02") - } - fmt.Fprintf(w, "%s\t%s…\t%s\t%s\t%s\t%s\n", - k.ID, k.TokenPrefix, k.Name, strings.Join(k.Scopes, ","), created, state) - } - return w.Flush() -} - -func newKeysRevokeCmd() *cobra.Command { - var hard bool - cmd := &cobra.Command{ - Use: "revoke ", - Short: "Revoke an API key", - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeKeyID, - RunE: func(cmd *cobra.Command, args []string) error { - d, err := newDeps() - if err != nil { - return err - } - if err := d.client.DeleteKey(cmd.Context(), args[0], !hard); err != nil { - return err - } - action := "revoked" - if hard { - action = "deleted" - } - fmt.Fprintln(prettyOut(cmd), ui.OK.Render("βœ“ "+action+" ")+args[0]) - return nil - }, - } - cmd.Flags().BoolVar(&hard, "delete", false, "hard-delete the record instead of revoking") - return cmd -} diff --git a/internal/cmd/keys_test.go b/internal/cmd/keys_test.go deleted file mode 100644 index 74fee38..0000000 --- a/internal/cmd/keys_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestKeysCreateCommandRemoved(t *testing.T) { - pointDepsAt(t, "http://unused.invalid") - root := NewRootCmd() - root.SetOut(new(bytes.Buffer)) - root.SetErr(new(bytes.Buffer)) - root.SetArgs([]string{"keys", "create", "--name", "ci"}) - // Creation is dashboard-only; the subcommand must not exist. - if err := root.Execute(); err == nil { - t.Fatal("expected error for removed `keys create` subcommand") - } -} - -func TestKeysListTable(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"keys":[{"id":"k1","name":"ci","scopes":["shorten:create"],"token_prefix":"abc12345","created_at":1750000000,"revoked":false}]}`)) - })) - defer srv.Close() - pointDepsAt(t, srv.URL) - - root := NewRootCmd() - var out bytes.Buffer - root.SetOut(&out) - root.SetErr(&out) - root.SetArgs([]string{"keys"}) - if err := root.Execute(); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "abc12345") || !strings.Contains(out.String(), "shorten:create") { - t.Fatalf("unexpected output:\n%s", out.String()) - } -} diff --git a/internal/cmd/links.go b/internal/cmd/links.go index ddb6f2a..0370439 100644 --- a/internal/cmd/links.go +++ b/internal/cmd/links.go @@ -5,19 +5,20 @@ import ( "fmt" "strings" "text/tabwriter" + "time" tea "charm.land/bubbletea/v2" "github.com/atotto/clipboard" "github.com/pkg/browser" "github.com/spf13/cobra" + spoo "github.com/spoo-me/spoo-go" - "github.com/spoo-me/spoo-cli/internal/api" "github.com/spoo-me/spoo-cli/internal/tui/links" "github.com/spoo-me/spoo-cli/internal/ui" ) func newLinksCmd() *cobra.Command { - var opts api.ListURLsOptions + var opts spoo.ListURLsOptions cmd := &cobra.Command{ Use: "links", Short: "Browse and manage your links", @@ -67,7 +68,7 @@ toggle, delete). Piped or with --json it prints the list and exits.`, return cmd } -func printLinksList(cmd *cobra.Command, d *deps, opts api.ListURLsOptions, asJSON bool) error { +func printLinksList(cmd *cobra.Command, d *deps, opts spoo.ListURLsOptions, asJSON bool) error { page, err := d.client.ListURLs(cmd.Context(), opts) if err != nil { return err @@ -81,12 +82,8 @@ func printLinksList(cmd *cobra.Command, d *deps, opts api.ListURLsOptions, asJSO w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) fmt.Fprintln(w, "ID\tALIAS\tDESTINATION\tCLICKS\tSTATUS\tCREATED") for _, it := range page.Items { - created := it.CreatedAt - if len(created) >= 10 { - created = created[:10] - } fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\n", - it.ID, it.Alias, truncate(it.LongURL, 60), it.TotalClicks, it.Status, created) + it.ID, it.Alias, truncate(it.LongURL, 60), it.TotalClicks, it.Status, day(it.CreatedAt)) } return w.Flush() } @@ -135,33 +132,54 @@ func newLinksUpdateCmd() *cobra.Command { if err != nil { return err } - fields := map[string]any{} + // the API's PATCH is tri-state: an omitted field keeps the + // current setting, null clears it, a value replaces it. A + // flag the user didn't pass stays omitted; the "remove" + // spellings (--max-clicks 0, empty --password/--expires) + // map to null. + var params spoo.UpdateURLParams + changed := 0 if cmd.Flags().Changed("long-url") { - fields["long_url"] = longURL + params.LongURL = longURL + changed++ } if cmd.Flags().Changed("alias") { - fields["alias"] = alias + params.Alias = alias + changed++ } if cmd.Flags().Changed("password") { - fields["password"] = password + params.Password = spoo.Set(password) + if password == "" { + params.Password = spoo.Null[string]() + } + changed++ } if cmd.Flags().Changed("max-clicks") { - fields["max_clicks"] = maxClicks + params.MaxClicks = spoo.Set(maxClicks) + if maxClicks == 0 { + params.MaxClicks = spoo.Null[int]() + } + changed++ } if cmd.Flags().Changed("expires") { - exp, err := parseExpiry(expires, timeNow()) + exp, err := spoo.ParseExpiry(expires, timeNow()) if err != nil { return err } - fields["expire_after"] = exp + params.ExpireAfter = spoo.Set(exp) + if exp.IsZero() { + params.ExpireAfter = spoo.Null[time.Time]() + } + changed++ } if cmd.Flags().Changed("status") { - fields["status"] = normalizeStatus(status) + params.Status = normalizeStatus(status) + changed++ } - if len(fields) == 0 { + if changed == 0 { return fmt.Errorf("nothing to update β€” pass at least one flag") } - res, err := d.client.UpdateURL(cmd.Context(), args[0], fields) + res, err := d.client.UpdateURL(cmd.Context(), args[0], params) if err != nil { return err } @@ -177,9 +195,9 @@ func newLinksUpdateCmd() *cobra.Command { } cmd.Flags().StringVar(&longURL, "long-url", "", "new destination URL") cmd.Flags().StringVar(&alias, "alias", "", "new alias") - cmd.Flags().StringVar(&password, "password", "", "new password") + cmd.Flags().StringVar(&password, "password", "", "new password (empty removes it)") cmd.Flags().IntVar(&maxClicks, "max-clicks", 0, "click limit (0 removes it)") - cmd.Flags().StringVar(&expires, "expires", "", "expiry: ISO 8601, epoch, or duration like 72h") + cmd.Flags().StringVar(&expires, "expires", "", "expiry: ISO 8601, epoch, or duration like 72h (empty removes it)") cmd.Flags().StringVar(&status, "status", "", "active or inactive") fixed(cmd, "status", "active", "inactive") return cmd diff --git a/internal/cmd/links_test.go b/internal/cmd/links_test.go index da884a7..d20a6c2 100644 --- a/internal/cmd/links_test.go +++ b/internal/cmd/links_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" ) func TestLinksListJSON(t *testing.T) { @@ -29,7 +29,7 @@ func TestLinksListJSON(t *testing.T) { if err := root.Execute(); err != nil { t.Fatal(err) } - var page api.URLPage + var page spoo.URLPage if err := json.Unmarshal(out.Bytes(), &page); err != nil { t.Fatalf("output is not JSON: %v\n%s", err, out.String()) } diff --git a/internal/cmd/open.go b/internal/cmd/open.go index 34cd952..50d7eb4 100644 --- a/internal/cmd/open.go +++ b/internal/cmd/open.go @@ -41,7 +41,7 @@ func newInspectCmd() *cobra.Command { if err != nil { return err } - res, err := d.client.Inspect(cmd.Context(), args[0]) + res, err := inspectLink(cmd.Context(), strings.TrimRight(d.cfg.APIBase, "/"), args[0]) if err != nil { return err } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 863c9b9..3ab0f14 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,9 +1,12 @@ package cmd import ( + "errors" + "github.com/spf13/cobra" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-go/option" - "github.com/spoo-me/spoo-cli/internal/api" "github.com/spoo-me/spoo-cli/internal/auth" "github.com/spoo-me/spoo-cli/internal/config" "github.com/spoo-me/spoo-cli/internal/ui" @@ -12,7 +15,7 @@ import ( // deps bundles everything a command needs. Factory is a package var so // command tests can swap in a client pointed at httptest. type deps struct { - client *api.Client + client *spoo.Client store *auth.Store cfg config.Config } @@ -24,7 +27,12 @@ var newDeps = func() (*deps, error) { return nil, err } store := auth.NewStore(dir) - return &deps{client: api.New(cfg.APIBase, store), store: store, cfg: cfg}, nil + client := spoo.NewClient( + option.WithBaseURL(cfg.APIBase), + option.WithTokenSource(store), + option.WithClientTag(clientTag()), + ) + return &deps{client: client, store: store, cfg: cfg}, nil } // NewRootCmd builds the spoo root command tree. @@ -40,8 +48,34 @@ func NewRootCmd() *cobra.Command { root.AddCommand( newAuthCmd(), newWhoamiCmd(), newShortenCmd(), newLinksCmd(), newStatsCmd(), newExportCmd(), - newKeysCmd(), newOpenCmd(), newInspectCmd(), newQRCmd(), ) + humanizeErrors(root) return root } + +// humanizeErrors wraps every command's RunE so the SDK's sentinel +// conditions come out as CLI guidance instead of raw API messages. The +// SDK owns detection (errors.Is); the CLI owns the wording. +func humanizeErrors(cmd *cobra.Command) { + if run := cmd.RunE; run != nil { + cmd.RunE = func(c *cobra.Command, args []string) error { + return humanize(run(c, args)) + } + } + for _, sub := range cmd.Commands() { + humanizeErrors(sub) + } +} + +func humanize(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, spoo.ErrSessionExpired): + return errors.New("session expired β€” run `spoo auth login` again") + case errors.Is(err, spoo.ErrLinkPasswordProtected): + return errors.New("this link's stats are password protected") + } + return err +} diff --git a/internal/cmd/shorten.go b/internal/cmd/shorten.go index f827ae3..90d134f 100644 --- a/internal/cmd/shorten.go +++ b/internal/cmd/shorten.go @@ -10,15 +10,15 @@ import ( "time" "github.com/spf13/cobra" + spoo "github.com/spoo-me/spoo-go" "golang.org/x/term" - "github.com/spoo-me/spoo-cli/internal/api" "github.com/spoo-me/spoo-cli/internal/ui" ) func newShortenCmd() *cobra.Command { var ( - req api.ShortenRequest + req spoo.ShortenRequest expires string showQR bool ) @@ -29,7 +29,10 @@ func newShortenCmd() *cobra.Command { With a URL argument, shortens it directly. With input piped on stdin, shortens every non-empty line (one short URL per line out). With no -argument on a terminal, opens an interactive form.`, +argument on a terminal, opens an interactive form. + +Anonymous links come back with a one-time claim token β€” keep it and +the link can be claimed into an account later from the dashboard.`, Example: ` spoo shorten https://example.com/very/long/path spoo shorten https://example.com --alias launch --expires 72h spoo shorten https://example.com --qr @@ -41,7 +44,7 @@ argument on a terminal, opens an interactive form.`, if err != nil { return err } - if req.ExpireAfter, err = parseExpiry(expires, time.Now()); err != nil { + if req.ExpireAfter, err = spoo.ParseExpiry(expires, time.Now()); err != nil { return err } asJSON, _ := cmd.Flags().GetBool("json") @@ -82,7 +85,7 @@ func stdoutIsTerminal(cmd *cobra.Command) bool { return ok && term.IsTerminal(int(f.Fd())) } -func shortenOne(cmd *cobra.Command, d *deps, req api.ShortenRequest, asJSON, showQR bool) error { +func shortenOne(cmd *cobra.Command, d *deps, req spoo.ShortenRequest, asJSON, showQR bool) error { res, err := d.client.Shorten(cmd.Context(), req) if err != nil { return err @@ -91,9 +94,11 @@ func shortenOne(cmd *cobra.Command, d *deps, req api.ShortenRequest, asJSON, sho } // shortenLines shortens each non-empty stdin line with the same flag -// options. Sequential on purpose: authed accounts get 60 req/min. -func shortenLines(cmd *cobra.Command, d *deps, base api.ShortenRequest, asJSON bool) error { - var results []*api.ShortURL +// options. Sequential on purpose: authed accounts get 60 req/min, and +// the SDK already retries transient failures per request β€” pushing +// lines concurrently would just trade 429s for retries. +func shortenLines(cmd *cobra.Command, d *deps, base spoo.ShortenRequest, asJSON bool) error { + var results []*spoo.ShortURL scanner := bufio.NewScanner(cmd.InOrStdin()) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) @@ -111,6 +116,7 @@ func shortenLines(cmd *cobra.Command, d *deps, base api.ShortenRequest, asJSON b results = append(results, res) } else { fmt.Fprintln(cmd.OutOrStdout(), res.ShortURL) + claimTokenNotice(cmd, res) } } if err := scanner.Err(); err != nil { @@ -124,7 +130,18 @@ func shortenLines(cmd *cobra.Command, d *deps, base api.ShortenRequest, asJSON b return nil } -func printShortURL(cmd *cobra.Command, res *api.ShortURL, asJSON, showQR bool) error { +// claimTokenNotice tells an anonymous creator how to keep their link. +// It goes to stderr so piped stdout stays exactly the short URLs. +func claimTokenNotice(cmd *cobra.Command, res *spoo.ShortURL) { + if res.ClaimToken == "" { + return + } + fmt.Fprintf(cmd.ErrOrStderr(), + "claim token for %s: %s (anonymous link β€” keep it to claim the link into an account later)\n", + res.ShortURL, res.ClaimToken) +} + +func printShortURL(cmd *cobra.Command, res *spoo.ShortURL, asJSON, showQR bool) error { out := cmd.OutOrStdout() if asJSON { enc := json.NewEncoder(out) @@ -137,6 +154,7 @@ func printShortURL(cmd *cobra.Command, res *api.ShortURL, asJSON, showQR bool) e if _, err := io.WriteString(out, res.ShortURL+"\n"); err != nil { return err } + claimTokenNotice(cmd, res) if showQR { _, err := io.WriteString(out, ui.QR(res.ShortURL, false)+"\n") return err @@ -146,6 +164,10 @@ func printShortURL(cmd *cobra.Command, res *api.ShortURL, asJSON, showQR bool) e body := ui.OK.Render("βœ“ Link created") + "\n\n" + ui.Title.Render(res.ShortURL) + "\n" + ui.Dim.Render("β†’ "+truncate(res.LongURL, 60)) + if res.ClaimToken != "" { + body += "\n\n" + ui.Dim.Render("claim token ") + res.ClaimToken + "\n" + + ui.Dim.Render("anonymous link β€” keep this token to claim it into an account later") + } if showQR { body += "\n\n" + ui.QR(res.ShortURL, false) } diff --git a/internal/cmd/shorten_form.go b/internal/cmd/shorten_form.go index 475c8ff..aa3e0c1 100644 --- a/internal/cmd/shorten_form.go +++ b/internal/cmd/shorten_form.go @@ -9,7 +9,7 @@ import ( huh "charm.land/huh/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" ) var aliasRe = regexp.MustCompile(`^[A-Za-z0-9_-]{3,16}$`) @@ -17,7 +17,7 @@ var aliasRe = regexp.MustCompile(`^[A-Za-z0-9_-]{3,16}$`) // runShortenForm collects shorten options interactively. The alias field // validates against the live check-alias endpoint (rate limit 180/min β€” // the backend sizes it for interactive use). -func runShortenForm(ctx context.Context, client *api.Client, req *api.ShortenRequest) error { +func runShortenForm(ctx context.Context, client *spoo.Client, req *spoo.ShortenRequest) error { form := huh.NewForm( huh.NewGroup( huh.NewInput(). diff --git a/internal/cmd/shorten_test.go b/internal/cmd/shorten_test.go index d72d114..8cee9d0 100644 --- a/internal/cmd/shorten_test.go +++ b/internal/cmd/shorten_test.go @@ -10,7 +10,9 @@ import ( "github.com/zalando/go-keyring" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-go/option" + "github.com/spoo-me/spoo-cli/internal/auth" "github.com/spoo-me/spoo-cli/internal/config" ) @@ -23,7 +25,8 @@ func pointDepsAt(t *testing.T, srvURL string) { store := auth.NewStore(t.TempDir()) orig := newDeps newDeps = func() (*deps, error) { - return &deps{client: api.New(srvURL, store), store: store, cfg: config.Config{APIBase: srvURL}}, nil + client := spoo.NewClient(option.WithBaseURL(srvURL), option.WithTokenSource(store)) + return &deps{client: client, store: store, cfg: config.Config{APIBase: srvURL}}, nil } t.Cleanup(func() { newDeps = orig }) } @@ -44,7 +47,7 @@ func TestShortenCommandJSON(t *testing.T) { if err := root.Execute(); err != nil { t.Fatal(err) } - var res api.ShortURL + var res spoo.ShortURL if err := json.Unmarshal(out.Bytes(), &res); err != nil { t.Fatalf("output is not JSON: %v\n%s", err, out.String()) } @@ -78,6 +81,49 @@ func TestShortenCommandPipedBulk(t *testing.T) { } } +// Anonymous shortens come back with a one-time claim token: JSON output +// carries it, and piped output announces it on stderr so stdout stays +// exactly the short URL. +func TestShortenSurfacesClaimToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":"x","short_url":"https://spoo.me/abc","alias":"abc","long_url":"https://example.com","status":"ACTIVE","claim_token":"claim-123"}`)) + })) + defer srv.Close() + pointDepsAt(t, srv.URL) + + root := NewRootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"shorten", "https://example.com", "--json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var res spoo.ShortURL + if err := json.Unmarshal(out.Bytes(), &res); err != nil { + t.Fatal(err) + } + if res.ClaimToken != "claim-123" { + t.Fatalf("claim_token = %q, want claim-123", res.ClaimToken) + } + + root = NewRootCmd() + var plain, errOut bytes.Buffer + root.SetOut(&plain) + root.SetErr(&errOut) + root.SetArgs([]string{"shorten", "https://example.com"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(plain.String()); got != "https://spoo.me/abc" { + t.Fatalf("piped stdout = %q, want just the short URL", got) + } + if !strings.Contains(errOut.String(), "claim-123") || !strings.Contains(errOut.String(), "claim") { + t.Fatalf("stderr = %q, want the claim token notice", errOut.String()) + } +} + func TestShortenCommandAPIErrorSurfaces(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go index c2edbae..5d9f005 100644 --- a/internal/cmd/stats.go +++ b/internal/cmd/stats.go @@ -6,31 +6,36 @@ import ( "errors" "fmt" "io" - "time" tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" + spoo "github.com/spoo-me/spoo-go" - "github.com/spoo-me/spoo-cli/internal/api" "github.com/spoo-me/spoo-cli/internal/auth" "github.com/spoo-me/spoo-cli/internal/tui/stats" ) // resolveTarget maps a short code and login state onto a stats surface. // Logged in with a code, the alias resolves to an owned link's url id on -// the given domain (empty means the system default). On a 404 with -// --domain set there is nowhere to fall back to β€” the public endpoint -// serves only default-domain links β€” so it errors instead of silently -// showing a different link's stats. Without --domain the code may still -// be someone else's public default-domain link, so it falls back to the -// public endpoint, announcing the switch on errOut. -func resolveTarget(ctx context.Context, client *api.Client, code, domain, defaultDomain string, loggedIn bool, errOut io.Writer) (stats.Target, error) { +// the given domain (empty means the system default β€” the SDK wants the +// namespace spelled out, so the default is applied here where it is +// visible policy). On a 404 with --domain set there is nowhere to fall +// back to β€” the public endpoint serves only default-domain links β€” so it +// errors instead of silently showing a different link's stats. Without +// --domain the code may still be someone else's public default-domain +// link, so it falls back to the public endpoint, announcing the switch +// on errOut. +func resolveTarget(ctx context.Context, client *spoo.Client, code, domain, defaultDomain string, loggedIn bool, errOut io.Writer) (stats.Target, error) { switch { case code == "": return stats.Target{Kind: stats.KindAccount}, nil case loggedIn: - u, err := client.ResolveAlias(ctx, code, domain) - if api.IsNotFound(err) { + resolveDomain := domain + if resolveDomain == "" { + resolveDomain = defaultDomain + } + u, err := client.ResolveAlias(ctx, code, resolveDomain) + if spoo.IsNotFound(err) { if domain != "" { return stats.Target{}, fmt.Errorf("%s on %s is not one of your links β€” public stats cover only %s links", code, domain, defaultDomain) } @@ -94,7 +99,16 @@ With a short code, shows that link β€” public stats work without login.`, } asJSON, _ := cmd.Flags().GetBool("json") - customRange := from != "" || to != "" + fromT, err := parseDate(from) + if err != nil { + return err + } + toT, err := parseDate(to) + if err != nil { + return err + } + + customRange := !fromT.IsZero() || !toT.IsZero() if !asJSON && !plain && !customRange && stdoutIsTerminal(cmd) { model := stats.New(d.client, target, loggedIn, tz) final, err := tea.NewProgram(model).Run() @@ -109,23 +123,30 @@ With a short code, shows that link β€” public stats work without login.`, // static path: the API's implicit default is only 7 days; // use the widest window unless the user narrows it - if from == "" && to == "" { - from = timeNow().UTC().AddDate(0, 0, -api.MaxRangeDays).Format(time.RFC3339) + if fromT.IsZero() && toT.IsZero() { + fromT = timeNow().UTC().AddDate(0, 0, -spoo.MaxRangeDays) } - q := api.StatsQuery{ - StartDate: from, - EndDate: to, + q := spoo.StatsQuery{ + StartDate: fromT, + EndDate: toT, Timezone: tz, GroupBy: []string{"time", "browser", "os", "country", "referrer"}, } - var res *api.StatsResponse + var res *spoo.StatsResponse + var link *spoo.PublicLinkFacts switch target.Kind { case stats.KindOwnedLink: res, err = d.client.LinkStats(cmd.Context(), target.URLID, q) case stats.KindPublicLink: // no group_by here β€” the public endpoint returns every - // dimension in one response - res, err = d.client.PublicStats(cmd.Context(), code, from, to, tz) + // dimension in one response, alongside the link facts + var public *spoo.PublicStatsResult + public, err = d.client.PublicStats(cmd.Context(), code, spoo.PublicStatsQuery{ + StartDate: fromT, EndDate: toT, Timezone: tz, + }) + if public != nil { + res, link = &public.Stats, &public.Link + } default: res, err = d.client.Stats(cmd.Context(), q) } @@ -137,7 +158,7 @@ With a short code, shows that link β€” public stats work without login.`, enc.SetIndent("", " ") return enc.Encode(res) } - fmt.Fprintln(prettyOut(cmd), renderStats(res, code)) + fmt.Fprintln(prettyOut(cmd), renderStats(res, code, link)) return nil }, } diff --git a/internal/cmd/stats_render.go b/internal/cmd/stats_render.go index 6fe7d9e..f145e1d 100644 --- a/internal/cmd/stats_render.go +++ b/internal/cmd/stats_render.go @@ -5,7 +5,8 @@ import ( "sort" "strings" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -14,7 +15,7 @@ const ( topNPerDim = 5 ) -func renderBarChart(title string, points []api.MetricPoint, total float64) string { +func renderBarChart(title string, points []spoo.MetricPoint, total float64) string { if len(points) == 0 { return "" } @@ -41,7 +42,7 @@ func renderBarChart(title string, points []api.MetricPoint, total float64) strin return b.String() } -func renderSparkline(points []api.MetricPoint) string { +func renderSparkline(points []spoo.MetricPoint) string { if len(points) == 0 { return "" } @@ -68,7 +69,9 @@ func renderSparkline(points []api.MetricPoint) string { return b.String() } -func renderStats(res *api.StatsResponse, target string) string { +// renderStats renders the static report. link carries the public +// envelope's link facts and is nil on the owner surfaces. +func renderStats(res *spoo.StatsResponse, target string, link *spoo.PublicLinkFacts) string { var sections []string header := "all links" @@ -76,8 +79,8 @@ func renderStats(res *api.StatsResponse, target string) string { header = target } title := ui.Title.Render("Stats Β· " + header) - if res.TimeRange.StartDate != "" { - title += ui.Dim.Render(" " + isoDay(res.TimeRange.StartDate) + " β†’ " + isoDay(res.TimeRange.EndDate)) + if !res.TimeRange.StartDate.IsZero() { + title += ui.Dim.Render(" " + day(res.TimeRange.StartDate) + " β†’ " + day(res.TimeRange.EndDate)) } summary := fmt.Sprintf("%s\n\n%s %s\n%s %s", title, @@ -86,6 +89,9 @@ func renderStats(res *api.StatsResponse, target string) string { firstLast(res.Summary), ui.Dim.Render(fmt.Sprintf("avg redirect %.0fms", res.Summary.AvgRedirectionTime)), ) + if facts := linkFactsLine(link); facts != "" { + summary += "\n" + facts + } sections = append(sections, ui.Box.Render(summary)) if pts := res.Points("time", "clicks"); len(pts) > 0 { @@ -113,23 +119,31 @@ func renderStats(res *api.StatsResponse, target string) string { return strings.Join(sections, "\n") } -func isoDay(s string) string { - if len(s) >= 10 { - return s[:10] +// linkFactsLine summarizes the public envelope's link half: what the +// link is, next to how it performs. +func linkFactsLine(link *spoo.PublicLinkFacts) string { + if link == nil { + return "" + } + var parts []string + if link.Status != "" { + parts = append(parts, link.Status) + } + if link.LongURL != "" { + parts = append(parts, "β†’ "+truncate(link.LongURL, 60)) } - return s + if link.PasswordProtected { + parts = append(parts, "password protected") + } + if len(parts) == 0 { + return "" + } + return ui.Dim.Render(strings.Join(parts, " Β· ")) } -func firstLast(s api.StatsSummary) string { - if s.FirstClick == "" { +func firstLast(s spoo.StatsSummary) string { + if s.FirstClick.IsZero() { return ui.Dim.Render("no clicks yet") } - first, last := s.FirstClick, s.LastClick - if len(first) >= 10 { - first = first[:10] - } - if len(last) >= 10 { - last = last[:10] - } - return ui.Dim.Render("first " + first + " Β· last " + last) + return ui.Dim.Render("first " + day(s.FirstClick) + " Β· last " + day(s.LastClick)) } diff --git a/internal/cmd/stats_test.go b/internal/cmd/stats_test.go index 813401f..cb3f836 100644 --- a/internal/cmd/stats_test.go +++ b/internal/cmd/stats_test.go @@ -9,7 +9,9 @@ import ( "github.com/zalando/go-keyring" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-go/option" + "github.com/spoo-me/spoo-cli/internal/auth" "github.com/spoo-me/spoo-cli/internal/config" ) @@ -45,7 +47,8 @@ func pointDepsAtLoggedIn(t *testing.T, srvURL string) { } orig := newDeps newDeps = func() (*deps, error) { - return &deps{client: api.New(srvURL, store), store: store, cfg: config.Config{APIBase: srvURL}}, nil + client := spoo.NewClient(option.WithBaseURL(srvURL), option.WithTokenSource(store)) + return &deps{client: client, store: store, cfg: config.Config{APIBase: srvURL}}, nil } t.Cleanup(func() { newDeps = orig }) } diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..5bc81ae --- /dev/null +++ b/internal/cmd/version.go @@ -0,0 +1,18 @@ +package cmd + +import "regexp" + +// Version is the CLI release, injected by goreleaser via ldflags. +var Version = "dev" + +var versionRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,16}$`) + +// clientTag identifies the CLI (and its version, when well-formed) to +// the backend so API traffic can be attributed per client. It is +// passed to the SDK, which sends it as X-Spoo-Client. +func clientTag() string { + if versionRe.MatchString(Version) { + return "cli/" + Version + } + return "cli" +} diff --git a/internal/tui/kit/chart.go b/internal/tui/kit/chart.go index 9b30df8..05ff8bf 100644 --- a/internal/tui/kit/chart.go +++ b/internal/tui/kit/chart.go @@ -5,7 +5,8 @@ import ( "strings" "time" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -47,7 +48,7 @@ func ParseBucketTime(label string) (time.Time, bool) { // MiniSpark draws a compact sparkline covering the WHOLE series: when // there are more points than columns they are summed into buckets, so // old activity is never silently cut off the left edge. -func MiniSpark(pts []api.MetricPoint, width int) string { +func MiniSpark(pts []spoo.MetricPoint, width int) string { if len(pts) == 0 || width < 1 { return ui.Dim.Render("no data") } diff --git a/internal/tui/kit/text.go b/internal/tui/kit/text.go index af0aeb6..6021ead 100644 --- a/internal/tui/kit/text.go +++ b/internal/tui/kit/text.go @@ -6,6 +6,7 @@ import ( "strings" lipgloss "charm.land/lipgloss/v2" + spoo "github.com/spoo-me/spoo-go" ) // PadToWidth right-pads by display width (emoji-safe, unlike %-*s). @@ -28,12 +29,12 @@ func TruncateToWidth(s string, w int) string { return string(r) + "…" } -// ISODate keeps the YYYY-MM-DD prefix of an ISO 8601 timestamp. -func ISODate(s string) string { - if len(s) >= 10 { - return s[:10] +// Day renders a timestamp as YYYY-MM-DD, empty when unset. +func Day(t spoo.Timestamp) string { + if t.IsZero() { + return "" } - return s + return t.Format("2006-01-02") } // OrNever renders an empty value as "never". diff --git a/internal/tui/links/analytics.go b/internal/tui/links/analytics.go index e38eb87..20f028e 100644 --- a/internal/tui/links/analytics.go +++ b/internal/tui/links/analytics.go @@ -3,7 +3,8 @@ package links import ( "fmt" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -20,7 +21,7 @@ func (m Model) analyticsLines(alias string, label func(string) string, width int } res := e.res if res.Summary.TotalClicks == 0 { - return []string{ui.Dim.Render(fmt.Sprintf("no clicks in the last %d days", api.MaxRangeDays))} + return []string{ui.Dim.Render(fmt.Sprintf("no clicks in the last %d days", spoo.MaxRangeDays))} } total := float64(res.Summary.TotalClicks) unique := fmt.Sprintf("%d of %d clicks", res.Summary.UniqueClicks, res.Summary.TotalClicks) @@ -40,7 +41,7 @@ func (m Model) analyticsLines(alias string, label func(string) string, width int // topOf names the dominant label of a dimension with its share; format // optionally decorates the label (e.g. country flag emoji). -func topOf(res *api.StatsResponse, dimension string, total float64, format func(string) string) string { +func topOf(res *spoo.StatsResponse, dimension string, total float64, format func(string) string) string { pts := res.Points(dimension, "clicks") if len(pts) == 0 { return "β€”" diff --git a/internal/tui/links/editform.go b/internal/tui/links/editform.go index 7e3d9db..798c27d 100644 --- a/internal/tui/links/editform.go +++ b/internal/tui/links/editform.go @@ -13,7 +13,8 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -45,7 +46,7 @@ var editMeta = [statusField]editFieldMeta{ // bordered dialog. tab/↑↓ move (with wrap), enter saves, esc cancels. type editForm struct { open bool - item api.URLItem + item spoo.URLItem inputs [statusField]textinput.Model status string // "active" | "inactive" focus int @@ -55,7 +56,7 @@ type editForm struct { func newEditForm() editForm { return editForm{} } // show builds the form pre-filled from it and focuses the first field. -func (e editForm) show(it api.URLItem) (editForm, tea.Cmd) { +func (e editForm) show(it spoo.URLItem) (editForm, tea.Cmd) { e = editForm{open: true, item: it, status: strings.ToLower(it.Status)} if e.status != "active" && e.status != "inactive" { e.status = "active" // blocked/expired aren't user-settable @@ -209,18 +210,26 @@ func (e editForm) statusToggle() string { pick("inactive", e.status == "inactive", off) } -// changes returns the PATCH body for fields that differ from the -// original link. Status is upper-cased to the API's enum. -func (e editForm) changes() (map[string]any, error) { - f := map[string]any{} +// changes diffs the form against the original link. It returns the +// typed PATCH params for the SDK and a display map (field β†’ shown +// value) that drives the confirmation summary; an empty map means +// nothing changed. The PATCH is tri-state: fields left out of params +// keep their current setting, spoo.Null clears one (max clicks 0), and +// spoo.Set replaces it. Status is upper-cased to the API's enum. +func (e editForm) changes() (spoo.UpdateURLParams, map[string]any, error) { + var params spoo.UpdateURLParams + shown := map[string]any{} if v := e.inputs[fDest].Value(); v != e.item.LongURL { - f["long_url"] = v + params.LongURL = v + shown["long_url"] = v } if v := e.inputs[fAlias].Value(); v != e.item.Alias { - f["alias"] = v + params.Alias = v + shown["alias"] = v } if v := e.inputs[fPassword].Value(); v != "" { - f["password"] = v + params.Password = spoo.Set(v) + shown["password"] = v } if mc := e.inputs[fMaxClicks].Value(); mc != "" { n, _ := strconv.Atoi(mc) @@ -229,20 +238,26 @@ func (e editForm) changes() (map[string]any, error) { cur = *e.item.MaxClicks } if n != cur { - f["max_clicks"] = n + params.MaxClicks = spoo.Set(n) + if n == 0 { + params.MaxClicks = spoo.Null[int]() // 0 removes the limit + } + shown["max_clicks"] = n } } if exp := e.inputs[fExpires].Value(); exp != "" { - v, err := api.ParseExpiry(exp, time.Now()) + v, err := spoo.ParseExpiry(exp, time.Now()) if err != nil { - return nil, err + return spoo.UpdateURLParams{}, nil, err } - f["expire_after"] = v + params.ExpireAfter = spoo.Set(v) + shown["expire_after"] = exp } if e.status != strings.ToLower(e.item.Status) { - f["status"] = strings.ToUpper(e.status) // API wants ACTIVE / INACTIVE + params.Status = strings.ToUpper(e.status) // API wants ACTIVE / INACTIVE + shown["status"] = params.Status } - return f, nil + return params, shown, nil } // summary lists the pending changes for the confirmation dialog. diff --git a/internal/tui/links/links_test.go b/internal/tui/links/links_test.go index 93ed198..2083d56 100644 --- a/internal/tui/links/links_test.go +++ b/internal/tui/links/links_test.go @@ -1,7 +1,7 @@ package links import ( - "fmt" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -11,20 +11,28 @@ import ( tea "charm.land/bubbletea/v2" "github.com/zalando/go-keyring" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-go/option" + "github.com/spoo-me/spoo-cli/internal/auth" "github.com/spoo-me/spoo-cli/internal/tui/kit" ) +// newTestClient builds an SDK client wired to the store, the same way +// the commands construct theirs. +func newTestClient(base string, store *auth.Store) *spoo.Client { + return spoo.NewClient(option.WithBaseURL(base), option.WithTokenSource(store)) +} + func newLinksModelWithPage(t *testing.T, srvURL string) Model { t.Helper() keyring.MockInit() _ = keyring.Delete("spoo-cli", "credentials") - client := api.New(srvURL, auth.NewStore(t.TempDir())) - m := New(client, srvURL, api.ListURLsOptions{}, func(string) error { return nil }, func(string) error { return nil }) + client := newTestClient(srvURL, auth.NewStore(t.TempDir())) + m := New(client, srvURL, spoo.ListURLsOptions{}, func(string) error { return nil }, func(string) error { return nil }) - page := &api.URLPage{ - Items: []api.URLItem{ + page := &spoo.URLPage{ + Items: []spoo.URLItem{ {ID: "id-first", Alias: "first", LongURL: "https://a.com", Status: "ACTIVE"}, {ID: "id-second", Alias: "second", LongURL: "https://b.com", Status: "ACTIVE"}, {ID: "id-third", Alias: "third", LongURL: "https://c.com", Status: "ACTIVE"}, @@ -114,8 +122,8 @@ func TestFetchCarriesOptions(t *testing.T) { defer srv.Close() keyring.MockInit() - client := api.New(srv.URL, auth.NewStore(t.TempDir())) - m := New(client, srv.URL, api.ListURLsOptions{ + client := newTestClient(srv.URL, auth.NewStore(t.TempDir())) + m := New(client, srv.URL, spoo.ListURLsOptions{ SortBy: "last_click", PageSize: 50, Status: "INACTIVE", Search: "demo", }, nil, nil) m.Init()() // run the initial fetch command @@ -131,8 +139,8 @@ func TestFetchCarriesOptions(t *testing.T) { func TestDefaultSortIsTotalClicks(t *testing.T) { keyring.MockInit() - client := api.New("http://unused.invalid", auth.NewStore(t.TempDir())) - m := New(client, "http://unused.invalid", api.ListURLsOptions{}, nil, nil) + client := newTestClient("http://unused.invalid", auth.NewStore(t.TempDir())) + m := New(client, "http://unused.invalid", spoo.ListURLsOptions{}, nil, nil) if m.opts.SortBy != "total_clicks" { t.Fatalf("default sort = %q, want total_clicks", m.opts.SortBy) } @@ -365,7 +373,7 @@ func TestStatsDebounceDropsStaleTicks(t *testing.T) { // cached rows schedule nothing β€” revisiting is free. func TestStatsCacheSkipsRefetch(t *testing.T) { m := newLinksModelWithPage(t, "http://unused.invalid") - m.stats["first"] = statsEntry{res: &api.StatsResponse{}} + m.stats["first"] = statsEntry{res: &spoo.StatsResponse{}} next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = next.(Model) if cmd != nil { @@ -381,8 +389,8 @@ func TestDetailRendersAnalytics(t *testing.T) { m := newLinksModelWithPage(t, "http://unused.invalid") next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = next.(Model) - next, _ = m.Update(statsMsg{alias: "first", res: &api.StatsResponse{ - Summary: api.StatsSummary{TotalClicks: 10, UniqueClicks: 4, AvgRedirectionTime: 42}, + next, _ = m.Update(statsMsg{alias: "first", res: &spoo.StatsResponse{ + Summary: spoo.StatsSummary{TotalClicks: 10, UniqueClicks: 4, AvgRedirectionTime: 42}, Metrics: map[string][]map[string]any{ "clicks_by_time": {{"time": "2026-06-01", "clicks": 10.0}}, "clicks_by_browser": {{"browser": "Chrome", "clicks": 9.0}}, @@ -402,9 +410,9 @@ func TestDetailRendersAnalytics(t *testing.T) { // the sparkline covers the whole series: early activity must not be // truncated off the left edge when there are more points than columns. func TestMiniSparkDownsamplesWholeSeries(t *testing.T) { - pts := make([]api.MetricPoint, 90) + pts := make([]spoo.MetricPoint, 90) for i := range pts { - pts[i] = api.MetricPoint{Label: "d", Value: 0} + pts[i] = spoo.MetricPoint{Label: "d", Value: 0} } pts[3].Value = 28 // old spike, far outside the last 30 columns got := kit.MiniSpark(pts, 30) @@ -445,14 +453,14 @@ func TestQRDialog(t *testing.T) { // The edit form diffs against the original and only PATCHes real changes. func TestEditFormChanges(t *testing.T) { max := 100 - it := api.URLItem{ + it := spoo.URLItem{ ID: "id-x", Alias: "launch", LongURL: "https://old.com", Status: "ACTIVE", MaxClicks: &max, } e, _ := newEditForm().show(it) // no edits β†’ no changes - if ch, _ := e.changes(); len(ch) != 0 { + if _, ch, _ := e.changes(); len(ch) != 0 { t.Fatalf("unchanged form yields %v, want empty", ch) } @@ -461,25 +469,38 @@ func TestEditFormChanges(t *testing.T) { e.inputs[fAlias].SetValue("promo") e.inputs[fMaxClicks].SetValue("0") e.status = "inactive" - ch, err := e.changes() + params, ch, err := e.changes() if err != nil { t.Fatal(err) } - want := map[string]any{ - "long_url": "https://new.com", "alias": "promo", - "max_clicks": 0, "status": "INACTIVE", // API enum is upper-case + if len(ch) != 4 { + t.Fatalf("changes = %v, want 4 entries", ch) } - if len(ch) != len(want) { - t.Fatalf("changes = %v, want %v", ch, want) + if params.LongURL != "https://new.com" || params.Alias != "promo" { + t.Fatalf("params = %+v", params) } - for k, v := range want { - if fmt.Sprintf("%v", ch[k]) != fmt.Sprintf("%v", v) { - t.Fatalf("changes[%q] = %v, want %v", k, ch[k], v) - } + if params.Status != "INACTIVE" { // API enum is upper-case + t.Fatalf("status = %q, want INACTIVE", params.Status) + } + // 0 means "remove the limit": the tri-state PATCH spells that null + if !params.MaxClicks.IsNull() { + t.Fatalf("max_clicks = %+v, want explicit null", params.MaxClicks) } - if _, ok := ch["password"]; ok { + if !params.Password.IsZero() { t.Fatal("blank password must not be sent") } + + // the wire body carries null for the cleared limit and omits password + body, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"max_clicks":null`) { + t.Fatalf("body = %s, want max_clicks:null", body) + } + if strings.Contains(string(body), "password") { + t.Fatalf("body = %s, must omit password", body) + } } // 'e' opens the editor pre-filled with the selected link. diff --git a/internal/tui/links/model.go b/internal/tui/links/model.go index 67e71d5..2dab72a 100644 --- a/internal/tui/links/model.go +++ b/internal/tui/links/model.go @@ -13,7 +13,8 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -33,7 +34,7 @@ const ( var sortFields = []string{"total_clicks", "created_at", "last_click"} type pageMsg struct { - page *api.URLPage + page *spoo.URLPage err error } @@ -50,12 +51,12 @@ type statsTickMsg struct { type statsMsg struct { alias string - res *api.StatsResponse + res *spoo.StatsResponse err error } type statsEntry struct { - res *api.StatsResponse + res *spoo.StatsResponse err error } @@ -63,26 +64,26 @@ type statsEntry struct { // GET /api/v1/urls with open/copy/toggle/delete actions, live search // (/), sort cycling (s), and a master-detail pane (enter). type Model struct { - client *api.Client + client *spoo.Client apiBase string openBrowser func(string) error copyText func(string) error - opts api.ListURLsOptions // current query: search, sort, status, page size + opts spoo.ListURLsOptions // current query: search, sort, status, page size tbl table.Model pager paginator.Model searchBox textinput.Model searching bool - edit editForm // 'e' opens the pre-filled link editor - confirm confirmDialog // shared save/delete confirmation - pendingPATCH map[string]any // edit changes awaiting confirmation - helper help.Model // ? flips between short and full key help - qrURL string // non-empty: the QR dialog is up for this URL - showDetail bool // detail pane open; it always reflects the selected row + edit editForm // 'e' opens the pre-filled link editor + confirm confirmDialog // shared save/delete confirmation + pendingPATCH spoo.UpdateURLParams // edit changes awaiting confirmation + helper help.Model // ? flips between short and full key help + qrURL string // non-empty: the QR dialog is up for this URL + showDetail bool // detail pane open; it always reflects the selected row stats map[string]statsEntry statsSeq int // bumped on selection change; stale debounce ticks no-op - page *api.URLPage + page *spoo.URLPage pageNo int status string // transient status-bar message loading bool @@ -91,7 +92,7 @@ type Model struct { height int } -func New(client *api.Client, apiBase string, opts api.ListURLsOptions, openBrowser, copyText func(string) error) Model { +func New(client *spoo.Client, apiBase string, opts spoo.ListURLsOptions, openBrowser, copyText func(string) error) Model { if opts.PageSize <= 0 { opts.PageSize = defaultPageSize } @@ -186,7 +187,7 @@ func (m *Model) syncPager() { func (m Model) Init() tea.Cmd { return m.fetch(m.pageNo) } -func (m Model) selected() *api.URLItem { +func (m Model) selected() *spoo.URLItem { if m.page == nil || len(m.page.Items) == 0 { return nil } @@ -197,7 +198,7 @@ func (m Model) selected() *api.URLItem { return &m.page.Items[i] } -func (m Model) shortURL(it *api.URLItem) string { +func (m Model) shortURL(it *spoo.URLItem) string { if it.Domain != "" { return "https://" + it.Domain + "/" + it.Alias } @@ -221,7 +222,7 @@ func (m Model) rows() []table.Row { it.LongURL, strconv.Itoa(it.TotalClicks), it.Status, - kit.ISODate(it.CreatedAt), + kit.Day(it.CreatedAt), }) } return rows diff --git a/internal/tui/links/update.go b/internal/tui/links/update.go index 6517be7..c7a8936 100644 --- a/internal/tui/links/update.go +++ b/internal/tui/links/update.go @@ -7,7 +7,8 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -104,18 +105,18 @@ func (m Model) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } if done { - changes, err := m.edit.changes() + params, changed, err := m.edit.changes() if err != nil { m.status = ui.Err.Render("βœ— " + err.Error()) return m, cmd } - if len(changes) == 0 { + if len(changed) == 0 { m.status = ui.Dim.Render("no changes") return m, cmd } - m.pendingPATCH = changes + m.pendingPATCH = params it := m.edit.item - m.confirm = m.confirm.askSimple("save", it.ID, "Save changes to "+it.Alias+"?", m.edit.summary(changes)) + m.confirm = m.confirm.askSimple("save", it.ID, "Save changes to "+it.Alias+"?", m.edit.summary(changed)) } return m, cmd } @@ -132,7 +133,7 @@ func (m Model) updateConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch m.confirm.tag { case "save": patch := m.pendingPATCH - m.pendingPATCH = nil + m.pendingPATCH = spoo.UpdateURLParams{} m.status = ui.Dim.Render("saving…") return m, m.applyPATCH(m.confirm.tagID, patch) case "delete": @@ -143,10 +144,10 @@ func (m Model) updateConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } // applyPATCH sends the staged edit and reports the outcome. -func (m Model) applyPATCH(id string, fields map[string]any) tea.Cmd { +func (m Model) applyPATCH(id string, params spoo.UpdateURLParams) tea.Cmd { client := m.client return func() tea.Msg { - _, err := client.UpdateURL(context.Background(), id, fields) + _, err := client.UpdateURL(context.Background(), id, params) return actionMsg{note: "link updated", err: err} } } @@ -318,35 +319,34 @@ func (m *Model) scheduleStats() tea.Cmd { }) } -func (m Model) fetchStats(it *api.URLItem) tea.Cmd { +func (m Model) fetchStats(it *spoo.URLItem) tea.Cmd { client := m.client id, alias := it.ID, it.Alias return func() tea.Msg { // the endpoint defaults to a 7-day window; ask for the maximum - from := time.Now().UTC().AddDate(0, 0, -api.MaxRangeDays).Format(time.RFC3339) - res, err := client.LinkStats(context.Background(), id, api.StatsQuery{ - StartDate: from, + res, err := client.LinkStats(context.Background(), id, spoo.StatsQuery{ + StartDate: time.Now().UTC().AddDate(0, 0, -spoo.MaxRangeDays), GroupBy: []string{"time", "browser", "os", "country", "referrer"}, }) return statsMsg{alias: alias, res: res, err: err} } } -func (m Model) openStatus(it *api.URLItem) string { +func (m Model) openStatus(it *spoo.URLItem) string { if err := m.openBrowser(m.shortURL(it)); err != nil { return ui.Err.Render("βœ— " + err.Error()) } return ui.Dim.Render("opened " + m.shortURL(it)) } -func (m Model) copyStatus(it *api.URLItem) string { +func (m Model) copyStatus(it *spoo.URLItem) string { if err := m.copyText(m.shortURL(it)); err != nil { return ui.Err.Render("βœ— " + err.Error()) } return ui.OK.Render("βœ“ copied " + m.shortURL(it)) } -func (m Model) toggleStatus(it *api.URLItem) tea.Cmd { +func (m Model) toggleStatus(it *spoo.URLItem) tea.Cmd { client := m.client id, alias, status := it.ID, it.Alias, it.Status return func() tea.Msg { @@ -354,7 +354,8 @@ func (m Model) toggleStatus(it *api.URLItem) tea.Cmd { if status != "ACTIVE" { next = "ACTIVE" } - _, err := client.UpdateURL(context.Background(), id, map[string]any{"status": next}) + // the dedicated status endpoint: a toggle is not a general PATCH + _, err := client.SetURLStatus(context.Background(), id, next) return actionMsg{note: alias + " β†’ " + next, err: err} } } diff --git a/internal/tui/links/view.go b/internal/tui/links/view.go index 1aa78a2..1dcf79a 100644 --- a/internal/tui/links/view.go +++ b/internal/tui/links/view.go @@ -4,7 +4,6 @@ import ( "fmt" "strconv" "strings" - "time" tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" @@ -120,8 +119,8 @@ func (m Model) detailView(width int) string { field("destination", it.LongURL), "", label("clicks") + strconv.Itoa(it.TotalClicks), - label("created") + kit.ISODate(it.CreatedAt), - label("last click") + kit.OrNever(kit.ISODate(it.LastClick)), + label("created") + kit.Day(it.CreatedAt), + label("last click") + kit.OrNever(kit.Day(it.LastClick)), "", label("password") + yesNo(it.PasswordSet), label("private stats") + yesNo(it.PrivateStats), @@ -130,8 +129,8 @@ func (m Model) detailView(width int) string { if it.MaxClicks != nil { lines = append(lines, label("max clicks")+strconv.Itoa(*it.MaxClicks)) } - if it.ExpireAfter != nil { - lines = append(lines, label("expires")+time.Unix(*it.ExpireAfter, 0).UTC().Format("2006-01-02 15:04 MST")) + if !it.ExpireAfter.IsZero() { + lines = append(lines, label("expires")+it.ExpireAfter.UTC().Format("2006-01-02 15:04 MST")) } if it.Domain != "" { lines = append(lines, label("domain")+it.Domain) diff --git a/internal/tui/stats/data.go b/internal/tui/stats/data.go index 8a34231..8bafc60 100644 --- a/internal/tui/stats/data.go +++ b/internal/tui/stats/data.go @@ -3,13 +3,15 @@ package stats import ( "context" "image/color" + "io" "os" "sort" "time" tea "charm.land/bubbletea/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -28,20 +30,20 @@ func (m Model) window() (start, end time.Time) { // query builds the stats request for the current dashboard state. // The public endpoint reads only the range and timezone from it β€” no // group_by (it answers with every dimension at once) and no filters. -func (m Model) query() api.StatsQuery { +func (m Model) query() spoo.StatsQuery { start, end := m.window() groupBy := []string{"time", "browser", "os", "country", "city", "referrer"} if m.target.Kind == KindAccount { groupBy = append(groupBy, "short_code") } - q := api.StatsQuery{ - StartDate: start.Format(time.RFC3339), + q := spoo.StatsQuery{ + StartDate: start, Timezone: m.tz, GroupBy: groupBy, Filters: map[string][]string{}, } if m.offset > 0 || !m.win.anchored() { - q.EndDate = end.Format(time.RFC3339) + q.EndDate = end } for _, f := range m.filters { q.Filters[f.dim] = append(q.Filters[f.dim], f.value) @@ -49,13 +51,21 @@ func (m Model) query() api.StatsQuery { return q } -// getStats routes a query to the target's endpoint. -func (m Model) getStats(ctx context.Context, q api.StatsQuery) (*api.StatsResponse, error) { +// getStats routes a query to the target's endpoint. The public +// envelope pairs link facts with stats; the dashboard reads the stats +// half. +func (m Model) getStats(ctx context.Context, q spoo.StatsQuery) (*spoo.StatsResponse, error) { switch m.target.Kind { case KindOwnedLink: return m.client.LinkStats(ctx, m.target.URLID, q) case KindPublicLink: - return m.client.PublicStats(ctx, m.target.Alias, q.StartDate, q.EndDate, q.Timezone) + res, err := m.client.PublicStats(ctx, m.target.Alias, spoo.PublicStatsQuery{ + StartDate: q.StartDate, EndDate: q.EndDate, Timezone: q.Timezone, + }) + if err != nil { + return nil, err + } + return &res.Stats, nil default: return m.client.Stats(ctx, q) } @@ -68,12 +78,12 @@ func (m Model) fetch() tea.Cmd { prevQ := q prevQ.GroupBy = []string{"time"} start, _ := m.window() - prevQ.StartDate = start.Add(-m.win.span).Format(time.RFC3339) - prevQ.EndDate = start.Format(time.RFC3339) + prevQ.StartDate = start.Add(-m.win.span) + prevQ.EndDate = start return func() tea.Msg { res, err := m.getStats(context.Background(), q) - var prev *api.StatsResponse + var prev *spoo.StatsResponse if err == nil { prev, _ = m.getStats(context.Background(), prevQ) // best-effort } @@ -98,33 +108,47 @@ func (m Model) openExport() (tea.Model, tea.Cmd) { } // export downloads the current view in the requested format and -// writes it where the dialog pointed. +// streams it to where the dialog pointed. func (m Model) export(req exportRequest) tea.Cmd { client := m.client target := m.target q := m.query() return func() tea.Msg { - var data []byte + var file *spoo.ExportFile var err error if target.Kind == KindOwnedLink { - _, data, err = client.ExportLink(context.Background(), target.URLID, q, req.format) + file, err = client.ExportLink(context.Background(), target.URLID, q, req.format) } else { - _, data, err = client.Export(context.Background(), q, req.format) + file, err = client.Export(context.Background(), q, req.format) } if err == nil { - err = os.WriteFile(req.path, data, 0o644) + err = writeStream(req.path, file.Body) } return exportDoneMsg{name: collapseHome(req.path), err: err} } } +// writeStream copies a download body to disk, owning the close. +func writeStream(path string, body io.ReadCloser) error { + defer body.Close() + out, err := os.Create(path) + if err != nil { + return err + } + _, err = io.Copy(out, body) + if closeErr := out.Close(); err == nil { + err = closeErr + } + return err +} + func autoTick() tea.Cmd { return tea.Tick(autoEvery, func(time.Time) tea.Msg { return autoTickMsg{} }) } // panelPoints returns a panel's rows for the active metric, capped to // n. Used by both rendering and drill-down so selection always matches. -func (m Model) panelPoints(idx, n int) []api.MetricPoint { +func (m Model) panelPoints(idx, n int) []spoo.MetricPoint { if m.res == nil { return nil } @@ -141,7 +165,7 @@ func (m Model) panelPoints(idx, n int) []api.MetricPoint { } // weekdayPoints folds the time series into a Monβ†’Sun distribution. -func (m Model) weekdayPoints() []api.MetricPoint { +func (m Model) weekdayPoints() []spoo.MetricPoint { var totals [7]float64 for _, p := range m.res.Points("time", m.metric) { if ts, ok := kit.ParseBucketTime(p.Label); ok { @@ -149,10 +173,10 @@ func (m Model) weekdayPoints() []api.MetricPoint { } } names := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} - out := make([]api.MetricPoint, 0, 7) + out := make([]spoo.MetricPoint, 0, 7) for i := 1; i <= 7; i++ { // Monday first idx := i % 7 - out = append(out, api.MetricPoint{Label: names[idx], Value: totals[idx]}) + out = append(out, spoo.MetricPoint{Label: names[idx], Value: totals[idx]}) } return out } diff --git a/internal/tui/stats/model.go b/internal/tui/stats/model.go index 034a7ba..d8aba5c 100644 --- a/internal/tui/stats/model.go +++ b/internal/tui/stats/model.go @@ -7,7 +7,8 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" ) @@ -22,7 +23,7 @@ const ( // defaultWindow is the widest window the server allows β€” the silent // server default is only 7 days, which hides most history. -var defaultWindow = timeWindow{span: api.MaxRangeDays * 24 * time.Hour, label: "90d"} +var defaultWindow = timeWindow{span: spoo.MaxRangeDays * 24 * time.Hour, label: "90d"} type panelDef struct{ key, title string } @@ -45,8 +46,8 @@ type Target struct { } type statsLoadedMsg struct { - res *api.StatsResponse - prev *api.StatsResponse // previous window, for period-over-period deltas + res *spoo.StatsResponse + prev *spoo.StatsResponse // previous window, for period-over-period deltas err error } @@ -66,7 +67,7 @@ type filterEntry struct { // period deltas, a dual-series time chart, focusable breakdown panels // with server-side drill-down, window paging, and a focus mode. type Model struct { - client *api.Client + client *spoo.Client target Target loggedIn bool // gates the link switcher and export tz string @@ -87,11 +88,11 @@ type Model struct { switchMode bool // the 'g' link picker is up switchBox textinput.Model - switchAll []api.URLItem // fetched once, cached for the session - switchSel int // 0 = "all links", 1.. = filtered items + switchAll []spoo.URLItem // fetched once, cached for the session + switchSel int // 0 = "all links", 1.. = filtered items - res *api.StatsResponse - prev *api.StatsResponse + res *spoo.StatsResponse + prev *spoo.StatsResponse fetchErr error loading bool status string @@ -107,7 +108,7 @@ type Model struct { height int } -func New(client *api.Client, target Target, loggedIn bool, tz string) Model { +func New(client *spoo.Client, target Target, loggedIn bool, tz string) Model { rangeBox := textinput.New() rangeBox.Placeholder = "type a range…" rangeBox.SetWidth(36) // fits "2026-01-01 to 2026-02-15" with room; keeps the cheat-sheet column still diff --git a/internal/tui/stats/panels.go b/internal/tui/stats/panels.go index ca50b2f..c663ff5 100644 --- a/internal/tui/stats/panels.go +++ b/internal/tui/stats/panels.go @@ -6,7 +6,8 @@ import ( lipgloss "charm.land/lipgloss/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -77,7 +78,7 @@ func (m Model) panelView(idx, width, contentRows, topN int) string { // between the trail and the number so neither the fill nor the dots ever // touch the digits. maxV/total (scaling the bars and shares) are derived // from the points; sel highlights one row (-1 for none). -func (m Model) barLines(panelKey string, pts []api.MetricPoint, labelW, barMax, countW, sel int) []string { +func (m Model) barLines(panelKey string, pts []spoo.MetricPoint, labelW, barMax, countW, sel int) []string { var maxV, total float64 for _, pt := range pts { maxV = max(maxV, pt.Value) diff --git a/internal/tui/stats/panels_test.go b/internal/tui/stats/panels_test.go index 8864e8e..f7e4c1f 100644 --- a/internal/tui/stats/panels_test.go +++ b/internal/tui/stats/panels_test.go @@ -9,7 +9,8 @@ import ( lipgloss "charm.land/lipgloss/v2" "github.com/zalando/go-keyring" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/auth" ) @@ -21,10 +22,10 @@ var ansiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") // no row's bar touching its number. func TestPanelRowsAligned(t *testing.T) { keyring.MockInit() - client := api.New("http://x", auth.NewStore(t.TempDir())) + client := newTestClient("http://x", auth.NewStore(t.TempDir())) m := New(client, Target{}, true, "") - resp := &api.StatsResponse{ - Summary: api.StatsSummary{TotalClicks: 287558}, + resp := &spoo.StatsResponse{ + Summary: spoo.StatsSummary{TotalClicks: 287558}, Metrics: map[string][]map[string]any{ "clicks_by_browser": { {"browser": "Chrome", "clicks": 131881.0}, diff --git a/internal/tui/stats/rangeexpr.go b/internal/tui/stats/rangeexpr.go index dd08aaa..e20df71 100644 --- a/internal/tui/stats/rangeexpr.go +++ b/internal/tui/stats/rangeexpr.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" ) // timeWindow is the dashboard's stats window. Windows anchored to @@ -133,8 +133,8 @@ func parseRangeExpr(input string, now time.Time) (timeWindow, error) { return timeWindow{}, fmt.Errorf("start must precede end") case span < time.Minute: return timeWindow{}, fmt.Errorf("range must cover at least a minute") - case span > api.MaxRangeDays*24*time.Hour: - return timeWindow{}, fmt.Errorf("range exceeds the server's %dd cap", api.MaxRangeDays) + case span > spoo.MaxRangeDays*24*time.Hour: + return timeWindow{}, fmt.Errorf("range exceeds the server's %dd cap", spoo.MaxRangeDays) } w := timeWindow{span: span, label: s} diff --git a/internal/tui/stats/stats_test.go b/internal/tui/stats/stats_test.go index 96b2458..0bc19f4 100644 --- a/internal/tui/stats/stats_test.go +++ b/internal/tui/stats/stats_test.go @@ -12,15 +12,25 @@ import ( tea "charm.land/bubbletea/v2" "github.com/zalando/go-keyring" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-go/option" + "github.com/spoo-me/spoo-cli/internal/auth" ) -func testStatsResponse() *api.StatsResponse { - return &api.StatsResponse{ - Summary: api.StatsSummary{TotalClicks: 100, UniqueClicks: 40, AvgRedirectionTime: 88}, - TimeRange: api.StatsTimeRange{ - StartDate: "2026-03-12T00:00:00Z", EndDate: "2026-06-10T00:00:00Z", +func mustTS(s string) spoo.Timestamp { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return spoo.Timestamp{Time: t} +} + +func testStatsResponse() *spoo.StatsResponse { + return &spoo.StatsResponse{ + Summary: spoo.StatsSummary{TotalClicks: 100, UniqueClicks: 40, AvgRedirectionTime: 88}, + TimeRange: spoo.StatsTimeRange{ + StartDate: mustTS("2026-03-12T00:00:00Z"), EndDate: mustTS("2026-06-10T00:00:00Z"), }, ComputedMetrics: map[string]float64{"unique_click_rate": 40, "average_clicks_per_visitor": 2.5}, Metrics: map[string][]map[string]any{ @@ -39,10 +49,16 @@ func testStatsResponse() *api.StatsResponse { } } +// newTestClient builds an SDK client wired to the store, the same way +// the commands construct theirs. +func newTestClient(base string, store *auth.Store) *spoo.Client { + return spoo.NewClient(option.WithBaseURL(base), option.WithTokenSource(store)) +} + func newStatsModel(t *testing.T, srvURL string) Model { t.Helper() keyring.MockInit() - client := api.New(srvURL, auth.NewStore(t.TempDir())) + client := newTestClient(srvURL, auth.NewStore(t.TempDir())) m := New(client, Target{}, true, "") next, _ := m.Update(statsLoadedMsg{res: testStatsResponse()}) return next.(Model) @@ -449,7 +465,7 @@ func TestPublicViewIsReadOnly(t *testing.T) { defer srv.Close() keyring.MockInit() - client := api.New(srv.URL, auth.NewStore(t.TempDir())) + client := newTestClient(srv.URL, auth.NewStore(t.TempDir())) m := New(client, Target{Kind: KindPublicLink, Alias: "launch"}, false, "") for _, msg := range drainCmd(m.Init()) { next, _ := m.Update(msg) @@ -504,8 +520,8 @@ func drainCmd(cmd tea.Cmd) []tea.Msg { // p ghosts the previous window's series on the time chart. func TestPrevPeriodGhost(t *testing.T) { m := newStatsModel(t, "http://unused.invalid") - m.prev = &api.StatsResponse{ - Summary: api.StatsSummary{TotalClicks: 30}, + m.prev = &spoo.StatsResponse{ + Summary: spoo.StatsSummary{TotalClicks: 30}, Metrics: map[string][]map[string]any{ "clicks_by_time": {{"time": "2026-03-05", "clicks": 30.0}}, }, @@ -584,11 +600,13 @@ func TestExportModal(t *testing.T) { } } -// an owned-link view exports through the per-link endpoint. +// an owned-link view exports through the unified endpoint, sliced to +// the link with the url_id filter. func TestExportRoutesOwnedLinkToPerLinkEndpoint(t *testing.T) { - var gotPath string + var gotPath, gotURLID string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path + gotURLID = r.URL.Query().Get("url_id") w.Write([]byte(`{}`)) })) defer srv.Close() @@ -599,8 +617,8 @@ func TestExportRoutesOwnedLinkToPerLinkEndpoint(t *testing.T) { if done, ok := cmd().(exportDoneMsg); !ok || done.err != nil { t.Fatalf("export failed: %+v", done) } - if gotPath != "/api/v1/export/links/id-launch" { - t.Fatalf("path = %q, want the per-link export endpoint", gotPath) + if gotPath != "/api/v1/export/links/id-launch" || gotURLID != "" { + t.Fatalf("path = %q url_id = %q, want the per-link endpoint with no url_id param", gotPath, gotURLID) } } diff --git a/internal/tui/stats/switcher.go b/internal/tui/stats/switcher.go index 1dc91e4..40e624f 100644 --- a/internal/tui/stats/switcher.go +++ b/internal/tui/stats/switcher.go @@ -8,7 +8,8 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -19,7 +20,7 @@ import ( const switcherRows = 8 // result rows visible at once type linksListMsg struct { - items []api.URLItem + items []spoo.URLItem err error } @@ -36,7 +37,7 @@ func (m Model) openSwitcher() (tea.Model, tea.Cmd) { if m.switchAll == nil { client := m.client cmds = append(cmds, func() tea.Msg { - page, err := client.ListURLs(context.Background(), api.ListURLsOptions{ + page, err := client.ListURLs(context.Background(), spoo.ListURLsOptions{ PageSize: 100, SortBy: "total_clicks", }) if err != nil { @@ -49,12 +50,12 @@ func (m Model) openSwitcher() (tea.Model, tea.Cmd) { } // switchCandidates filters the cached list by the typed query. -func (m Model) switchCandidates() []api.URLItem { +func (m Model) switchCandidates() []spoo.URLItem { q := strings.ToLower(strings.TrimSpace(m.switchBox.Value())) if q == "" { return m.switchAll } - var out []api.URLItem + var out []spoo.URLItem for _, it := range m.switchAll { if strings.Contains(strings.ToLower(it.Alias), q) || strings.Contains(strings.ToLower(it.LongURL), q) { diff --git a/internal/tui/stats/view.go b/internal/tui/stats/view.go index 58bd72d..eb140fa 100644 --- a/internal/tui/stats/view.go +++ b/internal/tui/stats/view.go @@ -97,8 +97,8 @@ func (m Model) headerLine() string { if m.target.Kind == KindPublicLink { h += ui.Dim.Render(" (public)") } - if m.res != nil && m.res.TimeRange.StartDate != "" { - h += ui.Dim.Render(" Β· " + kit.ISODate(m.res.TimeRange.StartDate) + " β†’ " + kit.ISODate(m.res.TimeRange.EndDate)) + if m.res != nil && !m.res.TimeRange.StartDate.IsZero() { + h += ui.Dim.Render(" Β· " + kit.Day(m.res.TimeRange.StartDate) + " β†’ " + kit.Day(m.res.TimeRange.EndDate)) } else { h += ui.Dim.Render(" Β· last " + m.win.label) } diff --git a/internal/tui/stats/view_overview.go b/internal/tui/stats/view_overview.go index a7066dc..ec98e7a 100644 --- a/internal/tui/stats/view_overview.go +++ b/internal/tui/stats/view_overview.go @@ -7,7 +7,8 @@ import ( lipgloss "charm.land/lipgloss/v2" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -16,7 +17,7 @@ import ( // of a StatsResponse (and the previous window, for the delta badge). It // declares exactly what it reads instead of reaching into the model. type overviewCard struct { - res, prev *api.StatsResponse + res, prev *spoo.StatsResponse metric string span time.Duration labelW int @@ -53,10 +54,10 @@ func (c overviewCard) render() string { if active, ok := c.activeDays(); ok { rows = append(rows, row("active days", active, plain)) } - if s.FirstClick != "" { + if !s.FirstClick.IsZero() { rows = append(rows, - row("first click", kit.ISODate(s.FirstClick), plain), - row("last click", kit.ISODate(s.LastClick), plain)) + row("first click", kit.Day(s.FirstClick), plain), + row("last click", kit.Day(s.LastClick), plain)) } return strings.Join(rows, "\n") } @@ -85,7 +86,7 @@ func (c overviewCard) deltaBadge() string { } func (c overviewCard) bestDay() (string, bool) { - var best api.MetricPoint + var best spoo.MetricPoint for _, p := range c.res.Points("time", c.metric) { if p.Value > best.Value { best = p diff --git a/internal/tui/stats/view_timechart.go b/internal/tui/stats/view_timechart.go index eb517ad..109a5a8 100644 --- a/internal/tui/stats/view_timechart.go +++ b/internal/tui/stats/view_timechart.go @@ -6,7 +6,8 @@ import ( tslc "github.com/NimbleMarkets/ntcharts/v2/linechart/timeserieslinechart" - "github.com/spoo-me/spoo-cli/internal/api" + spoo "github.com/spoo-me/spoo-go" + "github.com/spoo-me/spoo-cli/internal/tui/kit" "github.com/spoo-me/spoo-cli/internal/ui" ) @@ -15,7 +16,7 @@ import ( // the time buckets (clicks + unique, plus an optional previous-period // ghost). It reads only the series it is handed, not the model. type timeChartView struct { - clicks, uniques, prev []api.MetricPoint + clicks, uniques, prev []spoo.MetricPoint span time.Duration label string showPrev bool @@ -23,7 +24,7 @@ type timeChartView struct { // timeChartView builds the chart component from the current model state. func (m Model) timeChartView() timeChartView { - var prev []api.MetricPoint + var prev []spoo.MetricPoint if m.prev != nil { prev = m.prev.Points("time", m.metric) } @@ -57,7 +58,7 @@ func (c timeChartView) render(width, height int) string { return ui.Dim.Render("no time series data") } - toSeries := func(pts []api.MetricPoint) ([]tslc.TimePoint, float64) { + toSeries := func(pts []spoo.MetricPoint) ([]tslc.TimePoint, float64) { out := make([]tslc.TimePoint, 0, len(pts)) var maxV float64 for _, p := range pts {