From bb3ee82aef166e5e5a4f55c460c3c742ad2242c2 Mon Sep 17 00:00:00 2001 From: Piotr Durlej Date: Tue, 14 Jul 2026 01:31:36 +0200 Subject: [PATCH] audit: remove unsafe debug tools and harden history handling --- CLAUDE.md | 3 +- client.go | 12 +- client_test.go | 40 ++++++ cmd/README.md | 247 ------------------------------------- cmd/debug/main.go | 52 -------- cmd/debugupdate/main.go | 52 -------- cmd/findtask/main.go | 58 --------- cmd/fullstate/main.go | 77 ------------ cmd/list/main.go | 55 --------- cmd/rawitem/main.go | 42 ------- cmd/rawtask/main.go | 56 --------- cmd/recent/main.go | 36 ------ cmd/statedebug/main.go | 47 ------- cmd/synctest/main.go | 267 ---------------------------------------- cmd/thingsync/main.go | 3 +- cmd/trace/main.go | 37 ------ histories.go | 28 +++-- histories_test.go | 35 ++++++ syncutil/syncutil.go | 3 +- 19 files changed, 101 insertions(+), 1049 deletions(-) delete mode 100644 cmd/README.md delete mode 100644 cmd/debug/main.go delete mode 100644 cmd/debugupdate/main.go delete mode 100644 cmd/findtask/main.go delete mode 100644 cmd/fullstate/main.go delete mode 100644 cmd/list/main.go delete mode 100644 cmd/rawitem/main.go delete mode 100644 cmd/rawtask/main.go delete mode 100644 cmd/recent/main.go delete mode 100644 cmd/statedebug/main.go delete mode 100644 cmd/synctest/main.go delete mode 100644 cmd/trace/main.go diff --git a/CLAUDE.md b/CLAUDE.md index a614737..648cdbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,13 +82,12 @@ The `syncutil` package provides shared utilities for sync-based CLI tools: ### CLI Tools (`cmd/`) -See `cmd/README.md` for detailed documentation. Key tools: +See `README.md` for installation and command documentation. Key tools: - **`things-cloud-cli`** — Preferred CLI for reads, safe dry-run writes, CRUD, recurring tasks, completed/logbook evidence, and batch operations - **`things-cli`** — Backward-compatible alias for older integrations - **`things-mcp`** — Stdio MCP server for agent hosts - **`thingsync`** — JSON-based sync with workflow views (today, inbox, review, patterns) -- **`synctest`** — Human-readable sync output for testing ### Test Infrastructure diff --git a/client.go b/client.go index 4c81739..5726c03 100644 --- a/client.go +++ b/client.go @@ -7,7 +7,6 @@ import ( "fmt" "log" "net/http" - "net/http/httputil" "net/url" ) @@ -118,17 +117,16 @@ func (c *Client) do(req *http.Request) (*http.Response, error) { req.Header.Set("Things-Client-Info", base64.StdEncoding.EncodeToString(ciJSON)) if c.Debug { - bs, _ := httputil.DumpRequest(req, true) - log.Println("REQUEST:", string(bs)) + log.Printf("REQUEST: %s %s", req.Method, req.URL.Redacted()) } resp, err := c.client.Do(req) if c.Debug { - if err == nil { - bs, _ := httputil.DumpResponse(resp, true) - log.Println("RESPONSE:", string(bs)) + if err != nil { + log.Printf("RESPONSE ERROR: %v", err) + } else { + log.Printf("RESPONSE: %s", resp.Status) } - log.Println() } return resp, err } diff --git a/client_test.go b/client_test.go index e3025b2..c4acbfd 100644 --- a/client_test.go +++ b/client_test.go @@ -1,11 +1,14 @@ package thingscloud import ( + "bytes" "fmt" "io" + "log" "net/http" "net/http/httptest" "os" + "strings" "testing" ) @@ -73,3 +76,40 @@ func TestClient_UserAgent(t *testing.T) { t.Error("things-client-info header is missing or empty") } } + +func TestClient_DebugLoggingRedactsSensitiveData(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"private":"response-body"}`)) + })) + defer ts.Close() + + var logs bytes.Buffer + oldOutput := log.Writer() + log.SetOutput(&logs) + t.Cleanup(func() { log.SetOutput(oldOutput) }) + + c := New(ts.URL, "test@example.com", "secret-password") + c.Debug = true + req, err := http.NewRequest("POST", "/test", strings.NewReader(`{"password":"new-secret"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Password secret-password") + + resp, err := c.do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + got := logs.String() + for _, sensitive := range []string{"secret-password", "new-secret", "response-body", "Authorization"} { + if strings.Contains(got, sensitive) { + t.Errorf("debug log contains sensitive value %q", sensitive) + } + } + if !strings.Contains(got, "REQUEST: POST") || !strings.Contains(got, "RESPONSE: 200 OK") { + t.Errorf("debug log is missing request or response metadata: %q", got) + } +} diff --git a/cmd/README.md b/cmd/README.md deleted file mode 100644 index 6eeda14..0000000 --- a/cmd/README.md +++ /dev/null @@ -1,247 +0,0 @@ -# CLI Tools - -All tools require environment variables: -```bash -export THINGS_USERNAME="your@email.com" -export THINGS_PASSWORD="yourpassword" -``` - -Or create `~/.things-cloud.json`: - -```json -{ - "username": "your@email.com", - "password": "yourpassword", - "token": "optional-password-alias", - "cache": "/path/to/things-cli-state.json" -} -``` - -Set `THINGS_CONFIG=/path/to/config.json` to use a different file. Environment variables take precedence, so a `.env` file still works when sourced with `source .env`. - -## Production Tools - -### things-cloud-cli / things-cli - -Full-featured CLI for CRUD operations on Things Cloud. Prefer `things-cloud-cli` for new installs; `things-cli` remains available for compatibility. - -```bash -# Read operations (uses an incremental local state cache) -things-cloud-cli list [--today] [--inbox] [--anytime] [--someday] [--upcoming] [--search QUERY] [--area NAME] [--project NAME] [--simple|--format full|simple] -things-cli today [--simple] -things-cli inbox [--simple] -things-cli anytime [--simple] -things-cli someday [--simple] -things-cli upcoming [--simple] -things-cli search [--simple] -things-cli show [--simple] -things-cli areas -things-cli projects -things-cli tags - -# Write operations (fast - no state loading) -things-cli create "Task title" [options] [--dry-run] -things-cli edit [--title ...] [--note ...] [--when ...] [--dry-run] -things-cli complete [--dry-run] -things-cli trash [--dry-run] -things-cli purge [--dry-run] -things-cli move-to-today [--dry-run] - -# Batch operations (all in one HTTP request - much faster!) -echo '[ - {"cmd": "create", "title": "Task 1"}, - {"cmd": "create", "title": "Task 2"}, - {"cmd": "complete", "uuid": "abc123"}, - {"cmd": "move-to-project", "uuid": "def456", "project": "proj-uuid"} -]' | things-cli batch [--dry-run] - -# Batch commands: create, complete, trash, purge, move-to-today, -# move-to-project, move-to-area, edit - -# Optional read-state cache location: -# THINGS_CLI_CACHE=/path/to/things-cli-state.json - -# Create options: -# --note "text" Add a note -# --when today|anytime|someday|inbox -# --deadline YYYY-MM-DD -# --scheduled YYYY-MM-DD -# --project UUID Add to project -# --heading UUID Add under heading -# --area UUID Add to area -# --tags UUID,UUID,... Add tags -# --type task|project|heading -# --checklist "Item 1,Item 2,..." -# --dry-run Print the write payload without sending it -``` - -### things-mcp - -Stdio MCP server for agent integrations. It uses `THINGS_USERNAME` and `THINGS_PASSWORD` from the environment and exposes `list_tasks`, `search_tasks`, `list_projects`, `list_areas`, `list_tags`, `create_task`, `complete_task`, `edit_task`, `trash_task`, `move_task_to_today`, and `add_checklist`. - -```bash -go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@latest -things-mcp -``` - -### thingsync - -JSON-based sync with workflow views. Persists state to `~/.things-workflow/sync.db`. - -```bash -# Default: full sync with JSON output -thingsync - -# Human-readable output -thingsync --human - -# Workflow views (JSON output) -thingsync --today # Morning review: today's tasks + alerts -thingsync --inbox # Triage view: inbox items with staleness -thingsync --review # Evening review: completed vs remaining -thingsync --patterns # Behavioral analysis: reschedule patterns - -# Custom database location -thingsync --db /path/to/sync.db -``` - -Output includes: -- Sync metadata (index before/after, change count) -- Rich changes with context (project, area, heading, tags) -- Daily summary (completed, created, moved) -- Alerts (stale inbox, reschedule patterns, deadlines) - -### synctest - -Human-readable sync output for testing. Persists to temp directory. - -```bash -synctest -``` - -Shows: -- New changes with titles -- Today's activity summary -- Inbox status with item ages -- Today view with reschedule warnings -- Accountability check for problematic tasks - ---- - -## Debug & Development Tools - -These tools are for SDK development and debugging. Most have hardcoded UUIDs for specific investigations. - -### debug - -Count items by kind (Task6, Area3, Tag4, etc.) - -```bash -debug -# Output: -# Item kinds: -# Task6: 1234 -# Area3: 12 -# Tag4: 45 -``` - -### recent - -Show the last ~100 items from history. - -```bash -recent -``` - -### trace - -Trace all changes for a specific UUID through history. Has hardcoded target UUID. - -```bash -trace -# Shows all item versions for target UUID with full payload -``` - -### list - -Simple task listing using state/memory. - -```bash -list -# Output: all tasks with trash/status info -``` - -### fullstate - -Dump complete state: areas, tags, all tasks. - -```bash -fullstate -``` - -### statedebug - -Debug state aggregation - shows Task6 item counts vs final state. - -```bash -statedebug -``` - -### findtask - -Find a specific task and trace its state changes. Has hardcoded target UUID. - -```bash -findtask -``` - -### rawitem - -Show raw item JSON for a specific UUID. Has hardcoded target UUID. - -```bash -rawitem -``` - -### rawtask - -Show first 20 Task6 items with titles. - -```bash -rawtask -``` - -### debugupdate - -Debug state.Update() behavior for a specific task. Has hardcoded target UUID. - -```bash -debugupdate -``` - ---- - -## When to Use Which Tool - -| Use Case | Tool | -|----------|------| -| Create/edit/complete tasks | `things-cli` | -| Automated workflows, JSON output | `thingsync` | -| Quick human-readable sync test | `synctest` | -| Debug item kinds in history | `debug` | -| See recent activity | `recent` | -| Investigate specific item history | `trace` | -| Debug state aggregation | `statedebug`, `findtask` | - -## Building - -```bash -# Build all tools -go build -v ./cmd/... - -# Build specific tool -go build -v ./cmd/things-cli - -# Run without building -go run ./cmd/synctest -``` diff --git a/cmd/debug/main.go b/cmd/debug/main.go deleted file mode 100644 index 9d58057..0000000 --- a/cmd/debug/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - - _, err := c.Verify() - if err != nil { - fmt.Fprintf(os.Stderr, "Login failed: %v\n", err) - os.Exit(1) - } - - history, err := c.OwnHistory() - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading history: %v\n", err) - os.Exit(1) - } - - if err := history.Sync(); err != nil { - fmt.Fprintf(os.Stderr, "Failed syncing history: %v\n", err) - os.Exit(1) - } - - // Fetch all items - kindCounts := make(map[string]int) - startIndex := 0 - for { - items, hasMore, err := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading items: %v\n", err) - os.Exit(1) - } - for _, item := range items { - kindCounts[string(item.Kind)]++ - } - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } - - fmt.Println("Item kinds:") - for kind, count := range kindCounts { - fmt.Printf(" %s: %d\n", kind, count) - } -} diff --git a/cmd/debugupdate/main.go b/cmd/debugupdate/main.go deleted file mode 100644 index 456a4ca..0000000 --- a/cmd/debugupdate/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - thingscloud "github.com/pdurlej/things-cloud-sdk" - memory "github.com/pdurlej/things-cloud-sdk/state/memory" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - c.Verify() - history, _ := c.OwnHistory() - history.Sync() - - state := memory.NewState() - target := "2MNjM5gT" - - startIndex := 0 - for { - items, hasMore, _ := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - - for _, item := range items { - if strings.HasPrefix(item.UUID, target) { - var p map[string]interface{} - json.Unmarshal(item.P, &p) - fmt.Printf("Processing: UUID=%s Kind=%s Action=%d\n", item.UUID, item.Kind, item.Action) - - // Try updating just this one item - err := state.Update(item) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - } - - // Check if it's in state now - if t, ok := state.Tasks[item.UUID]; ok { - fmt.Printf(" -> In state: title=%q\n", t.Title) - } else { - fmt.Printf(" -> NOT in state!\n") - } - } - } - - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } -} diff --git a/cmd/findtask/main.go b/cmd/findtask/main.go deleted file mode 100644 index 8e66693..0000000 --- a/cmd/findtask/main.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" - memory "github.com/pdurlej/things-cloud-sdk/state/memory" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - c.Verify() - history, _ := c.OwnHistory() - history.Sync() - - state := memory.NewState() - target := "2MNjM5gT" // Book Teeth Cleaning - - startIndex := 0 - for { - items, hasMore, _ := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - - // Check state before update - if t, ok := state.Tasks[target+"5hZt2sSEw4PvDb"]; ok { - fmt.Printf("BEFORE batch: Task exists, title=%q trash=%v\n", t.Title, t.InTrash) - } - - state.Update(items...) - - // Check state after update - if t, ok := state.Tasks[target+"5hZt2sSEw4PvDb"]; ok { - fmt.Printf("AFTER batch: Task exists, title=%q trash=%v\n", t.Title, t.InTrash) - } - - // Look for our target in this batch - for _, item := range items { - if item.UUID == target+"5hZt2sSEw4PvDb" { - var p map[string]interface{} - json.Unmarshal(item.P, &p) - fmt.Printf("Found item: Kind=%s Action=%d tr=%v\n", item.Kind, item.Action, p["tr"]) - } - } - - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } - - fmt.Printf("\nFinal state has %d tasks\n", len(state.Tasks)) - if t, ok := state.Tasks[target+"5hZt2sSEw4PvDb"]; ok { - fmt.Printf("Target task: %q trash=%v status=%d\n", t.Title, t.InTrash, t.Status) - } else { - fmt.Println("Target task NOT in final state") - } -} diff --git a/cmd/fullstate/main.go b/cmd/fullstate/main.go deleted file mode 100644 index c2cb843..0000000 --- a/cmd/fullstate/main.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" - memory "github.com/pdurlej/things-cloud-sdk/state/memory" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - - _, err := c.Verify() - if err != nil { - fmt.Fprintf(os.Stderr, "Login failed: %v\n", err) - os.Exit(1) - } - - history, err := c.OwnHistory() - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading history: %v\n", err) - os.Exit(1) - } - - if err := history.Sync(); err != nil { - fmt.Fprintf(os.Stderr, "Failed syncing history: %v\n", err) - os.Exit(1) - } - - // Fetch all items - var allItems []thingscloud.Item - startIndex := 0 - for { - items, hasMore, err := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading items: %v\n", err) - os.Exit(1) - } - allItems = append(allItems, items...) - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } - - state := memory.NewState() - state.Update(allItems...) - - fmt.Printf("=== STATE SUMMARY ===\n") - fmt.Printf("Areas: %d\n", len(state.Areas)) - fmt.Printf("Tags: %d\n", len(state.Tags)) - fmt.Printf("Tasks: %d\n", len(state.Tasks)) - fmt.Printf("ChecklistItems: %d\n", len(state.CheckListItems)) - - fmt.Printf("\n=== AREAS ===\n") - for _, area := range state.Areas { - fmt.Printf("- %s\n", area.Title) - } - - fmt.Printf("\n=== TAGS ===\n") - for _, tag := range state.Tags { - fmt.Printf("- %s\n", tag.Title) - } - - fmt.Printf("\n=== ALL TASKS (including trashed/completed) ===\n") - for _, task := range state.Tasks { - status := "open" - if task.Status == 3 { - status = "completed" - } - if task.InTrash { - status = "trashed" - } - fmt.Printf("- [%s] %s (project:%v)\n", status, task.Title, task.Type == thingscloud.TaskTypeProject) - } -} diff --git a/cmd/list/main.go b/cmd/list/main.go deleted file mode 100644 index 4c69c21..0000000 --- a/cmd/list/main.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" - memory "github.com/pdurlej/things-cloud-sdk/state/memory" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - - _, err := c.Verify() - if err != nil { - fmt.Fprintf(os.Stderr, "Login failed: %v\n", err) - os.Exit(1) - } - - history, err := c.OwnHistory() - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading history: %v\n", err) - os.Exit(1) - } - - if err := history.Sync(); err != nil { - fmt.Fprintf(os.Stderr, "Failed syncing history: %v\n", err) - os.Exit(1) - } - // Fetch all items with pagination - var allItems []thingscloud.Item - startIndex := 0 - for { - items, hasMore, err := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading items: %v\n", err) - os.Exit(1) - } - allItems = append(allItems, items...) - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } - items := allItems - fmt.Fprintf(os.Stderr, "Fetched %d items\n", len(items)) - - state := memory.NewState() - state.Update(items...) - - fmt.Printf("=== TASKS (%d) ===\n", len(state.Tasks)) - for _, task := range state.Tasks { - fmt.Printf("- %s (InTrash:%v, Status:%d)\n", task.Title, task.InTrash, task.Status) - } -} diff --git a/cmd/rawitem/main.go b/cmd/rawitem/main.go deleted file mode 100644 index ad22b7f..0000000 --- a/cmd/rawitem/main.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - c.Verify() - history, _ := c.OwnHistory() - history.Sync() - - target := "2MNjM5gT5hZt2sSEw4PvDb" - - startIndex := 0 - for { - items, hasMore, _ := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - - for _, item := range items { - if item.UUID == target { - fmt.Printf("UUID: %s\n", item.UUID) - fmt.Printf("Kind: %s\n", item.Kind) - fmt.Printf("Action (parsed): %d\n", item.Action) - - // Also print item as raw JSON - raw, _ := json.MarshalIndent(item, "", " ") - fmt.Printf("Raw item: %s\n", string(raw)) - return - } - } - - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } - fmt.Println("Not found") -} diff --git a/cmd/rawtask/main.go b/cmd/rawtask/main.go deleted file mode 100644 index 207045f..0000000 --- a/cmd/rawtask/main.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - - _, err := c.Verify() - if err != nil { - fmt.Fprintf(os.Stderr, "Login failed: %v\n", err) - os.Exit(1) - } - - history, err := c.OwnHistory() - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading history: %v\n", err) - os.Exit(1) - } - - if err := history.Sync(); err != nil { - fmt.Fprintf(os.Stderr, "Failed syncing history: %v\n", err) - os.Exit(1) - } - - // Fetch all and show some recent Task6 items with titles - startIndex := 0 - count := 0 - for { - items, hasMore, err := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed loading items: %v\n", err) - os.Exit(1) - } - for _, item := range items { - if item.Kind == "Task6" && count < 20 { - var p map[string]interface{} - json.Unmarshal(item.P, &p) - if title, ok := p["tt"]; ok && title != nil { - fmt.Printf("UUID: %s, Title: %v, Action: %d\n", item.UUID, title, item.Action) - count++ - } - } - } - if !hasMore || count >= 20 { - break - } - startIndex = history.LoadedServerIndex - } - fmt.Printf("\nFound %d Task6 items with titles\n", count) -} diff --git a/cmd/recent/main.go b/cmd/recent/main.go deleted file mode 100644 index ddf76fb..0000000 --- a/cmd/recent/main.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - c.Verify() - - history, _ := c.OwnHistory() - history.Sync() - - // Get the LAST batch (most recent items) - fmt.Printf("LatestServerIndex: %d\n", history.LatestServerIndex) - - // Start from near the end - startIndex := history.LatestServerIndex - 100 - if startIndex < 0 { - startIndex = 0 - } - - items, _, _ := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - - fmt.Printf("\n=== LAST %d ITEMS ===\n", len(items)) - for _, item := range items { - var p map[string]interface{} - json.Unmarshal(item.P, &p) - title := p["tt"] - fmt.Printf("[%s] Action:%d UUID:%s Title:%v\n", item.Kind, item.Action, item.UUID[:8], title) - } -} diff --git a/cmd/statedebug/main.go b/cmd/statedebug/main.go deleted file mode 100644 index 07f4359..0000000 --- a/cmd/statedebug/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "fmt" - "os" - - thingscloud "github.com/pdurlej/things-cloud-sdk" - memory "github.com/pdurlej/things-cloud-sdk/state/memory" -) - -func main() { - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - c.Verify() - - history, _ := c.OwnHistory() - history.Sync() - - state := memory.NewState() - - // Fetch and update incrementally - startIndex := 0 - totalTask6 := 0 - for { - items, hasMore, _ := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - - for _, item := range items { - if item.Kind == "Task6" { - totalTask6++ - } - } - - state.Update(items...) - - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } - - fmt.Printf("Total Task6 items processed: %d\n", totalTask6) - fmt.Printf("Final state.Tasks count: %d\n", len(state.Tasks)) - - fmt.Println("\nAll tasks in state:") - for uuid, task := range state.Tasks { - fmt.Printf(" %s: %s (trash:%v status:%d)\n", uuid[:8], task.Title, task.InTrash, task.Status) - } -} diff --git a/cmd/synctest/main.go b/cmd/synctest/main.go deleted file mode 100644 index 99d255a..0000000 --- a/cmd/synctest/main.go +++ /dev/null @@ -1,267 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "path/filepath" - "strings" - - things "github.com/pdurlej/things-cloud-sdk" - "github.com/pdurlej/things-cloud-sdk/sync" - "github.com/pdurlej/things-cloud-sdk/syncutil" -) - -func main() { - username := os.Getenv("THINGS_USERNAME") - password := os.Getenv("THINGS_PASSWORD") - - if username == "" || password == "" { - log.Fatal("THINGS_USERNAME and THINGS_PASSWORD must be set") - } - - fmt.Printf("Connecting as: %s\n", username) - - // Create client - client := things.New(things.APIEndpoint, username, password) - - // Use persistent database to test incremental sync - dbPath := filepath.Join(os.TempDir(), "things-sync-test.db") - fmt.Printf("Database: %s\n", dbPath) - - // Open syncer - syncer, err := sync.Open(dbPath, client) - if err != nil { - log.Fatalf("Open failed: %v", err) - } - defer syncer.Close() - - // Show last synced index - fmt.Printf("Last synced index: %d\n", syncer.LastSyncedIndex()) - - // Sync - fmt.Println("\nSyncing...") - changes, err := syncer.Sync() - if err != nil { - log.Fatalf("Sync failed: %v", err) - } - fmt.Printf("New changes: %d\n", len(changes)) - - // Show detailed changes (last 10) - if len(changes) > 0 { - fmt.Println("\n--- New Changes ---") - limit := 10 - if len(changes) < limit { - limit = len(changes) - } - for i := len(changes) - limit; i < len(changes); i++ { - printChange(changes[i]) - } - } - - // Daily summary - dailySummary(syncer) - - // Inbox alert - inboxAlert(syncer) - - // Today view with context - todayWithContext(syncer) - - // Reschedule patterns for inbox items - reschedulePatterns(syncer) - - fmt.Println("\n✓ Sync complete!") -} - -// dailySummary shows what happened today -func dailySummary(syncer *sync.Syncer) { - summary := syncutil.BuildDailySummary(syncer) - - if summary.Completed == 0 && summary.Created == 0 && summary.MovedToToday == 0 { - return - } - - fmt.Println("\n--- Today's Activity ---") - fmt.Printf(" ✓ Completed: %d\n", summary.Completed) - fmt.Printf(" + Created: %d\n", summary.Created) - fmt.Printf(" → Moved to Today: %d\n", summary.MovedToToday) - fmt.Printf(" ↔ Rescheduled: %d\n", summary.Rescheduled) -} - -// inboxAlert warns if inbox is growing too large -func inboxAlert(syncer *sync.Syncer) { - state := syncer.State() - inbox, _ := state.TasksInInbox(sync.QueryOpts{}) - - fmt.Println("\n--- Inbox Status ---") - fmt.Printf(" 📥 %d items\n", len(inbox)) - - if len(inbox) > 10 { - fmt.Println(" ⚠️ Inbox has grown large — consider triaging") - } else if len(inbox) == 0 { - fmt.Println(" ✨ Inbox zero!") - } - - // Show inbox items - if len(inbox) > 0 { - fmt.Println("\n Items:") - for _, t := range inbox { - age := syncutil.TaskAge(t) - if age > 0 { - fmt.Printf(" - %s (%dd old)\n", t.Title, age) - } else { - fmt.Printf(" - %s\n", t.Title) - } - } - } -} - -// todayWithContext shows Today view with task history -func todayWithContext(syncer *sync.Syncer) { - state := syncer.State() - today, _ := state.TasksInToday(sync.QueryOpts{}) - - if len(today) == 0 { - fmt.Println("\n--- Today ---") - fmt.Println(" No tasks scheduled for today") - return - } - - fmt.Println("\n--- Today ---") - fmt.Printf(" %d tasks scheduled\n\n", len(today)) - - for _, task := range today { - changes, _ := syncer.ChangesForEntity(task.UUID) - age := syncutil.DaysSinceCreated(changes) - moves := syncutil.CountMoves(changes) - - // Build context string - var context []string - if age > 1 { - context = append(context, fmt.Sprintf("%dd old", age)) - } - if moves > 0 { - context = append(context, fmt.Sprintf("moved %dx", moves)) - } - - if len(context) > 0 { - fmt.Printf(" - %s (%s)\n", task.Title, strings.Join(context, ", ")) - } else { - fmt.Printf(" - %s\n", task.Title) - } - - // Warn about frequently rescheduled tasks - if moves >= 3 { - fmt.Printf(" ⚠️ Rescheduled %d times — consider breaking down or delegating\n", moves) - } - } -} - -// reschedulePatterns checks for tasks that keep getting pushed -func reschedulePatterns(syncer *sync.Syncer) { - state := syncer.State() - - // Check inbox items - inbox, _ := state.TasksInInbox(sync.QueryOpts{}) - var problematic []struct { - title string - moves int - age int - } - - for _, task := range inbox { - changes, _ := syncer.ChangesForEntity(task.UUID) - moves := syncutil.CountMoves(changes) - age := syncutil.DaysSinceCreated(changes) - - if moves >= 3 || age >= 7 { - problematic = append(problematic, struct { - title string - moves int - age int - }{task.Title, moves, age}) - } - } - - // Also check today items - today, _ := state.TasksInToday(sync.QueryOpts{}) - for _, task := range today { - changes, _ := syncer.ChangesForEntity(task.UUID) - moves := syncutil.CountMoves(changes) - age := syncutil.DaysSinceCreated(changes) - - if moves >= 3 { - problematic = append(problematic, struct { - title string - moves int - age int - }{task.Title, moves, age}) - } - } - - if len(problematic) > 0 { - fmt.Println("\n--- Accountability Check ---") - fmt.Printf(" ⚠️ %d tasks need attention:\n\n", len(problematic)) - for _, p := range problematic { - var reasons []string - if p.moves >= 3 { - reasons = append(reasons, fmt.Sprintf("moved %dx", p.moves)) - } - if p.age >= 7 { - reasons = append(reasons, fmt.Sprintf("%dd old", p.age)) - } - fmt.Printf(" - %s (%s)\n", p.title, strings.Join(reasons, ", ")) - } - } -} - -// Helper functions - -func printChange(c sync.Change) { - uuid := c.EntityUUID() - if len(uuid) > 8 { - uuid = uuid[:8] - } - fmt.Printf(" [%s] %s", c.ChangeType(), uuid) - - switch v := c.(type) { - case sync.TaskCreated: - if v.Task != nil { - fmt.Printf(" - %q", v.Task.Title) - } - case sync.TaskCompleted: - if v.Task != nil { - fmt.Printf(" - %q ✓", v.Task.Title) - } - case sync.TaskTitleChanged: - if v.Task != nil { - fmt.Printf(" → %q", v.Task.Title) - } - case sync.TaskNoteChanged: - if v.Task != nil { - fmt.Printf(" - %q (note)", v.Task.Title) - } - case sync.TaskMovedToToday: - if v.Task != nil { - fmt.Printf(" - %q → Today", v.Task.Title) - } - case sync.TaskMovedToInbox: - if v.Task != nil { - fmt.Printf(" - %q → Inbox", v.Task.Title) - } - case sync.ProjectCreated: - if v.Project != nil { - fmt.Printf(" - %q", v.Project.Title) - } - case sync.AreaCreated: - if v.Area != nil { - fmt.Printf(" - %q", v.Area.Title) - } - case sync.TagCreated: - if v.Tag != nil { - fmt.Printf(" - %q", v.Tag.Title) - } - } - fmt.Println() -} diff --git a/cmd/thingsync/main.go b/cmd/thingsync/main.go index 6107ef4..67035f8 100644 --- a/cmd/thingsync/main.go +++ b/cmd/thingsync/main.go @@ -878,7 +878,8 @@ func printInboxView(syncer *sync.Syncer) { func printReviewView(syncer *sync.Syncer) { state := syncer.State() - todayStart := time.Now().Truncate(24 * time.Hour) + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // Get completed tasks today changes, _ := syncer.ChangesSince(todayStart) diff --git a/cmd/trace/main.go b/cmd/trace/main.go deleted file mode 100644 index 27ea48b..0000000 --- a/cmd/trace/main.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - thingscloud "github.com/pdurlej/things-cloud-sdk" -) - -func main() { - target := "2MNjM5gT" // "Book Teeth Cleaning" - - c := thingscloud.New(thingscloud.APIEndpoint, os.Getenv("THINGS_USERNAME"), os.Getenv("THINGS_PASSWORD")) - c.Verify() - - history, _ := c.OwnHistory() - history.Sync() - - startIndex := 0 - for { - items, hasMore, _ := history.Items(thingscloud.ItemsOptions{StartIndex: startIndex}) - for _, item := range items { - if strings.HasPrefix(item.UUID, target) { - var p map[string]interface{} - json.Unmarshal(item.P, &p) - pJSON, _ := json.MarshalIndent(p, "", " ") - fmt.Printf("=== %s Action:%d Kind:%s ===\n%s\n\n", item.UUID, item.Action, item.Kind, string(pJSON)) - } - } - if !hasMore { - break - } - startIndex = history.LoadedServerIndex - } -} diff --git a/histories.go b/histories.go index 8b1c646..78cebf1 100644 --- a/histories.go +++ b/histories.go @@ -5,9 +5,7 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" - "net/http/httputil" "strconv" ) @@ -53,7 +51,9 @@ func (h *History) Sync() error { return err } var v itemsResponse - json.Unmarshal(bs, &v) + if err := json.Unmarshal(bs, &v); err != nil { + return fmt.Errorf("decoding history sync response: %w", err) + } h.LatestServerIndex = v.CurrentItemIndex h.LatestSchemaVersion = v.SchemaVersion h.LatestTotalContentSize = v.LatestTotalContentSize @@ -147,7 +147,9 @@ func (c *Client) Histories() ([]*History, error) { return nil, err } var keys []string - json.Unmarshal(bs, &keys) + if err := json.Unmarshal(bs, &keys); err != nil { + return nil, fmt.Errorf("decoding histories response: %w", err) + } var histories = make([]*History, len(keys)) for i, key := range keys { @@ -187,7 +189,9 @@ func (c *Client) CreateHistory() (*History, error) { return nil, err } var v createHistoryResponse - json.Unmarshal(bs, &v) + if err := json.Unmarshal(bs, &v); err != nil { + return nil, fmt.Errorf("decoding create history response: %w", err) + } return &History{ Client: c, ID: v.Key, @@ -233,6 +237,9 @@ func (h *History) Write(items ...Identifiable) error { return err } req, err := http.NewRequest("POST", fmt.Sprintf("/version/1/history/%s/commit", h.ID), bytes.NewReader(bs)) + if err != nil { + return err + } req.Header.Add("Schema", "301") req.Header.Add("Push-Priority", "5") // Full App-Instance-Id matching Things format: {hash}-{bundleId}-{hash} @@ -245,25 +252,22 @@ func (h *History) Write(items ...Identifiable) error { query.Add("ancestor-index", strconv.Itoa(h.LatestServerIndex)) query.Add("_cnt", "1") req.URL.RawQuery = query.Encode() - if err != nil { - return err - } resp, err := h.Client.do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - bs, _ := httputil.DumpResponse(resp, true) - log.Println(string(bs)) - return fmt.Errorf("Write failed: %d", resp.StatusCode) + return fmt.Errorf("write failed: %s", resp.Status) } rs, err := io.ReadAll(resp.Body) if err != nil { return err } var w commitResponse - json.Unmarshal(rs, &w) + if err := json.Unmarshal(rs, &w); err != nil { + return fmt.Errorf("decoding write response: %w", err) + } h.LatestServerIndex = w.ServerHeadIndex return nil } diff --git a/histories_test.go b/histories_test.go index 1ab6fa3..b0532db 100644 --- a/histories_test.go +++ b/histories_test.go @@ -139,3 +139,38 @@ func TestHistory_Sync(t *testing.T) { } }) } + +func TestHistory_WriteRejectsInvalidHistoryID(t *testing.T) { + c := New("https://example.com", "test@example.com", "secret") + h := c.HistoryWithID("invalid\nvalue") + + if err := h.Write(); err == nil { + t.Fatal("Write succeeded with an invalid history ID") + } +} + +func TestHistoryOperationsRejectMalformedJSON(t *testing.T) { + tests := []struct { + name string + run func(*Client) error + }{ + {"histories", func(c *Client) error { _, err := c.Histories(); return err }}, + {"create history", func(c *Client) error { _, err := c.CreateHistory(); return err }}, + {"sync", func(c *Client) error { return c.HistoryWithID("history-id").Sync() }}, + {"write", func(c *Client) error { return c.HistoryWithID("history-id").Write() }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + defer server.Close() + + if err := tt.run(New(server.URL, "test@example.com", "secret")); err == nil { + t.Fatal("operation accepted malformed JSON") + } + }) + } +} diff --git a/syncutil/syncutil.go b/syncutil/syncutil.go index 5b94482..cd99bce 100644 --- a/syncutil/syncutil.go +++ b/syncutil/syncutil.go @@ -71,7 +71,8 @@ type DailySummary struct { // BuildDailySummary calculates activity stats from today's changes. func BuildDailySummary(syncer *sync.Syncer) DailySummary { - today := time.Now().Truncate(24 * time.Hour) + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) changes, _ := syncer.ChangesSince(today) summary := DailySummary{}