diff --git a/db.go b/db.go index 5e69fff..3cb746e 100644 --- a/db.go +++ b/db.go @@ -16,6 +16,7 @@ import ( "time" _ "modernc.org/sqlite" + "tailscale.com/tstime" ) // Link is the structure stored for each go short link. @@ -42,6 +43,8 @@ func linkID(short string) string { type SQLiteDB struct { db *sql.DB mu sync.RWMutex + + clock tstime.Clock // allow overriding time for tests } //go:embed schema.sql @@ -64,6 +67,11 @@ func NewSQLiteDB(f string) (*SQLiteDB, error) { return &SQLiteDB{db: db}, nil } +// Now returns the current time. +func (s *SQLiteDB) Now() time.Time { + return tstime.DefaultClock{Clock: s.clock}.Now() +} + // LoadAll returns all stored Links. // // The caller owns the returned values. @@ -195,7 +203,7 @@ func (s *SQLiteDB) SaveStats(stats ClickStats) error { if err != nil { return err } - now := time.Now().Unix() + now := s.Now().Unix() for short, clicks := range stats { _, err := tx.Exec("INSERT INTO Stats (ID, Created, Clicks) VALUES (?, ?, ?)", linkID(short), now, clicks) if err != nil { diff --git a/flake.nix b/flake.nix index 363ea9d..391af47 100644 --- a/flake.nix +++ b/flake.nix @@ -39,7 +39,7 @@ "-X tailscale.com/version.longStamp=${tsVersion}" "-X tailscale.com/version.shortStamp=${tsVersion}" ]; - vendorHash = "sha256-k3BxPRTgoJM0oCixDVA2k44ztdAUZO4IcO2/QB19HvU="; # SHA based on vendoring go.mod + vendorHash = "sha256-tF3TuIWr5x4inGrykXAjXBQATpDdpTX8HDYmEeukTEc="; # SHA based on vendoring go.mod }; }); diff --git a/golink.go b/golink.go index 4769f96..0b98f66 100644 --- a/golink.go +++ b/golink.go @@ -421,6 +421,7 @@ func serveHandler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/.detail/", serveDetail) mux.HandleFunc("/.export", serveExport) + mux.HandleFunc("/.export-stats", serveExportStats) mux.HandleFunc("/.help", serveHelp) mux.HandleFunc("/.opensearch", serveOpenSearch) mux.HandleFunc("/.all", serveAll) @@ -990,6 +991,42 @@ func serveExport(w http.ResponseWriter, _ *http.Request) { } } +// serveExportStats prints a snapshot of the stats database table. +// +// Stats are printed in CSV format with three columns: link ID, UNIX timestamp, and click count. +// Each stat line represents the number of clicks in the previous minute. +func serveExportStats(w http.ResponseWriter, _ *http.Request) { + if err := flushStats(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + rows, err := db.db.Query("SELECT ID, Created, Clicks FROM Stats ORDER BY Created, ID") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { + rows.Close() + if err := rows.Err(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }() + + for rows.Next() { + var id string + var created int64 + var clicks int + err := rows.Scan(&id, &created, &clicks) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // id is not permitted to contain commas, so no need to worry about CSV quoting + fmt.Fprintf(w, "%s,%d,%d\n", id, created, clicks) + } +} + func restoreLastSnapshot() error { bs := bufio.NewScanner(bytes.NewReader(LastSnapshot)) var restored int diff --git a/golink_test.go b/golink_test.go index a34bf15..5842112 100644 --- a/golink_test.go +++ b/golink_test.go @@ -13,6 +13,7 @@ import ( "time" "golang.org/x/net/xsrftoken" + "tailscale.com/tstest" "tailscale.com/types/ptr" "tailscale.com/util/must" ) @@ -358,6 +359,67 @@ func TestServeDelete(t *testing.T) { } } +func TestServeExport(t *testing.T) { + clock := tstest.NewClock(tstest.ClockOpts{ + Start: time.Date(2022, 06, 02, 1, 2, 3, 4, time.UTC), + }) + + var err error + db, err = NewSQLiteDB(":memory:") + db.clock = clock + if err != nil { + t.Fatal(err) + } + db.Save(&Link{Short: "a", Owner: "a@example.com"}) + db.Save(&Link{Short: "foo", Owner: "foo@example.com"}) + db.Save(&Link{Short: "link-owned-by-tagged-devices", Long: "/before", Owner: "tagged-devices"}) + + click := func(id string) { + r := httptest.NewRequest("GET", "/"+id, nil) + w := httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + } + initStats() + click("a") + click("foo") + click("foo") + flushStats() + clock.Advance(3 * time.Minute) + click("a") + + // export links + r := httptest.NewRequest("GET", "/.export", nil) + w := httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + + if want := http.StatusOK; w.Code != want { + t.Errorf("serveExport = %d; want %d", w.Code, want) + } + wantOutput := `{"Short":"a","Long":"","Created":"0001-01-01T00:00:00Z","LastEdit":"0001-01-01T00:00:00Z","Owner":"a@example.com"} +{"Short":"foo","Long":"","Created":"0001-01-01T00:00:00Z","LastEdit":"0001-01-01T00:00:00Z","Owner":"foo@example.com"} +{"Short":"link-owned-by-tagged-devices","Long":"/before","Created":"0001-01-01T00:00:00Z","LastEdit":"0001-01-01T00:00:00Z","Owner":"tagged-devices"} +` + if got := w.Body.String(); got != wantOutput { + t.Errorf("serveExport = %v; want %v", got, wantOutput) + } + + // export links stats + r = httptest.NewRequest("GET", "/.export-stats", nil) + w = httptest.NewRecorder() + serveHandler().ServeHTTP(w, r) + + if want := http.StatusOK; w.Code != want { + t.Errorf("serveExportStats = %d; want %d", w.Code, want) + } + wantOutput = `a,1654131723,1 +foo,1654131723,2 +a,1654131903,1 +` + if got := w.Body.String(); got != wantOutput { + t.Errorf("serveExportStats = %v; want %v", got, wantOutput) + } +} + func TestReadOnlyMode(t *testing.T) { var err error db, err = NewSQLiteDB(":memory:")