From b07029d88aa52d7b5e6833085b62e07b8ed8f6c0 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 11:49:37 +0530 Subject: [PATCH 01/18] fix ci module ordering rigor --- .github/workflows/ci.yml | 7 +++++-- go.mod | 2 +- store.go | 7 +++++-- store_test.go | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab0306..fa5df99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [ "main", "dev" ] pull_request: - branches: [ "main" ] + branches: [ "main", "dev" ] jobs: build-and-test: @@ -21,4 +21,7 @@ jobs: run: go build -v ./... - name: Test - run: go test -v ./... \ No newline at end of file + run: go test -v ./... + + - name: Race test + run: go test -race ./... diff --git a/go.mod b/go.mod index c7d2827..76bdadd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module awesomeProject +module github.com/blackdragoon26/Do-It go 1.26 diff --git a/store.go b/store.go index 5e5f9e0..e10ed72 100644 --- a/store.go +++ b/store.go @@ -311,10 +311,13 @@ func (s *Store) sortedTasksLocked() []Task { tasks = append(tasks, task) } sort.Slice(tasks, func(i, j int) bool { - if tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) { + if !tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) { + return tasks[i].CreatedAt.Before(tasks[j].CreatedAt) + } + if tasks[i].Title != tasks[j].Title { return tasks[i].Title < tasks[j].Title } - return tasks[i].CreatedAt.Before(tasks[j].CreatedAt) + return tasks[i].ID < tasks[j].ID }) return tasks } diff --git a/store_test.go b/store_test.go index 5bf3900..825beb6 100644 --- a/store_test.go +++ b/store_test.go @@ -3,6 +3,7 @@ package main import ( "path/filepath" "testing" + "time" ) func TestStoreTaskLifecyclePersists(t *testing.T) { @@ -75,3 +76,20 @@ func TestStoreRejectsParentCycles(t *testing.T) { t.Fatal("expected cycle to be rejected") } } + +func TestStoreSortsTasksWithIDTieBreaker(t *testing.T) { + store := &Store{ + tasks: make(map[string]Task), + } + createdAt := time.Date(2026, 6, 22, 13, 8, 34, 0, time.UTC) + store.tasks["task_b"] = Task{ID: "task_b", Title: "Same", CreatedAt: createdAt} + store.tasks["task_a"] = Task{ID: "task_a", Title: "Same", CreatedAt: createdAt} + + tasks := store.sortedTasksLocked() + if len(tasks) != 2 { + t.Fatalf("expected two tasks, got %d", len(tasks)) + } + if tasks[0].ID != "task_a" || tasks[1].ID != "task_b" { + t.Fatalf("expected ID tie-breaker order, got %q then %q", tasks[0].ID, tasks[1].ID) + } +} From 66c042ae4302b51dde773f45a6c09d81898ab59d Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 11:52:49 +0530 Subject: [PATCH 02/18] harden upload lifecycle controls --- server.go | 120 ++++++++++++++++++++++++++++++++++++++++--- server_test.go | 135 +++++++++++++++++++++++++++++++++++++++++++++++++ store.go | 16 ++++++ 3 files changed, 263 insertions(+), 8 deletions(-) diff --git a/server.go b/server.go index 0b6afaa..ce903e1 100644 --- a/server.go +++ b/server.go @@ -20,9 +20,10 @@ import ( ) const ( - maxRequestBytes = 64 << 20 - maxUploadBytes = 32 << 20 - maxFilesPerTask = 8 + maxRequestBytes = 64 << 20 + maxUploadBytes = 32 << 20 + maxFilesPerTask = 8 + maxMutationsPerMinute = 60 ) type app struct { @@ -30,6 +31,7 @@ type app struct { hub *eventHub uploadDir string static http.Handler + limiter *rateLimiter } type eventHub struct { @@ -55,6 +57,18 @@ type serverEvent struct { Data []byte } +type rateLimiter struct { + mu sync.Mutex + window time.Duration + limit int + clients map[string]rateWindow +} + +type rateWindow struct { + start time.Time + count int +} + type ConnectedDevice struct { ID string `json:"id"` Name string `json:"name"` @@ -87,6 +101,7 @@ func newApp(store *Store, uploadDir string, static http.Handler) *app { hub: newEventHub(), uploadDir: uploadDir, static: static, + limiter: newRateLimiter(time.Minute, maxMutationsPerMinute), } } @@ -94,6 +109,14 @@ func newEventHub() *eventHub { return &eventHub{clients: make(map[string]*clientSession)} } +func newRateLimiter(window time.Duration, limit int) *rateLimiter { + return &rateLimiter{ + window: window, + limit: limit, + clients: make(map[string]rateWindow), + } +} + func (a *app) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/tasks", a.handleTasks) @@ -104,7 +127,7 @@ func (a *app) routes() http.Handler { mux.HandleFunc("/api/network", a.handleNetwork) mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(a.uploadDir)))) mux.Handle("/", a.static) - return withSecurityHeaders(mux) + return withSecurityHeaders(a.withMutationRateLimit(mux)) } func (a *app) handleTasks(w http.ResponseWriter, r *http.Request) { @@ -188,11 +211,17 @@ func (a *app) handleTaskByID(w http.ResponseWriter, r *http.Request) { a.hub.broadcast(snapshot) writeJSON(w, http.StatusOK, task) case http.MethodDelete: + task, err := a.store.Task(id) + if err != nil { + writeStoreError(w, err) + return + } snapshot, err := a.store.DeleteTask(id) if err != nil { writeStoreError(w, err) return } + a.removeUploadedAttachments(task.Attachments) a.hub.broadcast(snapshot) w.WriteHeader(http.StatusNoContent) default: @@ -360,6 +389,11 @@ func (a *app) saveUploadedFiles(r *http.Request) ([]Attachment, error) { if name == "" { name = fileID } + contentType, err := allowedUploadType(name) + if err != nil { + _ = source.Close() + return nil, err + } storedName := fileID + "_" + name targetPath := filepath.Join(a.uploadDir, storedName) target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) @@ -381,10 +415,6 @@ func (a *app) saveUploadedFiles(r *http.Request) ([]Attachment, error) { return nil, fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes)) } - contentType := header.Header.Get("Content-Type") - if contentType == "" { - contentType = mime.TypeByExtension(filepath.Ext(name)) - } attachments = append(attachments, Attachment{ ID: fileID, Name: name, @@ -397,6 +427,16 @@ func (a *app) saveUploadedFiles(r *http.Request) ([]Attachment, error) { return attachments, nil } +func (a *app) removeUploadedAttachments(attachments []Attachment) { + for _, attachment := range attachments { + storedName := filepath.Base(strings.TrimPrefix(attachment.URL, "/uploads/")) + if storedName == "." || storedName == string(filepath.Separator) || storedName == "" { + continue + } + _ = os.Remove(filepath.Join(a.uploadDir, storedName)) + } +} + func (h *eventHub) subscribe(r *http.Request) (*clientSession, error) { id, err := newID("client") if err != nil { @@ -567,6 +607,25 @@ func methodNotAllowed(w http.ResponseWriter, methods ...string) { httpError(w, http.StatusMethodNotAllowed, "method not allowed") } +func (a *app) withMutationRateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isMutation(r) && !a.limiter.Allow(remoteAddress(r)) { + httpError(w, http.StatusTooManyRequests, "too many requests") + return + } + next.ServeHTTP(w, r) + }) +} + +func isMutation(r *http.Request) bool { + switch r.Method { + case http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodPut: + return strings.HasPrefix(r.URL.Path, "/api/") + default: + return false + } +} + func withSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") @@ -575,6 +634,28 @@ func withSecurityHeaders(next http.Handler) http.Handler { }) } +func (l *rateLimiter) Allow(key string) bool { + if key == "" { + key = "unknown" + } + now := time.Now().UTC() + + l.mu.Lock() + defer l.mu.Unlock() + + window := l.clients[key] + if window.start.IsZero() || now.Sub(window.start) >= l.window { + l.clients[key] = rateWindow{start: now, count: 1} + return true + } + if window.count >= l.limit { + return false + } + window.count++ + l.clients[key] = window + return true +} + func sanitizeFilename(name string) string { name = filepath.Base(name) name = strings.TrimSpace(name) @@ -596,6 +677,29 @@ func sanitizeFilename(name string) string { return strings.Trim(builder.String(), ".-_") } +func allowedUploadType(name string) (string, error) { + ext := strings.ToLower(filepath.Ext(name)) + allowed := map[string]string{ + ".csv": "text/csv; charset=utf-8", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".json": "application/json", + ".md": "text/markdown; charset=utf-8", + ".pdf": "application/pdf", + ".png": "image/png", + ".txt": "text/plain; charset=utf-8", + ".webp": "image/webp", + } + if contentType, ok := allowed[ext]; ok { + return contentType, nil + } + if detected := mime.TypeByExtension(ext); detected != "" { + return "", fmt.Errorf("%s uploads are not allowed", ext) + } + return "", fmt.Errorf("file extension is required") +} + func humanBytes(n int64) string { const unit = 1024 if n < unit { diff --git a/server_test.go b/server_test.go index a720cd0..ce8b4a1 100644 --- a/server_test.go +++ b/server_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestCreateTaskWithUpload(t *testing.T) { @@ -71,6 +72,103 @@ func TestCreateTaskWithUpload(t *testing.T) { } } +func TestCreateTaskRejectsExecutableUploadExtension(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Upload a script"); err != nil { + t.Fatalf("write title: %v", err) + } + file, err := writer.CreateFormFile("files", "payload.html") + if err != nil { + t.Fatalf("create file field: %v", err) + } + if _, err := file.Write([]byte("")); err != nil { + t.Fatalf("write file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", response.Code, response.Body.String()) + } + entries, err := os.ReadDir(filepath.Join(dataDir, "uploads")) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("read upload dir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected rejected upload to avoid stored files, got %d", len(entries)) + } +} + +func TestDeleteTaskRemovesUploadedFiles(t *testing.T) { + dataDir := t.TempDir() + uploadDir := filepath.Join(dataDir, "uploads") + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, uploadDir, http.NotFoundHandler()) + task := createUploadedTask(t, app, "notes.txt", "temporary notes") + + uploadedPath := filepath.Join(uploadDir, strings.TrimPrefix(task.Attachments[0].URL, "/uploads/")) + if _, err := os.Stat(uploadedPath); err != nil { + t.Fatalf("expected uploaded file before delete: %v", err) + } + + request := httptest.NewRequest(http.MethodDelete, "/api/tasks/"+task.ID, nil) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d: %s", response.Code, response.Body.String()) + } + if _, err := os.Stat(uploadedPath); !os.IsNotExist(err) { + t.Fatalf("expected uploaded file to be removed, got %v", err) + } +} + +func TestMutationRateLimit(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + app.limiter = newRateLimiter(time.Minute, 1) + + for i, want := range []int{http.StatusCreated, http.StatusTooManyRequests} { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Limited task"); err != nil { + t.Fatalf("write title: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + request.RemoteAddr = "203.0.113.10:4000" + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + if response.Code != want { + t.Fatalf("request %d: expected status %d, got %d: %s", i+1, want, response.Code, response.Body.String()) + } + } +} + func TestEventHubTracksConnectedDevices(t *testing.T) { hub := newEventHub() request := httptest.NewRequest(http.MethodGet, "/api/events", nil) @@ -168,3 +266,40 @@ func TestEventHubTracksClientHealth(t *testing.T) { t.Fatalf("expected rtt, got %+v", health) } } + +func createUploadedTask(t *testing.T, app *app, name, contents string) Task { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Upload a file"); err != nil { + t.Fatalf("write title: %v", err) + } + file, err := writer.CreateFormFile("files", name) + if err != nil { + t.Fatalf("create file field: %v", err) + } + if _, err := file.Write([]byte(contents)); err != nil { + t.Fatalf("write file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", response.Code, response.Body.String()) + } + var task Task + if err := json.Unmarshal(response.Body.Bytes(), &task); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(task.Attachments) != 1 { + t.Fatalf("expected one attachment, got %d", len(task.Attachments)) + } + return task +} diff --git a/store.go b/store.go index e10ed72..517e38a 100644 --- a/store.go +++ b/store.go @@ -91,6 +91,22 @@ func (s *Store) Snapshot() Snapshot { return s.snapshotLocked() } +func (s *Store) Task(id string) (Task, error) { + id = strings.TrimSpace(id) + if id == "" { + return Task{}, fmt.Errorf("%w: id is required", errBadInput) + } + + s.mu.RLock() + defer s.mu.RUnlock() + + task, ok := s.tasks[id] + if !ok { + return Task{}, errNotFound + } + return task, nil +} + func (s *Store) AddTask(title, notes, parentID string, attachments []Attachment) (Snapshot, Task, error) { title = strings.TrimSpace(title) notes = strings.TrimSpace(notes) From 2ed1646d037b7f57bbe4338845e4d70b93ce2462 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 12:42:09 +0530 Subject: [PATCH 03/18] address upload lifecycle review --- server.go | 49 +++++++++++++++++++++------- server_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 11 deletions(-) diff --git a/server.go b/server.go index ce903e1..21812fd 100644 --- a/server.go +++ b/server.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log" "mime" "net" "net/http" @@ -24,6 +25,7 @@ const ( maxUploadBytes = 32 << 20 maxFilesPerTask = 8 maxMutationsPerMinute = 60 + maxRateLimitClients = 4096 ) type app struct { @@ -370,20 +372,25 @@ func (a *app) saveUploadedFiles(r *http.Request) ([]Attachment, error) { } attachments := make([]Attachment, 0, len(files)) + createdPaths := make([]string, 0, len(files)) + fail := func(err error) ([]Attachment, error) { + removeUploadedPaths(createdPaths) + return nil, err + } for _, header := range files { if header.Size > maxUploadBytes { - return nil, fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes)) + return fail(fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes))) } source, err := header.Open() if err != nil { - return nil, err + return fail(err) } fileID, err := newID("file") if err != nil { _ = source.Close() - return nil, err + return fail(err) } name := sanitizeFilename(header.Filename) if name == "" { @@ -392,27 +399,27 @@ func (a *app) saveUploadedFiles(r *http.Request) ([]Attachment, error) { contentType, err := allowedUploadType(name) if err != nil { _ = source.Close() - return nil, err + return fail(err) } storedName := fileID + "_" + name targetPath := filepath.Join(a.uploadDir, storedName) target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { _ = source.Close() - return nil, err + return fail(err) } + createdPaths = append(createdPaths, targetPath) written, copyErr := io.Copy(target, io.LimitReader(source, maxUploadBytes+1)) closeErr := errors.Join(source.Close(), target.Close()) if copyErr != nil { - return nil, copyErr + return fail(copyErr) } if closeErr != nil { - return nil, closeErr + return fail(closeErr) } if written > maxUploadBytes { - _ = os.Remove(targetPath) - return nil, fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes)) + return fail(fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes))) } attachments = append(attachments, Attachment{ @@ -433,7 +440,15 @@ func (a *app) removeUploadedAttachments(attachments []Attachment) { if storedName == "." || storedName == string(filepath.Separator) || storedName == "" { continue } - _ = os.Remove(filepath.Join(a.uploadDir, storedName)) + removeUploadedPaths([]string{filepath.Join(a.uploadDir, storedName)}) + } +} + +func removeUploadedPaths(paths []string) { + for _, path := range paths { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Printf("remove upload %s: %v", path, err) + } } } @@ -643,8 +658,17 @@ func (l *rateLimiter) Allow(key string) bool { l.mu.Lock() defer l.mu.Unlock() + for client, window := range l.clients { + if now.Sub(window.start) >= l.window { + delete(l.clients, client) + } + } + window := l.clients[key] if window.start.IsZero() || now.Sub(window.start) >= l.window { + if len(l.clients) >= maxRateLimitClients { + return false + } l.clients[key] = rateWindow{start: now, count: 1} return true } @@ -694,10 +718,13 @@ func allowedUploadType(name string) (string, error) { if contentType, ok := allowed[ext]; ok { return contentType, nil } + if ext == "" { + return "", fmt.Errorf("file extension is required") + } if detected := mime.TypeByExtension(ext); detected != "" { return "", fmt.Errorf("%s uploads are not allowed", ext) } - return "", fmt.Errorf("file extension is required") + return "", fmt.Errorf("%s uploads are not allowed", ext) } func humanBytes(n int64) string { diff --git a/server_test.go b/server_test.go index ce8b4a1..0acc140 100644 --- a/server_test.go +++ b/server_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "fmt" "mime/multipart" "net/http" "net/http/httptest" @@ -113,6 +114,65 @@ func TestCreateTaskRejectsExecutableUploadExtension(t *testing.T) { } } +func TestCreateTaskCleansEarlierUploadsWhenLaterFileFails(t *testing.T) { + dataDir := t.TempDir() + uploadDir := filepath.Join(dataDir, "uploads") + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, uploadDir, http.NotFoundHandler()) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Mixed upload"); err != nil { + t.Fatalf("write title: %v", err) + } + first, err := writer.CreateFormFile("files", "safe.txt") + if err != nil { + t.Fatalf("create first file: %v", err) + } + if _, err := first.Write([]byte("safe")); err != nil { + t.Fatalf("write first file: %v", err) + } + second, err := writer.CreateFormFile("files", "unsafe.sh") + if err != nil { + t.Fatalf("create second file: %v", err) + } + if _, err := second.Write([]byte("echo unsafe")); err != nil { + t.Fatalf("write second file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", response.Code, response.Body.String()) + } + entries, err := os.ReadDir(uploadDir) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("read upload dir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected failed multi-file upload to remove earlier files, got %d", len(entries)) + } +} + +func TestAllowedUploadTypeReportsUnknownExtension(t *testing.T) { + _, err := allowedUploadType("script.sh") + if err == nil { + t.Fatal("expected .sh to be rejected") + } + if !strings.Contains(err.Error(), ".sh uploads are not allowed") { + t.Fatalf("expected unknown extension message, got %q", err.Error()) + } +} + func TestDeleteTaskRemovesUploadedFiles(t *testing.T) { dataDir := t.TempDir() uploadDir := filepath.Join(dataDir, "uploads") @@ -169,6 +229,33 @@ func TestMutationRateLimit(t *testing.T) { } } +func TestMutationRateLimitPrunesExpiredClients(t *testing.T) { + limiter := newRateLimiter(time.Minute, 1) + limiter.clients["expired"] = rateWindow{ + start: time.Now().Add(-2 * time.Minute), + count: 1, + } + + if !limiter.Allow("fresh") { + t.Fatal("expected fresh client to be allowed") + } + if _, ok := limiter.clients["expired"]; ok { + t.Fatal("expected expired client to be pruned") + } +} + +func TestMutationRateLimitCapsClientMap(t *testing.T) { + limiter := newRateLimiter(time.Minute, 1) + now := time.Now() + for i := 0; i < maxRateLimitClients; i++ { + limiter.clients[fmt.Sprintf("client-%d", i)] = rateWindow{start: now, count: 1} + } + + if limiter.Allow("overflow") { + t.Fatal("expected new client to be rejected when limiter map is full") + } +} + func TestEventHubTracksConnectedDevices(t *testing.T) { hub := newEventHub() request := httptest.NewRequest(http.MethodGet, "/api/events", nil) From 32231722aaf567cff927eb98ba7f9d7a4568ff9b Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 13:00:37 +0530 Subject: [PATCH 04/18] address store task review --- server.go | 4 ---- store.go | 3 +++ store_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/server.go b/server.go index 21812fd..e2edaa1 100644 --- a/server.go +++ b/server.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "log" - "mime" "net" "net/http" "os" @@ -721,9 +720,6 @@ func allowedUploadType(name string) (string, error) { if ext == "" { return "", fmt.Errorf("file extension is required") } - if detected := mime.TypeByExtension(ext); detected != "" { - return "", fmt.Errorf("%s uploads are not allowed", ext) - } return "", fmt.Errorf("%s uploads are not allowed", ext) } diff --git a/store.go b/store.go index 517e38a..ed54b6a 100644 --- a/store.go +++ b/store.go @@ -104,6 +104,9 @@ func (s *Store) Task(id string) (Task, error) { if !ok { return Task{}, errNotFound } + if len(task.Attachments) > 0 { + task.Attachments = append([]Attachment(nil), task.Attachments...) + } return task, nil } diff --git a/store_test.go b/store_test.go index 825beb6..0d956f0 100644 --- a/store_test.go +++ b/store_test.go @@ -77,6 +77,35 @@ func TestStoreRejectsParentCycles(t *testing.T) { } } +func TestStoreTaskReturnsAttachmentCopy(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + _, task, err := store.AddTask("With attachment", "", "", []Attachment{{ + ID: "file_1", + Name: "notes.txt", + URL: "/uploads/file_1_notes.txt", + }}) + if err != nil { + t.Fatalf("add task: %v", err) + } + + got, err := store.Task(task.ID) + if err != nil { + t.Fatalf("get task: %v", err) + } + got.Attachments[0].Name = "mutated.txt" + + again, err := store.Task(task.ID) + if err != nil { + t.Fatalf("get task again: %v", err) + } + if again.Attachments[0].Name != "notes.txt" { + t.Fatalf("expected stored attachment to be unchanged, got %q", again.Attachments[0].Name) + } +} + func TestStoreSortsTasksWithIDTieBreaker(t *testing.T) { store := &Store{ tasks: make(map[string]Task), From 02be692ef8e0084b2cf768286bd274b1a72ee8ad Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 11:54:32 +0530 Subject: [PATCH 05/18] add csrf mutation guard --- server.go | 97 +++++++++++++++++++++++++++++++++++++++++++- server_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ static/app.js | 20 ++++++++- 3 files changed, 222 insertions(+), 3 deletions(-) diff --git a/server.go b/server.go index e2edaa1..82bfbf2 100644 --- a/server.go +++ b/server.go @@ -1,7 +1,9 @@ package main import ( + "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" "errors" @@ -25,6 +27,8 @@ const ( maxFilesPerTask = 8 maxMutationsPerMinute = 60 maxRateLimitClients = 4096 + csrfCookieName = "doit_csrf" + csrfHeaderName = "X-CSRF-Token" ) type app struct { @@ -128,7 +132,7 @@ func (a *app) routes() http.Handler { mux.HandleFunc("/api/network", a.handleNetwork) mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(a.uploadDir)))) mux.Handle("/", a.static) - return withSecurityHeaders(a.withMutationRateLimit(mux)) + return withSecurityHeaders(withCSRFCookie(withCSRFProtection(a.withMutationRateLimit(mux)))) } func (a *app) handleTasks(w http.ResponseWriter, r *http.Request) { @@ -631,6 +635,16 @@ func (a *app) withMutationRateLimit(next http.Handler) http.Handler { }) } +func withCSRFProtection(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requiresCSRF(r) && !validCSRFToken(r) { + httpError(w, http.StatusForbidden, "invalid CSRF token") + return + } + next.ServeHTTP(w, r) + }) +} + func isMutation(r *http.Request) bool { switch r.Method { case http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodPut: @@ -640,6 +654,87 @@ func isMutation(r *http.Request) bool { } } +func withCSRFCookie(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isSafeMethod(r.Method) { + ensureCSRFCookie(w, r) + } + next.ServeHTTP(w, r) + }) +} + +func requiresCSRF(r *http.Request) bool { + switch r.Method { + case http.MethodPost, http.MethodPatch, http.MethodDelete: + return strings.HasPrefix(r.URL.Path, "/api/") + default: + return false + } +} + +func isSafeMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + return false + } +} + +func ensureCSRFCookie(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(csrfCookieName); err == nil && strings.TrimSpace(cookie.Value) != "" { + return + } + token, err := newCSRFToken() + if err != nil { + return + } + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + SameSite: http.SameSiteStrictMode, + Secure: requestIsHTTPS(r), + }) +} + +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + return true + } + for _, part := range strings.Split(r.Header.Get("Forwarded"), ";") { + part = strings.TrimSpace(part) + if strings.EqualFold(part, "proto=https") { + return true + } + } + return false +} + +func validCSRFToken(r *http.Request) bool { + cookie, err := r.Cookie(csrfCookieName) + if err != nil { + return false + } + cookieToken := strings.TrimSpace(cookie.Value) + headerToken := strings.TrimSpace(r.Header.Get(csrfHeaderName)) + if cookieToken == "" || headerToken == "" || len(cookieToken) != len(headerToken) { + return false + } + return subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1 +} + +func newCSRFToken() (string, error) { + var bytes [32]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return hex.EncodeToString(bytes[:]), nil +} + func withSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/server_test.go b/server_test.go index 0acc140..0f10d88 100644 --- a/server_test.go +++ b/server_test.go @@ -40,6 +40,7 @@ func TestCreateTaskWithUpload(t *testing.T) { } request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) @@ -98,6 +99,7 @@ func TestCreateTaskRejectsExecutableUploadExtension(t *testing.T) { } request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) @@ -114,6 +116,93 @@ func TestCreateTaskRejectsExecutableUploadExtension(t *testing.T) { } } +func TestCreateTaskRejectsMissingCSRFToken(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Missing token"); err != nil { + t.Fatalf("write title: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", response.Code, response.Body.String()) + } +} + +func TestUnsupportedAPIMethodReturnsMethodNotAllowedWithoutCSRF(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + + request := httptest.NewRequest(http.MethodPut, "/api/tasks", nil) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d: %s", response.Code, response.Body.String()) + } +} + +func TestCSRFCookieIsOnlyIssuedForSafeMethods(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, t.TempDir(), http.NotFoundHandler()) + + postRequest := httptest.NewRequest(http.MethodPost, "/", nil) + postResponse := httptest.NewRecorder() + app.routes().ServeHTTP(postResponse, postRequest) + if cookieByName(postResponse.Result().Cookies(), csrfCookieName) != nil { + t.Fatal("expected unsafe non-API request not to receive a CSRF cookie") + } + + getRequest := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + getResponse := httptest.NewRecorder() + app.routes().ServeHTTP(getResponse, getRequest) + if cookieByName(getResponse.Result().Cookies(), csrfCookieName) == nil { + t.Fatal("expected safe request to receive a CSRF cookie") + } +} + +func TestCSRFCookieUsesForwardedHTTPSForSecureAttribute(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, t.TempDir(), http.NotFoundHandler()) + + request := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + request.Header.Set("X-Forwarded-Proto", "https") + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + cookie := cookieByName(response.Result().Cookies(), csrfCookieName) + if cookie == nil { + t.Fatal("expected CSRF cookie") + } + if !cookie.Secure { + t.Fatal("expected forwarded HTTPS request to set Secure cookie") + } +} + func TestCreateTaskCleansEarlierUploadsWhenLaterFileFails(t *testing.T) { dataDir := t.TempDir() uploadDir := filepath.Join(dataDir, "uploads") @@ -147,6 +236,7 @@ func TestCreateTaskCleansEarlierUploadsWhenLaterFileFails(t *testing.T) { } request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) @@ -189,6 +279,7 @@ func TestDeleteTaskRemovesUploadedFiles(t *testing.T) { } request := httptest.NewRequest(http.MethodDelete, "/api/tasks/"+task.ID, nil) + addCSRF(request) response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) @@ -219,6 +310,7 @@ func TestMutationRateLimit(t *testing.T) { t.Fatalf("close multipart writer: %v", err) } request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) request.RemoteAddr = "203.0.113.10:4000" request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() @@ -374,6 +466,7 @@ func createUploadedTask(t *testing.T, app *app, name, contents string) Task { } request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) @@ -390,3 +483,18 @@ func createUploadedTask(t *testing.T, app *app, name, contents string) Task { } return task } + +func addCSRF(request *http.Request) { + const token = "test-csrf-token" + request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token}) + request.Header.Set(csrfHeaderName, token) +} + +func cookieByName(cookies []*http.Cookie, name string) *http.Cookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil +} diff --git a/static/app.js b/static/app.js index 37e5f52..556fe9f 100644 --- a/static/app.js +++ b/static/app.js @@ -27,6 +27,7 @@ form.addEventListener("submit", async (event) => { const response = await fetch("/api/tasks", { method: "POST", + headers: csrfHeaders(), body: data, }); if (!response.ok) { @@ -490,7 +491,7 @@ function compareTasks(a, b) { async function patchTask(id, payload) { const response = await fetch(`/api/tasks/${id}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...csrfHeaders() }, body: JSON.stringify(payload), }); if (!response.ok) { @@ -501,6 +502,7 @@ async function patchTask(id, payload) { async function deleteTask(id) { const response = await fetch(`/api/tasks/${id}`, { method: "DELETE", + headers: csrfHeaders(), }); if (!response.ok) { await showRequestError(response); @@ -520,6 +522,20 @@ function getClientId() { return id; } +function csrfHeaders() { + const token = readCookie("doit_csrf"); + return token ? { "X-CSRF-Token": token } : {}; +} + +function readCookie(name) { + const prefix = `${name}=`; + return document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length) || ""; +} + async function startClientStatusReporting() { await attachBatteryListeners(); await reportClientStatus(); @@ -547,7 +563,7 @@ async function reportClientStatus() { try { await fetch("/api/client-status", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...csrfHeaders() }, body: JSON.stringify(status), keepalive: true, }); From 3186032beb583f17bce268f3facd5f2f67b3a142 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 11:56:10 +0530 Subject: [PATCH 06/18] align runtime operational limits --- docs/ARCHITECTURE.md | 6 ++++-- main.go | 29 +++++++++++++++++++++++++++-- server.go | 14 ++++++++++++++ server_test.go | 13 +++++++++++++ store.go | 41 ++++++++++++++++++++++++++++++++++++++++- store_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 5 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 930382a..1494c60 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -243,6 +243,7 @@ For the current product, HTTP plus SSE is simpler and easier to debug while lear - Uploads are local to one server device. - Device presence is approximate because it uses IP plus user agent. - No offline edit queue yet. +- Each mutation currently broadcasts a full task snapshot to every connected browser. That is simple and reliable for a LAN-scale app, but large datasets should move toward diff-based updates or paged sync. ## Next Architecture Upgrades @@ -252,5 +253,6 @@ Best order: 2. Pairing code or local auth. 3. Better backup/export. 4. Drag-to-reparent graph nodes. -5. Optional gRPC/Connect-Go API. -6. mDNS discovery, so devices can open `doit.local`. +5. Diff-based live sync for large task graphs. +6. Optional gRPC/Connect-Go API. +7. mDNS discovery, so devices can open `doit.local`. diff --git a/main.go b/main.go index 2dac000..76fa98f 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,16 @@ package main import ( + "context" "embed" "io/fs" "log" "net/http" "os" + "os/signal" "path/filepath" "strings" + "syscall" "time" ) @@ -42,8 +45,30 @@ func main() { log.Printf("LAN device URL: %s", url) } - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("serve: %v", err) + serverErr := make(chan error, 1) + go func() { + serverErr <- server.ListenAndServe() + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(stop) + + select { + case err := <-serverErr: + if err != nil && err != http.ErrServerClosed { + log.Fatalf("serve: %v", err) + } + case sig := <-stop: + log.Printf("received %s, shutting down", sig) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("shutdown: %v", err) + } + if err := <-serverErr; err != nil && err != http.ErrServerClosed { + log.Fatalf("serve: %v", err) + } } } diff --git a/server.go b/server.go index 82bfbf2..a579dfd 100644 --- a/server.go +++ b/server.go @@ -856,6 +856,9 @@ func localNetworkURLs(port string) []string { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { continue } + if isLikelyVirtualInterface(iface.Name) { + continue + } addrs, err := iface.Addrs() if err != nil { continue @@ -872,6 +875,17 @@ func localNetworkURLs(port string) []string { return urls } +func isLikelyVirtualInterface(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + prefixes := []string{"br-", "docker", "veth", "virbr"} + for _, prefix := range prefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + func remoteAddress(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { diff --git a/server_test.go b/server_test.go index 0f10d88..e71bbfd 100644 --- a/server_test.go +++ b/server_test.go @@ -446,6 +446,19 @@ func TestEventHubTracksClientHealth(t *testing.T) { } } +func TestVirtualInterfaceNamesAreSkippedForLANURLs(t *testing.T) { + for _, name := range []string{"docker0", "br-1a2b3c", "veth123", "virbr0"} { + if !isLikelyVirtualInterface(name) { + t.Fatalf("expected %q to be treated as virtual", name) + } + } + for _, name := range []string{"en0", "wlan0", "eth0"} { + if isLikelyVirtualInterface(name) { + t.Fatalf("expected %q to be treated as a physical candidate", name) + } + } +} + func createUploadedTask(t *testing.T, app *app, name, contents string) Task { t.Helper() diff --git a/store.go b/store.go index ed54b6a..707eeaa 100644 --- a/store.go +++ b/store.go @@ -19,6 +19,11 @@ var ( errNotFound = errors.New("not found") ) +const ( + maxTitleLength = 120 + maxNotesLength = 2000 +) + type Attachment struct { ID string `json:"id"` Name string `json:"name"` @@ -117,6 +122,9 @@ func (s *Store) AddTask(title, notes, parentID string, attachments []Attachment) if title == "" { return Snapshot{}, Task{}, fmt.Errorf("%w: title is required", errBadInput) } + if err := validateTaskText(title, notes); err != nil { + return Snapshot{}, Task{}, err + } s.mu.Lock() defer s.mu.Unlock() @@ -170,10 +178,17 @@ func (s *Store) PatchTask(id string, patch TaskPatch) (Snapshot, Task, error) { if title == "" { return Snapshot{}, Task{}, fmt.Errorf("%w: title is required", errBadInput) } + if tooLong(title, maxTitleLength) { + return Snapshot{}, Task{}, fmt.Errorf("%w: title must be at most %d characters", errBadInput, maxTitleLength) + } task.Title = title } if patch.Notes != nil { - task.Notes = strings.TrimSpace(*patch.Notes) + notes := strings.TrimSpace(*patch.Notes) + if tooLong(notes, maxNotesLength) { + return Snapshot{}, Task{}, fmt.Errorf("%w: notes must be at most %d characters", errBadInput, maxNotesLength) + } + task.Notes = notes } if patch.Done != nil { task.Done = *patch.Done @@ -355,6 +370,30 @@ func (s *Store) wouldCycleLocked(id, parentID string) bool { return false } +func validateTaskText(title, notes string) error { + if tooLong(title, maxTitleLength) { + return fmt.Errorf("%w: title must be at most %d characters", errBadInput, maxTitleLength) + } + if tooLong(notes, maxNotesLength) { + return fmt.Errorf("%w: notes must be at most %d characters", errBadInput, maxNotesLength) + } + return nil +} + +func tooLong(value string, max int) bool { + units := 0 + for _, r := range value { + units++ + if r > 0xffff { + units++ + } + if units > max { + return true + } + } + return false +} + func newID(prefix string) (string, error) { var bytes [8]byte if _, err := rand.Read(bytes[:]); err != nil { diff --git a/store_test.go b/store_test.go index 0d956f0..4a46ad4 100644 --- a/store_test.go +++ b/store_test.go @@ -2,6 +2,7 @@ package main import ( "path/filepath" + "strings" "testing" "time" ) @@ -122,3 +123,41 @@ func TestStoreSortsTasksWithIDTieBreaker(t *testing.T) { t.Fatalf("expected ID tie-breaker order, got %q then %q", tasks[0].ID, tasks[1].ID) } } + +func TestStoreEnforcesTaskTextLimits(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + + longTitle := strings.Repeat("t", maxTitleLength+1) + if _, _, err := store.AddTask(longTitle, "", "", nil); err == nil { + t.Fatal("expected long title to be rejected") + } + + _, task, err := store.AddTask("Within limit", "", "", nil) + if err != nil { + t.Fatalf("add task: %v", err) + } + longNotes := strings.Repeat("n", maxNotesLength+1) + if _, _, err := store.PatchTask(task.ID, TaskPatch{Notes: &longNotes}); err == nil { + t.Fatal("expected long notes to be rejected") + } +} + +func TestStoreTaskTextLimitsMatchUTF16MaxLength(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + + withinLimit := strings.Repeat("a", maxTitleLength-2) + "😀" + if _, _, err := store.AddTask(withinLimit, "", "", nil); err != nil { + t.Fatalf("expected UTF-16 limit boundary to be accepted: %v", err) + } + + overLimit := strings.Repeat("a", maxTitleLength-1) + "😀" + if _, _, err := store.AddTask(overLimit, "", "", nil); err == nil { + t.Fatal("expected title beyond UTF-16 limit to be rejected") + } +} From 5add82e7ec332c5c455e53fc2e3942d1af2d4831 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 12:55:12 +0530 Subject: [PATCH 07/18] address operational review --- store.go | 5 +++-- store_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/store.go b/store.go index 707eeaa..e018caa 100644 --- a/store.go +++ b/store.go @@ -383,8 +383,9 @@ func validateTaskText(title, notes string) error { func tooLong(value string, max int) bool { units := 0 for _, r := range value { - units++ - if r > 0xffff { + if r > 0xFFFF { + units += 2 + } else { units++ } if units > max { diff --git a/store_test.go b/store_test.go index 4a46ad4..9faf2f6 100644 --- a/store_test.go +++ b/store_test.go @@ -145,7 +145,7 @@ func TestStoreEnforcesTaskTextLimits(t *testing.T) { } } -func TestStoreTaskTextLimitsMatchUTF16MaxLength(t *testing.T) { +func TestStoreTextLimitsMatchBrowserUTF16Counting(t *testing.T) { store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) if err != nil { t.Fatalf("new store: %v", err) From e489240a38b526ca1bc822ad49f94ca6e1b5472b Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 11:56:44 +0530 Subject: [PATCH 08/18] add visible author credit --- static/app.css | 21 +++++++++++++++++++++ static/index.html | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/static/app.css b/static/app.css index e3c131c..b8914d3 100644 --- a/static/app.css +++ b/static/app.css @@ -162,6 +162,27 @@ label span, margin-top: 26px; } +.credit { + margin-top: 28px; + padding-top: 14px; + border-top: 1px solid var(--faint); + display: flex; + flex-wrap: wrap; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.credit a { + color: var(--ink); + text-decoration: none; +} + +.credit a:hover, +.credit a:focus-visible { + text-decoration: underline; +} + .section-heading { display: flex; align-items: center; diff --git a/static/index.html b/static/index.html index 16a98ed..49c7c2b 100644 --- a/static/index.html +++ b/static/index.html @@ -55,6 +55,11 @@

Do-It

+ +
From f22e7cf67ddb5ca516ec2a0a2e2da15d9dc2a64c Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 11:57:51 +0530 Subject: [PATCH 09/18] document terminal release downloads --- README.md | 27 +++++++++++++++++++++++ scripts/install.sh | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100755 scripts/install.sh diff --git a/README.md b/README.md index 3ae0cfb..e7d199b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,33 @@ http://192.168.1.22:8080 Open that address from another device connected to the same Wi-Fi. +## Terminal Download + +Install the latest release with either `curl` or `wget`: + +```bash +curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh +``` + +```bash +wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh +``` + +The installer downloads the matching GitHub release archive for Linux or macOS on `amd64`/`arm64`, then installs `doit` into `~/.local/bin`. Override the destination or version when needed: + +```bash +DOIT_INSTALL_DIR=/usr/local/bin DOIT_VERSION=v1.0.0 sh scripts/install.sh +``` + +Manual download format: + +```text +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_linux_amd64.tar.gz +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_linux_arm64.tar.gz +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_darwin_amd64.tar.gz +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_darwin_arm64.tar.gz +``` + ## Data Storage By default, Do-It writes local app data under: diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..7bfda4c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env sh +set -eu + +repo="blackdragoon26/Do-It" +install_dir="${DOIT_INSTALL_DIR:-$HOME/.local/bin}" +version="${DOIT_VERSION:-latest}" + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" + +case "$arch" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) echo "unsupported architecture: $arch" >&2; exit 1 ;; +esac + +case "$os" in + linux|darwin) archive_ext="tar.gz" ;; + *) echo "unsupported OS: $os" >&2; exit 1 ;; +esac + +if [ "$version" = "latest" ]; then + base_url="https://github.com/$repo/releases/latest/download" +else + base_url="https://github.com/$repo/releases/download/$version" +fi + +archive="Do-It_${os}_${arch}.${archive_ext}" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT INT TERM + +download() { + url="$1" + output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + return + fi + if command -v wget >/dev/null 2>&1; then + wget -qO "$output" "$url" + return + fi + echo "curl or wget is required" >&2 + exit 1 +} + +download "$base_url/$archive" "$tmp_dir/$archive" +tar -xzf "$tmp_dir/$archive" -C "$tmp_dir" + +mkdir -p "$install_dir" +install "$tmp_dir/doit" "$install_dir/doit" + +echo "Installed doit to $install_dir/doit" +echo "Run: $install_dir/doit" From 16c74421107476dbd75c4d48dcc6e0d547ebdccc Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 12:13:55 +0530 Subject: [PATCH 10/18] point installer docs at dev branch --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e7d199b..e56829d 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,11 @@ Open that address from another device connected to the same Wi-Fi. Install the latest release with either `curl` or `wget`: ```bash -curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh ``` ```bash -wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh +wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh ``` The installer downloads the matching GitHub release archive for Linux or macOS on `amd64`/`arm64`, then installs `doit` into `~/.local/bin`. Override the destination or version when needed: From a6f25842a4b6a3ff7cc8cbf226bfdfaebfeafc7a Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 16:07:22 +0530 Subject: [PATCH 11/18] address terminal download review --- README.md | 6 +++--- scripts/install.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e56829d..f95046c 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,14 @@ Open that address from another device connected to the same Wi-Fi. Install the latest release with either `curl` or `wget`: ```bash -curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh ``` ```bash -wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh +wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh ``` -The installer downloads the matching GitHub release archive for Linux or macOS on `amd64`/`arm64`, then installs `doit` into `~/.local/bin`. Override the destination or version when needed: +The installer downloads the matching GitHub release archive for Linux or macOS on `amd64`/`arm64`, then installs `doit` into `~/.local/bin`. If you cloned the repository or downloaded `scripts/install.sh` locally, override the destination or version when needed: ```bash DOIT_INSTALL_DIR=/usr/local/bin DOIT_VERSION=v1.0.0 sh scripts/install.sh diff --git a/scripts/install.sh b/scripts/install.sh index 7bfda4c..69bd7c0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -26,7 +26,7 @@ else fi archive="Do-It_${os}_${arch}.${archive_ext}" -tmp_dir="$(mktemp -d)" +tmp_dir="$(mktemp -d 2>/dev/null || mktemp -d "${TMPDIR:-/tmp}/doit.XXXXXX")" trap 'rm -rf "$tmp_dir"' EXIT INT TERM download() { From dc75542aaf7faa106949d16fe7a8d846915a94f2 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 11:59:27 +0530 Subject: [PATCH 12/18] add product website surface --- website/index.html | 111 +++++++++++++++++ website/styles.css | 295 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 website/index.html create mode 100644 website/styles.css diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..b9213ed --- /dev/null +++ b/website/index.html @@ -0,0 +1,111 @@ + + + + + + Do-It | Local-first task graph + + + + + +
+
+ Do-It task graph running in a browser +
+

Local-first task graph

+

Do-It

+

A quiet Go server for tasks, notes, files, and images that stay synced across your own Wi-Fi devices.

+ +
+
+ +
+
+ Runtime + Single Go binary +
+
+ Sync + Server-Sent Events +
+
+ Storage + Local JSON and uploads +
+
+ +
+
+

Why it exists

+

A personal task map for homelab devices

+
+
+
+

Graph-shaped work

+

Tasks can point to parent tasks, so plans become connected nodes instead of a flat list.

+
+
+

LAN sync

+

Open the same server from a laptop, phone, or tablet and receive live updates in every browser.

+
+
+

Files stay home

+

Attachments are saved on the host device under the app data directory, not pushed to a cloud account.

+
+
+

Device awareness

+

Connected browser sessions report useful status like battery, network type, and online state when available.

+
+
+
+ +
+
+

Terminal download

+

Install from GitHub releases

+
+
+
+

Use the latest release on Linux or macOS:

+
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
+
wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
+
+
+

Run it on your LAN:

+
DOIT_ADDR=0.0.0.0:8080 doit
+

Then open the printed LAN URL from another device on the same Wi-Fi.

+
+
+
+ +
+
+

Good host devices

+

Built for machines you already have

+
+
    +
  • Old Android phone running Termux
  • +
  • Raspberry Pi
  • +
  • Mini PC
  • +
  • NAS or small Linux box
  • +
+
+
+ +
+ Created by blackdragoon26 + GitHub +
+ + diff --git a/website/styles.css b/website/styles.css new file mode 100644 index 0000000..2a84957 --- /dev/null +++ b/website/styles.css @@ -0,0 +1,295 @@ +:root { + color-scheme: light dark; + --bg: Canvas; + --ink: CanvasText; + --muted: color-mix(in srgb, CanvasText 62%, transparent); + --line: color-mix(in srgb, CanvasText 18%, transparent); + --panel: color-mix(in srgb, Canvas 88%, transparent); + --panel-strong: color-mix(in srgb, Canvas 96%, transparent); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +a { + color: inherit; +} + +.site-header { + position: fixed; + z-index: 10; + top: 0; + left: 0; + right: 0; + height: 58px; + padding: 0 clamp(18px, 4vw, 54px); + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + background: color-mix(in srgb, Canvas 78%, transparent); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(18px); +} + +.brand { + font-weight: 720; + text-decoration: none; +} + +nav { + display: flex; + gap: clamp(12px, 3vw, 28px); + font-size: 14px; +} + +nav a, +.site-footer a { + text-decoration: none; + color: var(--muted); +} + +nav a:hover, +.site-footer a:hover { + color: var(--ink); +} + +.hero { + min-height: 92vh; + position: relative; + display: grid; + align-items: end; + padding: 116px clamp(18px, 5vw, 72px) 54px; + overflow: hidden; + border-bottom: 1px solid var(--line); +} + +.hero img { + position: absolute; + inset: 58px 0 auto; + width: 100%; + height: calc(100% - 58px); + object-fit: cover; + object-position: center top; + opacity: 0.42; +} + +.hero::after { + content: ""; + position: absolute; + inset: 58px 0 0; + background: color-mix(in srgb, Canvas 42%, transparent); +} + +.hero-copy { + position: relative; + z-index: 1; + max-width: 780px; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--muted); + font-size: 13px; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + overflow-wrap: anywhere; +} + +h1 { + margin: 0; + font-size: clamp(58px, 12vw, 148px); + line-height: 0.88; + font-weight: 760; +} + +.lede { + max-width: 650px; + margin: 26px 0 0; + font-size: clamp(19px, 2vw, 26px); + line-height: 1.3; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 28px; +} + +.hero-actions a { + min-height: 42px; + padding: 11px 16px; + border: 1px solid var(--line); + border-radius: 6px; + text-decoration: none; +} + +.hero-actions .primary { + background: var(--ink); + color: var(--bg); +} + +.summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid var(--line); +} + +.summary div { + padding: 24px clamp(18px, 4vw, 48px); + border-right: 1px solid var(--line); +} + +.summary div:last-child { + border-right: 0; +} + +.summary span, +.muted { + color: var(--muted); +} + +.summary strong { + display: block; + margin-top: 6px; + font-size: 18px; +} + +.section { + padding: clamp(58px, 8vw, 106px) clamp(18px, 5vw, 72px); + border-bottom: 1px solid var(--line); +} + +.section-heading { + max-width: 720px; + margin-bottom: 30px; +} + +h2 { + margin: 0; + font-size: clamp(34px, 5vw, 72px); + line-height: 1; +} + +.feature-grid, +.install-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: var(--line); + border: 1px solid var(--line); +} + +article, +.install-grid > div { + min-height: 190px; + padding: clamp(20px, 4vw, 34px); + background: var(--panel-strong); +} + +h3 { + margin: 0 0 12px; + font-size: 22px; +} + +article p, +.install-grid p { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +pre { + margin: 16px 0 0; + padding: 14px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; +} + +.split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 420px); + gap: 36px; + align-items: start; +} + +ul { + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--line); +} + +li { + padding: 15px 0; + border-bottom: 1px solid var(--line); + color: var(--muted); +} + +.site-footer { + min-height: 76px; + padding: 0 clamp(18px, 5vw, 72px); + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + color: var(--muted); +} + +@media (max-width: 760px) { + .site-header { + position: sticky; + } + + nav { + gap: 12px; + font-size: 13px; + } + + .hero { + min-height: 88vh; + padding-top: 82px; + } + + .summary, + .feature-grid, + .install-grid, + .split { + grid-template-columns: 1fr; + } + + .summary div { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .summary div:last-child { + border-bottom: 0; + } +} From 86c249c05416650b54f5914e1a8047942a980b95 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 12:13:21 +0530 Subject: [PATCH 13/18] point website install snippet at dev --- website/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/index.html b/website/index.html index b9213ed..4f12d4c 100644 --- a/website/index.html +++ b/website/index.html @@ -78,8 +78,8 @@

Install from GitHub releases

Use the latest release on Linux or macOS:

-
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
-
wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
+
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh
+
wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh

Run it on your LAN:

From 2a5b66b199e6f2d2ced3c5cc75e7fca3dfff5089 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 17:00:32 +0530 Subject: [PATCH 14/18] refine product website surface --- .gitignore | 2 + .vercelignore | 5 + vercel.json | 6 + website/assets/product-preview.svg | 33 +++ website/index.html | 123 ++++------ website/styles.css | 359 +++++++++++++++++------------ 6 files changed, 304 insertions(+), 224 deletions(-) create mode 100644 .vercelignore create mode 100644 vercel.json create mode 100644 website/assets/product-preview.svg diff --git a/.gitignore b/.gitignore index 1b63969..ddb7d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ data/ awesomeProject +.gocache +.vercel diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..5dcc2d5 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,5 @@ +.git +.gocache +.idea +data +awesomeProject diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..02b3c30 --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "framework": null, + "installCommand": null, + "buildCommand": null, + "outputDirectory": "website" +} diff --git a/website/assets/product-preview.svg b/website/assets/product-preview.svg new file mode 100644 index 0000000..ff0c1cf --- /dev/null +++ b/website/assets/product-preview.svg @@ -0,0 +1,33 @@ + + Do-It dark interface preview + A minimal dark task graph interface with a rail, connected nodes, and details panel. + + + + + Do-It + TASK + + LAN + + 192.168.1.22 + DETAILS + Build site + + + + + + + + + + Plan + + Build + + Notes + + Ship + + diff --git a/website/index.html b/website/index.html index 4f12d4c..bfe0071 100644 --- a/website/index.html +++ b/website/index.html @@ -3,109 +3,76 @@ + Do-It | Local-first task graph - +
-
- Do-It task graph running in a browser +

Local-first task graph

-

Do-It

-

A quiet Go server for tasks, notes, files, and images that stay synced across your own Wi-Fi devices.

-
- Install - View source +

Do-It

+

Tasks, notes, files, and nearby devices. Served from your own machine, synced across your own network.

+ +

Latest release checking GitHub

-
-
-
- Runtime - Single Go binary -
-
- Sync - Server-Sent Events -
-
- Storage - Local JSON and uploads -
+
+ Minimal Do-It task graph interface preview +
-
-
-

Why it exists

-

A personal task map for homelab devices

-
-
-
-

Graph-shaped work

-

Tasks can point to parent tasks, so plans become connected nodes instead of a flat list.

-
-
-

LAN sync

-

Open the same server from a laptop, phone, or tablet and receive live updates in every browser.

-
-
-

Files stay home

-

Attachments are saved on the host device under the app data directory, not pushed to a cloud account.

-
-
-

Device awareness

-

Connected browser sessions report useful status like battery, network type, and online state when available.

-
-
+
+

Runtime Single Go binary

+

Sync Live browser updates

+

Storage Local JSON and uploads

-
-
-

Terminal download

-

Install from GitHub releases

-
-
-
-

Use the latest release on Linux or macOS:

-
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh
-
wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/dev/scripts/install.sh | sh
-
-
-

Run it on your LAN:

-
DOIT_ADDR=0.0.0.0:8080 doit
-

Then open the printed LAN URL from another device on the same Wi-Fi.

-
-
-
- -
+
-

Good host devices

-

Built for machines you already have

+

Install

+

Run it anywhere you keep online.

+
+
+
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
+
DOIT_ADDR=0.0.0.0:8080 doit
-
    -
  • Old Android phone running Termux
  • -
  • Raspberry Pi
  • -
  • Mini PC
  • -
  • NAS or small Linux box
  • -
+ diff --git a/website/styles.css b/website/styles.css index 2a84957..d5df8df 100644 --- a/website/styles.css +++ b/website/styles.css @@ -1,11 +1,13 @@ :root { - color-scheme: light dark; - --bg: Canvas; - --ink: CanvasText; - --muted: color-mix(in srgb, CanvasText 62%, transparent); - --line: color-mix(in srgb, CanvasText 18%, transparent); - --panel: color-mix(in srgb, Canvas 88%, transparent); - --panel-strong: color-mix(in srgb, Canvas 96%, transparent); + color-scheme: dark; + --bg: #030303; + --ink: #f5f5f5; + --muted: rgb(245 245 245 / 58%); + --faint: rgb(245 245 245 / 10%); + --line: rgb(245 245 245 / 18%); + --line-strong: rgb(245 245 245 / 36%); + --panel: rgb(255 255 255 / 5%); + --panel-strong: rgb(255 255 255 / 8%); } * { @@ -18,12 +20,27 @@ html { body { margin: 0; - background: var(--bg); + min-height: 100vh; + background: + radial-gradient(circle at 50% 0, rgb(255 255 255 / 8%), transparent 34rem), + var(--bg); color: var(--ink); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; } +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(var(--faint) 1px, transparent 1px), + linear-gradient(90deg, var(--faint) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: linear-gradient(to bottom, black, transparent 78%); +} + a { color: inherit; } @@ -34,262 +51,312 @@ a { top: 0; left: 0; right: 0; - height: 58px; - padding: 0 clamp(18px, 4vw, 54px); + height: 62px; + padding: 0 clamp(18px, 5vw, 72px); display: flex; align-items: center; justify-content: space-between; - gap: 20px; - background: color-mix(in srgb, Canvas 78%, transparent); + gap: 24px; border-bottom: 1px solid var(--line); + background: rgb(3 3 3 / 82%); backdrop-filter: blur(18px); } -.brand { - font-weight: 720; +.brand, +nav a, +.site-footer a { text-decoration: none; } +.brand { + font-size: 16px; + font-weight: 680; +} + nav { display: flex; - gap: clamp(12px, 3vw, 28px); + gap: 24px; + color: var(--muted); font-size: 14px; } nav a, -.site-footer a { - text-decoration: none; - color: var(--muted); +.site-footer a, +.button { + transition: color 160ms ease, border-color 160ms ease, background 160ms ease, transform 160ms ease; } nav a:hover, -.site-footer a:hover { +.site-footer a:hover, +.release-note a:hover { color: var(--ink); } -.hero { - min-height: 92vh; +main { position: relative; - display: grid; - align-items: end; - padding: 116px clamp(18px, 5vw, 72px) 54px; - overflow: hidden; - border-bottom: 1px solid var(--line); -} - -.hero img { - position: absolute; - inset: 58px 0 auto; - width: 100%; - height: calc(100% - 58px); - object-fit: cover; - object-position: center top; - opacity: 0.42; + padding-top: 62px; } -.hero::after { - content: ""; - position: absolute; - inset: 58px 0 0; - background: color-mix(in srgb, Canvas 42%, transparent); +.hero { + min-height: calc(100vh - 62px); + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(320px, 0.75fr); + gap: clamp(34px, 7vw, 96px); + align-items: center; + padding: clamp(54px, 8vw, 96px) clamp(18px, 5vw, 72px); } .hero-copy { - position: relative; - z-index: 1; - max-width: 780px; + max-width: 760px; } .eyebrow { - margin: 0 0 12px; + margin: 0 0 18px; color: var(--muted); - font-size: 13px; + font-size: 12px; text-transform: uppercase; } h1, h2, -h3, p { overflow-wrap: anywhere; } h1 { margin: 0; - font-size: clamp(58px, 12vw, 148px); - line-height: 0.88; - font-weight: 760; + font-size: clamp(78px, 17vw, 220px); + line-height: 0.84; + font-weight: 680; } .lede { max-width: 650px; - margin: 26px 0 0; - font-size: clamp(19px, 2vw, 26px); - line-height: 1.3; + margin: 30px 0 0; + color: rgb(245 245 245 / 76%); + font-size: clamp(22px, 3vw, 38px); + line-height: 1.16; } .hero-actions { display: flex; flex-wrap: wrap; - gap: 12px; - margin-top: 28px; + gap: 10px; + margin-top: 34px; } -.hero-actions a { +.release-note { + margin: 16px 0 0; + color: var(--muted); + font-size: 14px; +} + +.release-note a { + color: var(--ink); + text-decoration: none; + transition: color 160ms ease; +} + +.button { min-height: 42px; - padding: 11px 16px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 15px; border: 1px solid var(--line); border-radius: 6px; + background: transparent; + color: var(--ink); + font-weight: 620; text-decoration: none; } -.hero-actions .primary { +.button:hover { + border-color: var(--line-strong); + transform: translateY(-1px); +} + +.button.primary { background: var(--ink); color: var(--bg); } -.summary { - display: grid; - grid-template-columns: repeat(3, 1fr); - border-bottom: 1px solid var(--line); +.product-frame { + margin: 0; + opacity: 0.9; + transform: translateY(0); + animation: settle 700ms ease both; } -.summary div { - padding: 24px clamp(18px, 4vw, 48px); - border-right: 1px solid var(--line); +.product-frame img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 40px 120px rgb(0 0 0 / 52%); } -.summary div:last-child { - border-right: 0; +.facts, +.install-section { + scroll-margin-top: 84px; + border-top: 1px solid var(--line); + padding: clamp(34px, 5vw, 62px) clamp(18px, 5vw, 72px); } -.summary span, -.muted { - color: var(--muted); +.facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--line); + padding: 1px; + margin: 0 clamp(18px, 5vw, 72px) clamp(34px, 5vw, 62px); + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; } -.summary strong { - display: block; - margin-top: 6px; - font-size: 18px; +.facts p { + margin: 0; + padding: 20px; + background: rgb(3 3 3 / 86%); + color: var(--ink); } -.section { - padding: clamp(58px, 8vw, 106px) clamp(18px, 5vw, 72px); - border-bottom: 1px solid var(--line); +.facts span { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; } -.section-heading { - max-width: 720px; - margin-bottom: 30px; +.install-section { + display: grid; + grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr); + gap: clamp(24px, 6vw, 72px); + align-items: start; } h2 { + max-width: 520px; margin: 0; - font-size: clamp(34px, 5vw, 72px); - line-height: 1; + font-size: clamp(34px, 6vw, 82px); + line-height: 0.96; + font-weight: 620; } -.feature-grid, -.install-grid { +.commands { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1px; - background: var(--line); - border: 1px solid var(--line); -} - -article, -.install-grid > div { - min-height: 190px; - padding: clamp(20px, 4vw, 34px); - background: var(--panel-strong); -} - -h3 { - margin: 0 0 12px; - font-size: 22px; -} - -article p, -.install-grid p { - margin: 0; - color: var(--muted); - line-height: 1.55; + gap: 10px; } pre { - margin: 16px 0 0; - padding: 14px; + margin: 0; + padding: 16px; overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; border: 1px solid var(--line); border-radius: 6px; - background: var(--panel); + background: var(--panel-strong); } code { + color: rgb(245 245 245 / 84%); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; -} - -.split { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(260px, 420px); - gap: 36px; - align-items: start; -} - -ul { - margin: 0; - padding: 0; - list-style: none; - border-top: 1px solid var(--line); -} - -li { - padding: 15px 0; - border-bottom: 1px solid var(--line); - color: var(--muted); + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; } .site-footer { - min-height: 76px; + min-height: 74px; padding: 0 clamp(18px, 5vw, 72px); display: flex; align-items: center; justify-content: space-between; gap: 18px; + border-top: 1px solid var(--line); color: var(--muted); + font-size: 14px; } -@media (max-width: 760px) { +@keyframes settle { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 0.9; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +@media (max-width: 840px) { .site-header { position: sticky; } - nav { - gap: 12px; - font-size: 13px; + main { + padding-top: 0; + } + + .hero, + .install-section { + grid-template-columns: 1fr; } .hero { - min-height: 88vh; - padding-top: 82px; + min-height: auto; + } + + .product-frame { + max-width: 560px; } - .summary, - .feature-grid, - .install-grid, - .split { + .facts { grid-template-columns: 1fr; } +} + +@media (max-width: 560px) { + .site-header { + height: 58px; + } + + nav { + gap: 16px; + } + + h1 { + font-size: 76px; + } + + .lede { + font-size: 23px; + } - .summary div { - border-right: 0; - border-bottom: 1px solid var(--line); + .button { + width: 100%; } - .summary div:last-child { - border-bottom: 0; + .site-footer { + min-height: 92px; + flex-direction: column; + align-items: flex-start; + justify-content: center; } } From 97ab6949b27845e90f4ae8578c0425e2fc8fdb68 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 18:15:38 +0530 Subject: [PATCH 15/18] polish website install surface --- website/index.html | 35 +++++++++++++++++++++++++----- website/styles.css | 53 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/website/index.html b/website/index.html index bfe0071..484552d 100644 --- a/website/index.html +++ b/website/index.html @@ -5,7 +5,7 @@ Do-It | Local-first task graph - +
-

Latest release checking GitHub

+

Latest release latest on GitHub

@@ -44,16 +44,23 @@

Do-It

Install

Run it anywhere you keep online.

+

The installer auto-detects Linux/macOS and amd64/arm64 for the device running it. For a different target, use the manual release assets documented in the GitHub repo.

-
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
-
DOIT_ADDR=0.0.0.0:8080 doit
+
+
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
+ +
+
+
DOIT_ADDR=0.0.0.0:8080 doit
+ +
diff --git a/website/styles.css b/website/styles.css index d5df8df..dbfee94 100644 --- a/website/styles.css +++ b/website/styles.css @@ -82,7 +82,8 @@ nav { nav a, .site-footer a, -.button { +.button, +.command button { transition: color 160ms ease, border-color 160ms ease, background 160ms ease, transform 160ms ease; } @@ -239,6 +240,14 @@ h1 { align-items: start; } +.install-note { + max-width: 520px; + margin: 20px 0 0; + color: var(--muted); + font-size: 15px; + line-height: 1.55; +} + h2 { max-width: 520px; margin: 0; @@ -252,15 +261,23 @@ h2 { gap: 10px; } +.command { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-strong); + overflow: hidden; +} + pre { margin: 0; padding: 16px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel-strong); + background: transparent; } code { @@ -272,6 +289,24 @@ code { overflow-wrap: anywhere; } +.command button { + min-width: 68px; + border: 0; + border-left: 1px solid var(--line); + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: 13px; +} + +.command button:hover, +.command button:focus-visible { + color: var(--ink); + outline: none; + background: var(--panel); +} + .site-footer { min-height: 74px; padding: 0 clamp(18px, 5vw, 72px); @@ -353,6 +388,16 @@ code { width: 100%; } + .command { + grid-template-columns: 1fr; + } + + .command button { + min-height: 38px; + border-left: 0; + border-top: 1px solid var(--line); + } + .site-footer { min-height: 92px; flex-direction: column; From 7fb5a60fcba21e9c45b6435131c232606a929e3c Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 18:25:22 +0530 Subject: [PATCH 16/18] pin vercel deployments to main --- vercel.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index 02b3c30..e57de4d 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,12 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", "framework": null, "installCommand": null, "buildCommand": null, - "outputDirectory": "website" + "outputDirectory": "website", + "git": { + "deploymentEnabled": { + "dev": false + } + } } From 87e118158395b8f0adcf90c0c750f321a20ce8c0 Mon Sep 17 00:00:00 2001 From: blackdragoon26 Date: Tue, 7 Jul 2026 19:15:42 +0530 Subject: [PATCH 17/18] address final pr review feedback --- .github/workflows/ci.yml | 5 +---- .goreleaser.yaml | 5 ++++- scripts/install.sh | 27 ++++++++++++++++++++++++ server.go | 30 ++++++++++++++++++--------- server_test.go | 11 +++++++++- store.go | 15 ++++++-------- website/index.html | 44 ++++++++++++++++++---------------------- 7 files changed, 88 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa5df99..35d1a92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,4 @@ jobs: run: go build -v ./... - name: Test - run: go test -v ./... - - - name: Race test - run: go test -race ./... + run: go test -race -v ./... diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a305cf6..aeced49 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,9 +20,12 @@ archives: format: zip name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" +checksum: + name_template: "checksums.txt" + changelog: sort: asc filters: exclude: - "^docs:" - - "^test:" \ No newline at end of file + - "^test:" diff --git a/scripts/install.sh b/scripts/install.sh index 69bd7c0..78fc118 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -44,7 +44,34 @@ download() { exit 1 } +verify_checksum() { + archive_path="$1" + checksums_path="$2" + archive_name="$(basename "$archive_path")" + expected="$(awk -v name="$archive_name" '$2 == name { print $1; found = 1 } END { if (!found) exit 1 }' "$checksums_path")" || { + echo "checksum for $archive_name not found" >&2 + exit 1 + } + + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$expected" "$archive_path" | sha256sum -c - + return + fi + if command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" + if [ "$actual" = "$expected" ]; then + return + fi + echo "$archive_name checksum mismatch" >&2 + exit 1 + fi + echo "sha256sum or shasum is required to verify downloads" >&2 + exit 1 +} + download "$base_url/$archive" "$tmp_dir/$archive" +download "$base_url/checksums.txt" "$tmp_dir/checksums.txt" +verify_checksum "$tmp_dir/$archive" "$tmp_dir/checksums.txt" tar -xzf "$tmp_dir/$archive" -C "$tmp_dir" mkdir -p "$install_dir" diff --git a/server.go b/server.go index a579dfd..fb526db 100644 --- a/server.go +++ b/server.go @@ -27,6 +27,7 @@ const ( maxFilesPerTask = 8 maxMutationsPerMinute = 60 maxRateLimitClients = 4096 + rateLimitPruneEvery = 15 * time.Second csrfCookieName = "doit_csrf" csrfHeaderName = "X-CSRF-Token" ) @@ -63,10 +64,11 @@ type serverEvent struct { } type rateLimiter struct { - mu sync.Mutex - window time.Duration - limit int - clients map[string]rateWindow + mu sync.Mutex + window time.Duration + limit int + lastPrune time.Time + clients map[string]rateWindow } type rateWindow struct { @@ -665,7 +667,7 @@ func withCSRFCookie(next http.Handler) http.Handler { func requiresCSRF(r *http.Request) bool { switch r.Method { - case http.MethodPost, http.MethodPatch, http.MethodDelete: + case http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodPut: return strings.HasPrefix(r.URL.Path, "/api/") default: return false @@ -752,11 +754,7 @@ func (l *rateLimiter) Allow(key string) bool { l.mu.Lock() defer l.mu.Unlock() - for client, window := range l.clients { - if now.Sub(window.start) >= l.window { - delete(l.clients, client) - } - } + l.pruneExpiredLocked(now) window := l.clients[key] if window.start.IsZero() || now.Sub(window.start) >= l.window { @@ -774,6 +772,18 @@ func (l *rateLimiter) Allow(key string) bool { return true } +func (l *rateLimiter) pruneExpiredLocked(now time.Time) { + if !l.lastPrune.IsZero() && now.Sub(l.lastPrune) < rateLimitPruneEvery { + return + } + l.lastPrune = now + for client, window := range l.clients { + if now.Sub(window.start) >= l.window { + delete(l.clients, client) + } + } +} + func sanitizeFilename(name string) string { name = filepath.Base(name) name = strings.TrimSpace(name) diff --git a/server_test.go b/server_test.go index e71bbfd..787f40e 100644 --- a/server_test.go +++ b/server_test.go @@ -143,7 +143,7 @@ func TestCreateTaskRejectsMissingCSRFToken(t *testing.T) { } } -func TestUnsupportedAPIMethodReturnsMethodNotAllowedWithoutCSRF(t *testing.T) { +func TestUnsupportedAPIMethodRequiresCSRFBeforeMethodCheck(t *testing.T) { dataDir := t.TempDir() store, err := NewStore(filepath.Join(dataDir, "state.json")) if err != nil { @@ -155,6 +155,15 @@ func TestUnsupportedAPIMethodReturnsMethodNotAllowedWithoutCSRF(t *testing.T) { response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", response.Code, response.Body.String()) + } + + request = httptest.NewRequest(http.MethodPut, "/api/tasks", nil) + addCSRF(request) + response = httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + if response.Code != http.StatusMethodNotAllowed { t.Fatalf("expected status 405, got %d: %s", response.Code, response.Body.String()) } diff --git a/store.go b/store.go index e018caa..1ab0bd0 100644 --- a/store.go +++ b/store.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "time" + "unicode/utf16" ) var ( @@ -178,15 +179,15 @@ func (s *Store) PatchTask(id string, patch TaskPatch) (Snapshot, Task, error) { if title == "" { return Snapshot{}, Task{}, fmt.Errorf("%w: title is required", errBadInput) } - if tooLong(title, maxTitleLength) { - return Snapshot{}, Task{}, fmt.Errorf("%w: title must be at most %d characters", errBadInput, maxTitleLength) + if err := validateTaskText(title, task.Notes); err != nil { + return Snapshot{}, Task{}, err } task.Title = title } if patch.Notes != nil { notes := strings.TrimSpace(*patch.Notes) - if tooLong(notes, maxNotesLength) { - return Snapshot{}, Task{}, fmt.Errorf("%w: notes must be at most %d characters", errBadInput, maxNotesLength) + if err := validateTaskText(task.Title, notes); err != nil { + return Snapshot{}, Task{}, err } task.Notes = notes } @@ -383,11 +384,7 @@ func validateTaskText(title, notes string) error { func tooLong(value string, max int) bool { units := 0 for _, r := range value { - if r > 0xFFFF { - units += 2 - } else { - units++ - } + units += utf16.RuneLen(r) if units > max { return true } diff --git a/website/index.html b/website/index.html index 484552d..faa3742 100644 --- a/website/index.html +++ b/website/index.html @@ -26,7 +26,7 @@

Do-It

Install Source -

Latest release latest on GitHub

+

Latest release v1.0.0

@@ -64,37 +64,33 @@

Run it anywhere you keep online.

github.com/blackdragoon26/Do-It