diff --git a/backend/controllers/get_tasks.go b/backend/controllers/get_tasks.go index b6004286..07051799 100644 --- a/backend/controllers/get_tasks.go +++ b/backend/controllers/get_tasks.go @@ -1,6 +1,8 @@ package controllers import ( + "ccsync_backend/models" + "ccsync_backend/utils" "ccsync_backend/utils/tw" "encoding/json" "net/http" @@ -35,10 +37,14 @@ func TasksHandler(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { tasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID) - if err != nil || tasks == nil { + if err != nil { + utils.Logger.Errorf("Failed to fetch tasks: %v", err) http.Error(w, "Failed to fetch tasks at backend", http.StatusInternalServerError) return } + if tasks == nil { + tasks = []models.Task{} + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tasks) diff --git a/backend/utils/exec_command.go b/backend/utils/exec_command.go index d1473e10..ef5c3ccd 100644 --- a/backend/utils/exec_command.go +++ b/backend/utils/exec_command.go @@ -1,6 +1,11 @@ package utils -import "os/exec" +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) func ExecCommandInDir(dir, command string, args ...string) error { cmd := exec.Command(command, args...) @@ -18,3 +23,35 @@ func ExecCommandForOutputInDir(dir, command string, args ...string) ([]byte, err cmd.Dir = dir return cmd.Output() } + +// TaskwarriorEnv returns a copy of the process environment with TASKDATA and +// TASKRC pointing at tempDir so Taskwarrior does not use /root/.task. +func TaskwarriorEnv(tempDir string) []string { + env := os.Environ() + filtered := make([]string, 0, len(env)+2) + for _, e := range env { + if strings.HasPrefix(e, "TASKDATA=") || strings.HasPrefix(e, "TASKRC=") { + continue + } + filtered = append(filtered, e) + } + filtered = append(filtered, + "TASKDATA="+tempDir, + "TASKRC="+filepath.Join(tempDir, "taskrc"), + ) + return filtered +} + +func ExecTaskInDir(dir string, args ...string) error { + cmd := exec.Command("task", args...) + cmd.Dir = dir + cmd.Env = TaskwarriorEnv(dir) + return cmd.Run() +} + +func ExecTaskOutputInDir(dir string, args ...string) ([]byte, error) { + cmd := exec.Command("task", args...) + cmd.Dir = dir + cmd.Env = TaskwarriorEnv(dir) + return cmd.Output() +} diff --git a/backend/utils/tw/add_task.go b/backend/utils/tw/add_task.go index ce65c15f..47253743 100644 --- a/backend/utils/tw/add_task.go +++ b/backend/utils/tw/add_task.go @@ -9,9 +9,8 @@ import ( ) func AddTaskToTaskwarrior(req models.AddTaskRequestBody, dueDate string) error { - if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { - return fmt.Errorf("error deleting Taskwarrior data: %v", err) - } + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", req.Email)) if err != nil { @@ -75,13 +74,13 @@ func AddTaskToTaskwarrior(req models.AddTaskRequestBody, dueDate string) error { } } - if err := utils.ExecCommandInDir(tempDir, "task", cmdArgs...); err != nil { + if err := utils.ExecTaskInDir(tempDir, cmdArgs...); err != nil { return fmt.Errorf("failed to add task: %v\n %v", err, cmdArgs) } var taskID string if req.End != "" || len(req.Annotations) > 0 { - output, err := utils.ExecCommandForOutputInDir(tempDir, "task", "+LATEST", "_ids") + output, err := utils.ExecTaskOutputInDir(tempDir, "+LATEST", "_ids") if err != nil { return fmt.Errorf("failed to get latest task Id: %v", err) } @@ -95,7 +94,7 @@ func AddTaskToTaskwarrior(req models.AddTaskRequestBody, dueDate string) error { return fmt.Errorf("unexpected end date format error: %v", err) } doneArgs := []string{"rc.confirmation=off", taskID, "done", "end:" + end} - if err := utils.ExecCommandInDir(tempDir, "task", doneArgs...); err != nil { + if err := utils.ExecTaskInDir(tempDir, doneArgs...); err != nil { return fmt.Errorf("failed to complete task with end date: %v", err) } } @@ -104,7 +103,7 @@ func AddTaskToTaskwarrior(req models.AddTaskRequestBody, dueDate string) error { for _, annotation := range req.Annotations { if annotation.Description != "" { annotateArgs := []string{"rc.confirmation=off", taskID, "annotate", annotation.Description} - if err := utils.ExecCommandInDir(tempDir, "task", annotateArgs...); err != nil { + if err := utils.ExecTaskInDir(tempDir, annotateArgs...); err != nil { return fmt.Errorf("failed to add annotation to task %s: %v", taskID, err) } } diff --git a/backend/utils/tw/complete_task.go b/backend/utils/tw/complete_task.go index 1195a941..fee3e219 100644 --- a/backend/utils/tw/complete_task.go +++ b/backend/utils/tw/complete_task.go @@ -7,9 +7,9 @@ import ( ) func CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) error { - if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { - return fmt.Errorf("error deleting Taskwarrior data: %v", err) - } + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() + tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", email)) if err != nil { return fmt.Errorf("failed to create temporary directory: %v", err) @@ -25,7 +25,7 @@ func CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) e return err } - if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "done", "rc.confirmation=off"); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskuuid, "done", "rc.confirmation=off"); err != nil { return fmt.Errorf("failed to mark task as done: %v", err) } diff --git a/backend/utils/tw/complete_tasks.go b/backend/utils/tw/complete_tasks.go index c97f0561..df644a0b 100644 --- a/backend/utils/tw/complete_tasks.go +++ b/backend/utils/tw/complete_tasks.go @@ -9,9 +9,8 @@ import ( func CompleteTasksInTaskwarrior(email, encryptionSecret, uuid string, taskUUIDs []string) (map[string]string, error) { failedTasks := make(map[string]string) - if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { - return nil, fmt.Errorf("error deleting Taskwarrior data: %v", err) - } + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", email)) @@ -30,7 +29,7 @@ func CompleteTasksInTaskwarrior(email, encryptionSecret, uuid string, taskUUIDs } for _, taskuuid := range taskUUIDs { - if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "done", "rc.confirmation=off"); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskuuid, "done", "rc.confirmation=off"); err != nil { failedTasks[taskuuid] = err.Error() continue } diff --git a/backend/utils/tw/delete_task.go b/backend/utils/tw/delete_task.go index 46c4fa2c..fa487a10 100644 --- a/backend/utils/tw/delete_task.go +++ b/backend/utils/tw/delete_task.go @@ -7,9 +7,9 @@ import ( ) func DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) error { - if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { - return fmt.Errorf("error deleting Taskwarrior data: %v", err) - } + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() + tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", email)) if err != nil { return fmt.Errorf("failed to create temporary directory: %v", err) @@ -25,7 +25,7 @@ func DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) err return err } - if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "delete", "rc.confirmation=off"); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskuuid, "delete", "rc.confirmation=off"); err != nil { return fmt.Errorf("failed to mark task as deleted: %v", err) } diff --git a/backend/utils/tw/delete_tasks.go b/backend/utils/tw/delete_tasks.go index a746abea..6aef9c11 100644 --- a/backend/utils/tw/delete_tasks.go +++ b/backend/utils/tw/delete_tasks.go @@ -9,9 +9,8 @@ import ( func DeleteTasksInTaskwarrior(email, encryptionSecret, uuid string, taskUUIDs []string) (map[string]string, error) { failedTasks := make(map[string]string) - if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { - return nil, fmt.Errorf("error deleting Taskwarrior data: %v", err) - } + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", email)) @@ -30,7 +29,7 @@ func DeleteTasksInTaskwarrior(email, encryptionSecret, uuid string, taskUUIDs [] } for _, taskuuid := range taskUUIDs { - if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "delete", "rc.confirmation=off"); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskuuid, "delete", "rc.confirmation=off"); err != nil { failedTasks[taskuuid] = err.Error() continue } diff --git a/backend/utils/tw/edit_task.go b/backend/utils/tw/edit_task.go index b944e418..10b8ff54 100644 --- a/backend/utils/tw/edit_task.go +++ b/backend/utils/tw/edit_task.go @@ -14,6 +14,9 @@ func EditTaskInTaskwarrior( tags, depends []string, annotations []models.Annotation, ) error { + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() + tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", email)) if err != nil { return fmt.Errorf("failed to create temporary directory: %v", err) @@ -84,12 +87,12 @@ func EditTaskInTaskwarrior( } } - if err := utils.ExecCommand("task", modifyArgs...); err != nil { + if err := utils.ExecTaskInDir(tempDir, modifyArgs...); err != nil { return fmt.Errorf("failed to edit task: %v", err) } if len(annotations) > 0 { - output, err := utils.ExecCommandForOutputInDir(tempDir, "task", taskUUID, "export") + output, err := utils.ExecTaskOutputInDir(tempDir, taskUUID, "export") if err == nil { var tasks []map[string]interface{} if err := json.Unmarshal(output, &tasks); err == nil && len(tasks) > 0 { @@ -97,7 +100,7 @@ func EditTaskInTaskwarrior( for _, ann := range existingAnnotations { if annMap, ok := ann.(map[string]interface{}); ok { if desc, ok := annMap["description"].(string); ok { - utils.ExecCommand("task", taskUUID, "denotate", desc) + utils.ExecTaskInDir(tempDir, taskUUID, "denotate", desc) } } } @@ -107,7 +110,7 @@ func EditTaskInTaskwarrior( for _, annotation := range annotations { if annotation.Description != "" { - if err := utils.ExecCommand("task", taskUUID, "annotate", annotation.Description); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskUUID, "annotate", annotation.Description); err != nil { return fmt.Errorf("failed to add annotation %s: %v", annotation.Description, err) } } diff --git a/backend/utils/tw/export_tasks.go b/backend/utils/tw/export_tasks.go index 5690ddd4..03ad758a 100644 --- a/backend/utils/tw/export_tasks.go +++ b/backend/utils/tw/export_tasks.go @@ -9,7 +9,7 @@ import ( // export the tasks so as to add them to DB func ExportTasks(tempDir string) ([]models.Task, error) { - output, err := utils.ExecCommandForOutputInDir(tempDir, "task", "export") + output, err := utils.ExecTaskOutputInDir(tempDir, "export") if err != nil { return nil, fmt.Errorf("error executing Taskwarrior export command: %v", err) } diff --git a/backend/utils/tw/fetch_tasks.go b/backend/utils/tw/fetch_tasks.go index 67d2d137..b7730557 100644 --- a/backend/utils/tw/fetch_tasks.go +++ b/backend/utils/tw/fetch_tasks.go @@ -7,12 +7,9 @@ import ( "os" ) -// complete logic (delete config if any->setup config->sync->get tasks->export) func FetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID string) ([]models.Task, error) { - // temporary directory for each user - if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { - return nil, fmt.Errorf("error deleting Taskwarrior data: %v", err) - } + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", email)) if err != nil { diff --git a/backend/utils/tw/isolation.go b/backend/utils/tw/isolation.go new file mode 100644 index 00000000..13920676 --- /dev/null +++ b/backend/utils/tw/isolation.go @@ -0,0 +1,7 @@ +package tw + +import "sync" + +// taskwarriorMu serializes all Taskwarrior CLI use so GET /tasks cannot +// overlap mutation jobs (and so concurrent HTTP fetches do not share a home). +var taskwarriorMu sync.Mutex diff --git a/backend/utils/tw/modify_task.go b/backend/utils/tw/modify_task.go index 0172de4c..4d3406c9 100644 --- a/backend/utils/tw/modify_task.go +++ b/backend/utils/tw/modify_task.go @@ -8,9 +8,9 @@ import ( ) func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID string, tags []string, depends []string) error { - if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { - return fmt.Errorf("error deleting Taskwarrior data: %v", err) - } + taskwarriorMu.Lock() + defer taskwarriorMu.Unlock() + tempDir, err := os.MkdirTemp("", utils.SafeTempDirPrefix("taskwarrior-", email)) if err != nil { return fmt.Errorf("failed to create temporary directory: %v", err) @@ -28,41 +28,41 @@ func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, escapedDescription := fmt.Sprintf(`description:"%s"`, strings.ReplaceAll(description, `"`, `\"`)) - if err := utils.ExecCommand("task", taskID, "modify", escapedDescription); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", escapedDescription); err != nil { return fmt.Errorf("failed to edit task: %v", err) } escapedProject := fmt.Sprintf(`project:%s`, strings.ReplaceAll(project, `"`, `\"`)) - if err := utils.ExecCommand("task", taskID, "modify", escapedProject); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", escapedProject); err != nil { return fmt.Errorf("failed to edit task project: %v", err) } escapedPriority := fmt.Sprintf(`priority:%s`, strings.ReplaceAll(priority, `"`, `\"`)) - if err := utils.ExecCommand("task", taskID, "modify", escapedPriority); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", escapedPriority); err != nil { return fmt.Errorf("failed to edit task priority: %v", err) } escapedDue := fmt.Sprintf(`due:%s`, strings.ReplaceAll(due, `"`, `\"`)) - if err := utils.ExecCommand("task", taskID, "modify", escapedDue); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", escapedDue); err != nil { return fmt.Errorf("failed to edit task due: %v", err) } // Handle dependencies - always set to ensure clearing works - if err := utils.ExecCommand("task", taskID, "modify", "depends:"); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", "depends:"); err != nil { return fmt.Errorf("failed to clear dependencies: %v", err) } if len(depends) > 0 { dependsStr := strings.Join(depends, ",") - if err := utils.ExecCommand("task", taskID, "modify", "depends:"+dependsStr); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", "depends:"+dependsStr); err != nil { return fmt.Errorf("failed to set dependencies %s: %v", dependsStr, err) } } // escapedStatus := fmt.Sprintf(`status:%s`, strings.ReplaceAll(status, `"`, `\"`)) if status == "completed" { - utils.ExecCommand("task", taskID, "done", "rc.confirmation=off") + utils.ExecTaskInDir(tempDir, taskID, "done", "rc.confirmation=off") } else if status == "deleted" { - utils.ExecCommand("task", taskID, "delete", "rc.confirmation=off") + utils.ExecTaskInDir(tempDir, taskID, "delete", "rc.confirmation=off") } // Handle tags @@ -71,18 +71,18 @@ func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, if strings.HasPrefix(tag, "+") { // Add tag tagValue := strings.TrimPrefix(tag, "+") - if err := utils.ExecCommand("task", taskID, "modify", "+"+tagValue); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", "+"+tagValue); err != nil { return fmt.Errorf("failed to add tag %s: %v", tagValue, err) } } else if strings.HasPrefix(tag, "-") { // Remove tag tagValue := strings.TrimPrefix(tag, "-") - if err := utils.ExecCommand("task", taskID, "modify", "-"+tagValue); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", "-"+tagValue); err != nil { return fmt.Errorf("failed to remove tag %s: %v", tagValue, err) } } else { // Add tag without prefix - if err := utils.ExecCommand("task", taskID, "modify", "+"+tag); err != nil { + if err := utils.ExecTaskInDir(tempDir, taskID, "modify", "+"+tag); err != nil { return fmt.Errorf("failed to add tag %s: %v", tag, err) } } diff --git a/backend/utils/tw/set_config.go b/backend/utils/tw/set_config.go index 5b9926db..0ab7c431 100644 --- a/backend/utils/tw/set_config.go +++ b/backend/utils/tw/set_config.go @@ -14,7 +14,7 @@ func SetTaskwarriorConfig(tempDir, encryptionSecret, origin, UUID string) error } for _, args := range configCmds { - if err := utils.ExecCommandInDir(tempDir, args[0], args[1:]...); err != nil { + if err := utils.ExecTaskInDir(tempDir, args[1:]...); err != nil { return fmt.Errorf("error setting Taskwarrior config (%v)", err) } } diff --git a/backend/utils/tw/sync_tasks.go b/backend/utils/tw/sync_tasks.go index 79f77726..72f94546 100644 --- a/backend/utils/tw/sync_tasks.go +++ b/backend/utils/tw/sync_tasks.go @@ -7,7 +7,7 @@ import ( // sync the user's tasks to all of their TW clients func SyncTaskwarrior(tempDir string) error { - if err := utils.ExecCommandInDir(tempDir, "task", "sync"); err != nil { + if err := utils.ExecTaskInDir(tempDir, "sync"); err != nil { return fmt.Errorf("error syncing Taskwarrior: %v", err) } return nil diff --git a/backend/utils/utils_test.go b/backend/utils/utils_test.go index 39bb0e14..632ea224 100644 --- a/backend/utils/utils_test.go +++ b/backend/utils/utils_test.go @@ -4,6 +4,8 @@ import ( "crypto/sha256" "encoding/hex" "os" + "path/filepath" + "strings" "testing" "github.com/google/uuid" @@ -85,6 +87,33 @@ func Test_ExecCommandForOutputInDir(t *testing.T) { } } +func Test_TaskwarriorEnv(t *testing.T) { + t.Setenv("TASKDATA", "/root/.task") + t.Setenv("TASKRC", "/root/.taskrc") + + tempDir := t.TempDir() + env := TaskwarriorEnv(tempDir) + + var data, rc string + for _, e := range env { + switch { + case strings.HasPrefix(e, "TASKDATA="): + if data != "" { + t.Fatal("duplicate TASKDATA") + } + data = strings.TrimPrefix(e, "TASKDATA=") + case strings.HasPrefix(e, "TASKRC="): + if rc != "" { + t.Fatal("duplicate TASKRC") + } + rc = strings.TrimPrefix(e, "TASKRC=") + } + } + + assert.Equal(t, tempDir, data) + assert.Equal(t, filepath.Join(tempDir, "taskrc"), rc) +} + func Test_ValidateDependencies_EmptyList(t *testing.T) { depends := []string{} currentTaskUUID := "current-task-uuid"