From ba0cfc08e3ca60df034dd37a922e057f060d3db9 Mon Sep 17 00:00:00 2001 From: Joakim Date: Mon, 5 Jan 2026 16:14:49 +0100 Subject: [PATCH] feat: Phase 1 - Core API server with authentication - Added database schema for users, api_keys, sync_state, change_log, and sync_config - Implemented API key generation and validation with bcrypt hashing - Created Chi-based REST API server with endpoints for: - Task CRUD operations (create, read, update, delete) - Task actions (complete, start, stop) - Tag management (list, add, remove) - Projects listing - Health check endpoint - Added middleware for authentication and CORS - Implemented change log tracking with triggers (key:value format) - Added configurable change log retention (default 30 days) - Created server CLI commands (opal server start, opal server keygen) - Dependencies added: golang.org/x/crypto/bcrypt, github.com/go-chi/chi/v5 --- opal-task/cmd/root.go | 2 +- opal-task/cmd/server.go | 115 ++++++ opal-task/go.mod | 6 +- opal-task/go.sum | 12 +- opal-task/internal/api/handlers/auth.go | 41 ++ opal-task/internal/api/handlers/projects.go | 18 + opal-task/internal/api/handlers/sync.go | 167 ++++++++ opal-task/internal/api/handlers/tags.go | 18 + opal-task/internal/api/handlers/tasks.go | 435 ++++++++++++++++++++ opal-task/internal/api/middleware.go | 79 ++++ opal-task/internal/api/response.go | 39 ++ opal-task/internal/api/server.go | 81 ++++ opal-task/internal/engine/auth.go | 156 +++++++ opal-task/internal/engine/database.go | 194 +++++++++ opal-task/internal/engine/tags.go | 55 +++ opal-task/srv/README.md | 12 + 16 files changed, 1423 insertions(+), 7 deletions(-) create mode 100644 opal-task/cmd/server.go create mode 100644 opal-task/internal/api/handlers/auth.go create mode 100644 opal-task/internal/api/handlers/projects.go create mode 100644 opal-task/internal/api/handlers/sync.go create mode 100644 opal-task/internal/api/handlers/tags.go create mode 100644 opal-task/internal/api/handlers/tasks.go create mode 100644 opal-task/internal/api/middleware.go create mode 100644 opal-task/internal/api/response.go create mode 100644 opal-task/internal/api/server.go create mode 100644 opal-task/internal/engine/auth.go create mode 100644 opal-task/internal/engine/tags.go create mode 100644 opal-task/srv/README.md diff --git a/opal-task/cmd/root.go b/opal-task/cmd/root.go index 532d36d..a891ddc 100644 --- a/opal-task/cmd/root.go +++ b/opal-task/cmd/root.go @@ -25,7 +25,7 @@ const parsedArgsKey contextKey = "parsedArgs" var commandNames = []string{ "add", "list", "done", "modify", "delete", "start", "stop", "count", "projects", "tags", - "info", "edit", + "info", "edit", "server", } var commandsWithModifiers = map[string]bool{ diff --git a/opal-task/cmd/server.go b/opal-task/cmd/server.go new file mode 100644 index 0000000..82e53b3 --- /dev/null +++ b/opal-task/cmd/server.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "fmt" + "os" + + "git.jnss.me/joakim/opal/internal/api" + "git.jnss.me/joakim/opal/internal/engine" + "github.com/spf13/cobra" +) + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Server management commands", + Long: `Commands for running and managing the opal API server`, +} + +var serverStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the opal API server", + Long: `Starts the opal-task REST API server for remote access. + +Examples: + opal server start + opal server start --addr :8080 + opal server start --db /var/lib/opal/opal.db`, + Run: func(cmd *cobra.Command, args []string) { + addr, _ := cmd.Flags().GetString("addr") + dbPath, _ := cmd.Flags().GetString("db") + + // Override DB path if specified + if dbPath != "" { + os.Setenv("OPAL_DB_PATH", dbPath) + } + + // Initialize database + if err := engine.InitDB(); err != nil { + fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err) + os.Exit(1) + } + defer engine.CloseDB() + + // Create and start server + server := api.NewServer(addr) + if err := server.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) + os.Exit(1) + } + }, +} + +var keygenCmd = &cobra.Command{ + Use: "keygen", + Short: "Generate API key for server authentication", + Long: `Generate a new API key for authenticating with the opal server. + +This command should be run on the server with direct database access. +The generated key will be displayed once and cannot be retrieved again. + +Examples: + opal server keygen --name "My Phone" + opal server keygen --name "Laptop" --db /var/lib/opal/opal.db`, + Run: func(cmd *cobra.Command, args []string) { + name, _ := cmd.Flags().GetString("name") + dbPath, _ := cmd.Flags().GetString("db") + + if name == "" { + fmt.Fprintf(os.Stderr, "Error: --name is required\n") + os.Exit(1) + } + + // Override DB path if specified + if dbPath != "" { + os.Setenv("OPAL_DB_PATH", dbPath) + } + + if err := engine.InitDB(); err != nil { + fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err) + os.Exit(1) + } + defer engine.CloseDB() + + key, err := engine.GenerateAPIKey(name) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating API key: %v\n", err) + os.Exit(1) + } + + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println("API Key Generated Successfully") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Printf("Name: %s\n", name) + fmt.Printf("Key: %s\n", key) + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println("") + fmt.Println("⚠️ IMPORTANT: Save this key securely!") + fmt.Println(" It will not be displayed again.") + fmt.Println("") + fmt.Println("To configure a client:") + fmt.Printf(" opal sync init --url https://opal.yourdomain.com --key %s\n", key) + }, +} + +func init() { + rootCmd.AddCommand(serverCmd) + serverCmd.AddCommand(serverStartCmd) + serverCmd.AddCommand(keygenCmd) + + serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address") + serverStartCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") + + keygenCmd.Flags().StringP("name", "n", "", "Name for this API key (e.g., device name)") + keygenCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") + keygenCmd.MarkFlagRequired("name") +} diff --git a/opal-task/go.mod b/opal-task/go.mod index 89adc55..88abae9 100644 --- a/opal-task/go.mod +++ b/opal-task/go.mod @@ -4,11 +4,13 @@ go 1.25.5 require ( github.com/fatih/color v1.18.0 + github.com/go-chi/chi/v5 v5.2.3 github.com/google/uuid v1.6.0 github.com/jedib0t/go-pretty/v6 v6.7.8 github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.46.0 ) require ( @@ -27,6 +29,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/opal-task/go.sum b/opal-task/go.sum index aa22266..24e9e7f 100644 --- a/opal-task/go.sum +++ b/opal-task/go.sum @@ -7,6 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -61,12 +63,14 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/opal-task/internal/api/handlers/auth.go b/opal-task/internal/api/handlers/auth.go new file mode 100644 index 0000000..2462cb4 --- /dev/null +++ b/opal-task/internal/api/handlers/auth.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + "strconv" + + "git.jnss.me/joakim/opal/internal/engine" + "github.com/go-chi/chi/v5" +) + +// ListAPIKeys returns all API keys for the current user +func ListAPIKeys(w http.ResponseWriter, r *http.Request) { + // For now, use default user ID (1 - shared user) + userID := 1 + + keys, err := engine.ListAPIKeys(userID) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, keys) +} + +// RevokeAPIKey revokes an API key by ID +func RevokeAPIKey(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + + keyID, err := strconv.Atoi(idStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid key ID") + return + } + + if err := engine.RevokeAPIKey(keyID); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, map[string]string{"message": "API key revoked"}) +} diff --git a/opal-task/internal/api/handlers/projects.go b/opal-task/internal/api/handlers/projects.go new file mode 100644 index 0000000..60f3797 --- /dev/null +++ b/opal-task/internal/api/handlers/projects.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "net/http" + + "git.jnss.me/joakim/opal/internal/engine" +) + +// ListProjects returns all unique projects from all tasks +func ListProjects(w http.ResponseWriter, r *http.Request) { + projects, err := engine.GetAllProjects() + if err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, projects) +} diff --git a/opal-task/internal/api/handlers/sync.go b/opal-task/internal/api/handlers/sync.go new file mode 100644 index 0000000..996a4ae --- /dev/null +++ b/opal-task/internal/api/handlers/sync.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "git.jnss.me/joakim/opal/internal/engine" +) + +// GetChangesRequest represents the request for getting changes +type GetChangesRequest struct { + Since int64 `json:"since"` + ClientID string `json:"client_id"` +} + +// GetChanges returns tasks that have changed since a given timestamp +func GetChanges(w http.ResponseWriter, r *http.Request) { + var req GetChangesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errorResponse(w, http.StatusBadRequest, "invalid request body") + return + } + + // Query change_log for entries since timestamp + db := engine.GetDB() + if db == nil { + errorResponse(w, http.StatusInternalServerError, "database not initialized") + return + } + + rows, err := db.Query(` + SELECT id, task_uuid, change_type, changed_at, data + FROM change_log + WHERE changed_at > ? + ORDER BY id ASC + `, req.Since) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + type Change struct { + ID int64 `json:"id"` + TaskUUID string `json:"task_uuid"` + ChangeType string `json:"change_type"` + ChangedAt int64 `json:"changed_at"` + Data string `json:"data"` + } + + var changes []Change + for rows.Next() { + var change Change + if err := rows.Scan(&change.ID, &change.TaskUUID, &change.ChangeType, &change.ChangedAt, &change.Data); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + changes = append(changes, change) + } + + // Update sync_state for client + _, _ = db.Exec(` + INSERT OR REPLACE INTO sync_state (client_id, last_sync, last_change_id) + VALUES (?, ?, ?) + `, req.ClientID, engine.GetCurrentTimestamp(), 0) + + jsonResponse(w, http.StatusOK, changes) +} + +// PushChangesRequest represents the request for pushing changes +type PushChangesRequest struct { + Tasks []json.RawMessage `json:"tasks"` + ClientID string `json:"client_id"` +} + +// PushChanges accepts task changes from clients +func PushChanges(w http.ResponseWriter, r *http.Request) { + var req PushChangesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errorResponse(w, http.StatusBadRequest, "invalid request body") + return + } + + db := engine.GetDB() + if db == nil { + errorResponse(w, http.StatusInternalServerError, "database not initialized") + return + } + + // For each task, parse and apply (last-write-wins for now) + var processed int + var conflicts int + + for _, taskData := range req.Tasks { + var task engine.Task + if err := json.Unmarshal(taskData, &task); err != nil { + continue + } + + // Check if task exists + existing, err := engine.GetTask(task.UUID) + if err != nil { + // Task doesn't exist - create it + if err := task.Save(); err != nil { + continue + } + // Add tags + for _, tag := range task.Tags { + _ = task.AddTag(tag) + } + processed++ + continue + } + + // Task exists - check timestamps for conflicts + if existing.Modified.Unix() > task.Modified.Unix() { + // Server version is newer - conflict (but we'll apply last-write-wins) + conflicts++ + } + + // Apply changes (last-write-wins) + task.ID = existing.ID // Preserve database ID + if err := task.Save(); err != nil { + continue + } + + // Sync tags + existingTags := make(map[string]bool) + for _, tag := range existing.Tags { + existingTags[tag] = true + } + + // Add new tags + for _, tag := range task.Tags { + if !existingTags[tag] { + _ = task.AddTag(tag) + } + } + + // Remove missing tags + for _, tag := range existing.Tags { + found := false + for _, newTag := range task.Tags { + if tag == newTag { + found = true + break + } + } + if !found { + _ = task.RemoveTag(tag) + } + } + + processed++ + } + + // Update sync_state + _, _ = db.Exec(` + INSERT OR REPLACE INTO sync_state (client_id, last_sync, last_change_id) + VALUES (?, ?, ?) + `, req.ClientID, engine.GetCurrentTimestamp(), 0) + + jsonResponse(w, http.StatusOK, map[string]interface{}{ + "processed": processed, + "conflicts": conflicts, + }) +} diff --git a/opal-task/internal/api/handlers/tags.go b/opal-task/internal/api/handlers/tags.go new file mode 100644 index 0000000..eefe311 --- /dev/null +++ b/opal-task/internal/api/handlers/tags.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "net/http" + + "git.jnss.me/joakim/opal/internal/engine" +) + +// ListAllTags returns all unique tags from all tasks +func ListAllTags(w http.ResponseWriter, r *http.Request) { + tags, err := engine.GetAllTags() + if err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, tags) +} diff --git a/opal-task/internal/api/handlers/tasks.go b/opal-task/internal/api/handlers/tasks.go new file mode 100644 index 0000000..4336f3c --- /dev/null +++ b/opal-task/internal/api/handlers/tasks.go @@ -0,0 +1,435 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "git.jnss.me/joakim/opal/internal/engine" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// Response helpers (avoiding import cycle) +func jsonResponse(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": status >= 200 && status < 300, + "data": data, + }) +} + +func errorResponse(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": message, + }) +} + +// ListTasks returns tasks based on filter query parameters +func ListTasks(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + // Build filter from query params + filter := engine.NewFilter() + + // Status filter + if status := query.Get("status"); status != "" { + filter.Attributes["status"] = status + } + + // Project filter + if project := query.Get("project"); project != "" { + filter.Attributes["project"] = project + } + + // Priority filter + if priority := query.Get("priority"); priority != "" { + filter.Attributes["priority"] = priority + } + + // Tag filters + if tags := query["tag"]; len(tags) > 0 { + filter.IncludeTags = tags + } + + // Get tasks + tasks, err := engine.GetTasks(filter) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, tasks) +} + +// CreateTaskRequest represents the request body for creating a task +type CreateTaskRequest struct { + Description string `json:"description"` + Tags []string `json:"tags,omitempty"` + Project *string `json:"project,omitempty"` + Priority *string `json:"priority,omitempty"` + Due *int64 `json:"due,omitempty"` + Scheduled *int64 `json:"scheduled,omitempty"` + Wait *int64 `json:"wait,omitempty"` + Until *int64 `json:"until,omitempty"` + Recurrence *string `json:"recurrence,omitempty"` +} + +// CreateTask creates a new task +func CreateTask(w http.ResponseWriter, r *http.Request) { + var req CreateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errorResponse(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Description == "" { + errorResponse(w, http.StatusBadRequest, "description is required") + return + } + + // Build modifier from request + mod := engine.NewModifier() + mod.AddTags = req.Tags + + if req.Project != nil { + mod.SetAttributes["project"] = req.Project + } + + if req.Priority != nil { + mod.SetAttributes["priority"] = req.Priority + } + + if req.Due != nil { + dueStr := fmt.Sprintf("%d", *req.Due) + mod.SetAttributes["due"] = &dueStr + } + + if req.Scheduled != nil { + scheduledStr := fmt.Sprintf("%d", *req.Scheduled) + mod.SetAttributes["scheduled"] = &scheduledStr + } + + if req.Wait != nil { + waitStr := fmt.Sprintf("%d", *req.Wait) + mod.SetAttributes["wait"] = &waitStr + } + + if req.Until != nil { + untilStr := fmt.Sprintf("%d", *req.Until) + mod.SetAttributes["until"] = &untilStr + } + + if req.Recurrence != nil { + mod.SetAttributes["recurrence"] = req.Recurrence + } + + // Create task + task, err := engine.CreateTaskWithModifier(req.Description, mod) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusCreated, task) +} + +// GetTask returns a single task by UUID +func GetTask(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + jsonResponse(w, http.StatusOK, task) +} + +// UpdateTaskRequest represents the request body for updating a task +type UpdateTaskRequest struct { + Description *string `json:"description,omitempty"` + Status *string `json:"status,omitempty"` + Priority *string `json:"priority,omitempty"` + Project *string `json:"project,omitempty"` + Due *int64 `json:"due,omitempty"` + Scheduled *int64 `json:"scheduled,omitempty"` + Wait *int64 `json:"wait,omitempty"` + Until *int64 `json:"until,omitempty"` + Start *int64 `json:"start,omitempty"` + Recurrence *string `json:"recurrence,omitempty"` +} + +// UpdateTask updates an existing task +func UpdateTask(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + var req UpdateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errorResponse(w, http.StatusBadRequest, "invalid request body") + return + } + + // Build modifier + mod := engine.NewModifier() + + if req.Description != nil { + mod.SetAttributes["description"] = req.Description + } + + if req.Status != nil { + mod.SetAttributes["status"] = req.Status + } + + if req.Priority != nil { + mod.SetAttributes["priority"] = req.Priority + } + + if req.Project != nil { + mod.SetAttributes["project"] = req.Project + } + + if req.Due != nil { + dueStr := fmt.Sprintf("%d", *req.Due) + mod.SetAttributes["due"] = &dueStr + } + + if req.Scheduled != nil { + scheduledStr := fmt.Sprintf("%d", *req.Scheduled) + mod.SetAttributes["scheduled"] = &scheduledStr + } + + if req.Wait != nil { + waitStr := fmt.Sprintf("%d", *req.Wait) + mod.SetAttributes["wait"] = &waitStr + } + + if req.Until != nil { + untilStr := fmt.Sprintf("%d", *req.Until) + mod.SetAttributes["until"] = &untilStr + } + + if req.Start != nil { + t := time.Unix(*req.Start, 0) + task.Start = &t + } + + if req.Recurrence != nil { + mod.SetAttributes["recurrence"] = req.Recurrence + } + + // Apply modifier + if err := mod.Apply(task); err != nil { + errorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, task) +} + +// DeleteTask deletes a task +func DeleteTask(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + // Check for permanent delete flag + permanent := r.URL.Query().Get("permanent") == "true" + + if err := task.Delete(permanent); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, map[string]string{"message": "task deleted"}) +} + +// CompleteTask marks a task as completed +func CompleteTask(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + if err := task.Complete(); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, task) +} + +// StartTask sets the start time for a task +func StartTask(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + if err := task.StartTask(); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, task) +} + +// StopTask clears the start time for a task +func StopTask(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + if err := task.StopTask(); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, task) +} + +// GetTaskTags returns all tags for a task +func GetTaskTags(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + jsonResponse(w, http.StatusOK, task.Tags) +} + +// AddTaskTagRequest represents the request to add a tag +type AddTaskTagRequest struct { + Tag string `json:"tag"` +} + +// AddTaskTag adds a tag to a task +func AddTaskTag(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + var req AddTaskTagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errorResponse(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Tag == "" { + errorResponse(w, http.StatusBadRequest, "tag is required") + return + } + + if err := task.AddTag(req.Tag); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, task) +} + +// RemoveTaskTag removes a tag from a task +func RemoveTaskTag(w http.ResponseWriter, r *http.Request) { + uuidStr := chi.URLParam(r, "uuid") + tag := chi.URLParam(r, "tag") + + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + errorResponse(w, http.StatusBadRequest, "invalid UUID") + return + } + + task, err := engine.GetTask(taskUUID) + if err != nil { + errorResponse(w, http.StatusNotFound, "task not found") + return + } + + if err := task.RemoveTag(tag); err != nil { + errorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonResponse(w, http.StatusOK, task) +} diff --git a/opal-task/internal/api/middleware.go b/opal-task/internal/api/middleware.go new file mode 100644 index 0000000..0a6ffd2 --- /dev/null +++ b/opal-task/internal/api/middleware.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "net/http" + "strings" + + "git.jnss.me/joakim/opal/internal/engine" +) + +type contextKey string + +const userIDKey contextKey = "userID" + +// AuthMiddleware validates API keys and adds userID to context +func AuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + Error(w, http.StatusUnauthorized, "missing authorization header") + return + } + + // Parse "Bearer " + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + Error(w, http.StatusUnauthorized, "invalid authorization header format") + return + } + + apiKey := parts[1] + + // Validate API key + valid, userID, err := engine.ValidateAPIKey(apiKey) + if err != nil { + Error(w, http.StatusInternalServerError, "failed to validate API key") + return + } + + if !valid { + Error(w, http.StatusUnauthorized, "invalid API key") + return + } + + // Add userID to context + ctx := context.WithValue(r.Context(), userIDKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// GetUserID extracts userID from request context +func GetUserID(r *http.Request) int { + userID, ok := r.Context().Value(userIDKey).(int) + if !ok { + return 0 + } + return userID +} + +// CORSMiddleware adds CORS headers for future web frontend +func CORSMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/opal-task/internal/api/response.go b/opal-task/internal/api/response.go new file mode 100644 index 0000000..a139bea --- /dev/null +++ b/opal-task/internal/api/response.go @@ -0,0 +1,39 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// Response represents a standard API response +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// JSON sends a JSON response +func JSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: status >= 200 && status < 300, + Data: data, + } + + json.NewEncoder(w).Encode(response) +} + +// Error sends an error response +func Error(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: false, + Error: message, + } + + json.NewEncoder(w).Encode(response) +} diff --git a/opal-task/internal/api/server.go b/opal-task/internal/api/server.go new file mode 100644 index 0000000..c14f8c7 --- /dev/null +++ b/opal-task/internal/api/server.go @@ -0,0 +1,81 @@ +package api + +import ( + "fmt" + "net/http" + + "git.jnss.me/joakim/opal/internal/api/handlers" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +// Server represents the API server +type Server struct { + router chi.Router + addr string +} + +// NewServer creates a new API server +func NewServer(addr string) *Server { + s := &Server{ + router: chi.NewRouter(), + addr: addr, + } + s.setupRoutes() + return s +} + +// setupRoutes configures all API routes +func (s *Server) setupRoutes() { + r := s.router + + // Global middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(CORSMiddleware()) + + // Health check (no auth required) + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + JSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(AuthMiddleware()) + + // Tasks + r.Route("/tasks", func(r chi.Router) { + r.Get("/", handlers.ListTasks) + r.Post("/", handlers.CreateTask) + r.Get("/{uuid}", handlers.GetTask) + r.Put("/{uuid}", handlers.UpdateTask) + r.Delete("/{uuid}", handlers.DeleteTask) + r.Post("/{uuid}/complete", handlers.CompleteTask) + r.Post("/{uuid}/start", handlers.StartTask) + r.Post("/{uuid}/stop", handlers.StopTask) + + // Tags + r.Get("/{uuid}/tags", handlers.GetTaskTags) + r.Post("/{uuid}/tags", handlers.AddTaskTag) + r.Delete("/{uuid}/tags/{tag}", handlers.RemoveTaskTag) + }) + + // Metadata + r.Get("/tags", handlers.ListAllTags) + r.Get("/projects", handlers.ListProjects) + + // Sync endpoints + r.Post("/sync/changes", handlers.GetChanges) + r.Post("/sync/push", handlers.PushChanges) + + // Key management + r.Get("/auth/keys", handlers.ListAPIKeys) + r.Delete("/auth/keys/{id}", handlers.RevokeAPIKey) + }) +} + +// Start starts the HTTP server +func (s *Server) Start() error { + fmt.Printf("Starting opal API server on %s\n", s.addr) + return http.ListenAndServe(s.addr, s.router) +} diff --git a/opal-task/internal/engine/auth.go b/opal-task/internal/engine/auth.go new file mode 100644 index 0000000..819809b --- /dev/null +++ b/opal-task/internal/engine/auth.go @@ -0,0 +1,156 @@ +package engine + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// APIKey represents an API key in the database +type APIKey struct { + ID int + Name string + UserID int + CreatedAt time.Time + LastUsed *time.Time + Revoked bool +} + +// GenerateAPIKey creates a new API key for the given name +func GenerateAPIKey(name string) (string, error) { + db := GetDB() + if db == nil { + return "", fmt.Errorf("database not initialized") + } + + // Generate random key: oak_ + 32 random bytes (base64 encoded) + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return "", fmt.Errorf("failed to generate random key: %w", err) + } + + key := "oak_" + base64.URLEncoding.EncodeToString(keyBytes) + + // Hash the key for storage + hashedKey, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash key: %w", err) + } + + // Store in database (user_id defaults to 1 for shared user) + _, err = db.Exec(` + INSERT INTO api_keys (key, name, user_id, created_at) + VALUES (?, ?, 1, ?) + `, string(hashedKey), name, timeNow().Unix()) + + if err != nil { + return "", fmt.Errorf("failed to store API key: %w", err) + } + + return key, nil +} + +// ValidateAPIKey checks if an API key is valid and updates last_used timestamp +func ValidateAPIKey(key string) (bool, int, error) { + db := GetDB() + if db == nil { + return false, 0, fmt.Errorf("database not initialized") + } + + // Get all non-revoked keys + rows, err := db.Query(` + SELECT id, key, user_id, revoked + FROM api_keys + WHERE revoked = 0 + `) + if err != nil { + return false, 0, fmt.Errorf("failed to query API keys: %w", err) + } + defer rows.Close() + + // Check each key (bcrypt comparison) + for rows.Next() { + var id, userID int + var hashedKey string + var revoked bool + + if err := rows.Scan(&id, &hashedKey, &userID, &revoked); err != nil { + continue + } + + // Compare with bcrypt + if err := bcrypt.CompareHashAndPassword([]byte(hashedKey), []byte(key)); err == nil { + // Valid key found - update last_used + now := timeNow().Unix() + _, _ = db.Exec("UPDATE api_keys SET last_used = ? WHERE id = ?", now, id) + return true, userID, nil + } + } + + return false, 0, nil +} + +// ListAPIKeys returns all API keys for a user (without the actual key value) +func ListAPIKeys(userID int) ([]*APIKey, error) { + db := GetDB() + if db == nil { + return nil, fmt.Errorf("database not initialized") + } + + rows, err := db.Query(` + SELECT id, name, user_id, created_at, last_used, revoked + FROM api_keys + WHERE user_id = ? + ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, fmt.Errorf("failed to query API keys: %w", err) + } + defer rows.Close() + + var keys []*APIKey + for rows.Next() { + key := &APIKey{} + var createdAt, lastUsed int64 + var lastUsedNull *int64 + + err := rows.Scan(&key.ID, &key.Name, &key.UserID, &createdAt, &lastUsedNull, &key.Revoked) + if err != nil { + return nil, fmt.Errorf("failed to scan API key: %w", err) + } + + key.CreatedAt = time.Unix(createdAt, 0) + if lastUsedNull != nil { + lastUsed = *lastUsedNull + t := time.Unix(lastUsed, 0) + key.LastUsed = &t + } + + keys = append(keys, key) + } + + return keys, nil +} + +// RevokeAPIKey marks an API key as revoked +func RevokeAPIKey(keyID int) error { + db := GetDB() + if db == nil { + return fmt.Errorf("database not initialized") + } + + result, err := db.Exec("UPDATE api_keys SET revoked = 1 WHERE id = ?", keyID) + if err != nil { + return fmt.Errorf("failed to revoke API key: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("API key not found") + } + + return nil +} diff --git a/opal-task/internal/engine/database.go b/opal-task/internal/engine/database.go index b5477d9..e531c2f 100644 --- a/opal-task/internal/engine/database.go +++ b/opal-task/internal/engine/database.go @@ -126,6 +126,137 @@ func runMigrations() error { task_uuid TEXT NOT NULL, FOREIGN KEY (task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE ); + + -- Users table (minimal for now, expandable later) + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + created_at INTEGER NOT NULL + ); + + -- Default shared user for household + INSERT INTO users (id, username, created_at) VALUES (1, 'shared', unixepoch()); + + -- API Keys for authentication + CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + user_id INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + last_used INTEGER, + revoked INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_api_keys_key ON api_keys(key); + CREATE INDEX idx_api_keys_user ON api_keys(user_id); + + -- Sync state (per client device) + CREATE TABLE sync_state ( + client_id TEXT PRIMARY KEY, + last_sync INTEGER NOT NULL, + last_change_id INTEGER DEFAULT 0 + ); + + -- Change log (key:value format like edit command) + CREATE TABLE change_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_uuid TEXT NOT NULL, + change_type TEXT NOT NULL, + changed_at INTEGER NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY (task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE + ); + + CREATE INDEX idx_change_log_timestamp ON change_log(changed_at); + CREATE INDEX idx_change_log_task ON change_log(task_uuid); + CREATE INDEX idx_change_log_id ON change_log(id); + + -- Sync configuration + CREATE TABLE sync_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + -- Default: keep change log for 30 days + INSERT INTO sync_config (key, value) VALUES ('change_log_retention_days', '30'); + + -- Triggers to populate change_log + CREATE TRIGGER track_task_create AFTER INSERT ON tasks + BEGIN + INSERT INTO change_log (task_uuid, change_type, changed_at, data) + VALUES (NEW.uuid, 'create', NEW.modified, + 'uuid: ' || NEW.uuid || CHAR(10) || + 'description: ' || NEW.description || CHAR(10) || + 'status: ' || CASE NEW.status + WHEN 80 THEN 'pending' + WHEN 67 THEN 'completed' + WHEN 68 THEN 'deleted' + WHEN 82 THEN 'recurring' + ELSE 'pending' + END || CHAR(10) || + 'priority: ' || CASE NEW.priority + WHEN 0 THEN 'L' + WHEN 1 THEN 'D' + WHEN 2 THEN 'M' + WHEN 3 THEN 'H' + ELSE 'D' + END || CHAR(10) || + CASE WHEN NEW.project IS NOT NULL THEN 'project: ' || NEW.project || CHAR(10) ELSE '' END || + 'created: ' || NEW.created || CHAR(10) || + 'modified: ' || NEW.modified || CHAR(10) || + CASE WHEN NEW.start IS NOT NULL THEN 'start: ' || NEW.start || CHAR(10) ELSE '' END || + CASE WHEN NEW.end IS NOT NULL THEN 'end: ' || NEW.end || CHAR(10) ELSE '' END || + CASE WHEN NEW.due IS NOT NULL THEN 'due: ' || NEW.due || CHAR(10) ELSE '' END || + CASE WHEN NEW.scheduled IS NOT NULL THEN 'scheduled: ' || NEW.scheduled || CHAR(10) ELSE '' END || + CASE WHEN NEW.wait IS NOT NULL THEN 'wait: ' || NEW.wait || CHAR(10) ELSE '' END || + CASE WHEN NEW.until_date IS NOT NULL THEN 'until: ' || NEW.until_date || CHAR(10) ELSE '' END || + CASE WHEN NEW.recurrence_duration IS NOT NULL THEN 'recurrence: ' || NEW.recurrence_duration || CHAR(10) ELSE '' END || + CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END + ); + END; + + CREATE TRIGGER track_task_update AFTER UPDATE ON tasks + BEGIN + INSERT INTO change_log (task_uuid, change_type, changed_at, data) + VALUES (NEW.uuid, 'update', NEW.modified, + 'uuid: ' || NEW.uuid || CHAR(10) || + 'description: ' || NEW.description || CHAR(10) || + 'status: ' || CASE NEW.status + WHEN 80 THEN 'pending' + WHEN 67 THEN 'completed' + WHEN 68 THEN 'deleted' + WHEN 82 THEN 'recurring' + ELSE 'pending' + END || CHAR(10) || + 'priority: ' || CASE NEW.priority + WHEN 0 THEN 'L' + WHEN 1 THEN 'D' + WHEN 2 THEN 'M' + WHEN 3 THEN 'H' + ELSE 'D' + END || CHAR(10) || + CASE WHEN NEW.project IS NOT NULL THEN 'project: ' || NEW.project || CHAR(10) ELSE '' END || + 'created: ' || NEW.created || CHAR(10) || + 'modified: ' || NEW.modified || CHAR(10) || + CASE WHEN NEW.start IS NOT NULL THEN 'start: ' || NEW.start || CHAR(10) ELSE '' END || + CASE WHEN NEW.end IS NOT NULL THEN 'end: ' || NEW.end || CHAR(10) ELSE '' END || + CASE WHEN NEW.due IS NOT NULL THEN 'due: ' || NEW.due || CHAR(10) ELSE '' END || + CASE WHEN NEW.scheduled IS NOT NULL THEN 'scheduled: ' || NEW.scheduled || CHAR(10) ELSE '' END || + CASE WHEN NEW.wait IS NOT NULL THEN 'wait: ' || NEW.wait || CHAR(10) ELSE '' END || + CASE WHEN NEW.until_date IS NOT NULL THEN 'until: ' || NEW.until_date || CHAR(10) ELSE '' END || + CASE WHEN NEW.recurrence_duration IS NOT NULL THEN 'recurrence: ' || NEW.recurrence_duration || CHAR(10) ELSE '' END || + CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END + ); + END; + + CREATE TRIGGER track_task_delete AFTER DELETE ON tasks + BEGIN + INSERT INTO change_log (task_uuid, change_type, changed_at, data) + VALUES (OLD.uuid, 'delete', unixepoch(), 'uuid: ' || OLD.uuid); + END; `, }, } @@ -167,3 +298,66 @@ func runMigrations() error { func getCurrentTimestamp() int64 { return timeNow().Unix() } + +// GetCurrentTimestamp returns the current Unix timestamp (exported for API use) +func GetCurrentTimestamp() int64 { + return getCurrentTimestamp() +} + +// CleanupChangeLog removes old change log entries based on retention policy +func CleanupChangeLog() error { + db := GetDB() + if db == nil { + return fmt.Errorf("database not initialized") + } + + // Get retention days from config + var retentionDays int + err := db.QueryRow("SELECT value FROM sync_config WHERE key = 'change_log_retention_days'").Scan(&retentionDays) + if err != nil { + retentionDays = 30 // Default to 30 days if not found + } + + // Calculate cutoff timestamp + cutoffTime := timeNow().AddDate(0, 0, -retentionDays).Unix() + + // Delete old entries + result, err := db.Exec("DELETE FROM change_log WHERE changed_at < ?", cutoffTime) + if err != nil { + return fmt.Errorf("failed to cleanup change log: %w", err) + } + + rows, _ := result.RowsAffected() + if rows > 0 { + fmt.Printf("Cleaned up %d old change log entries\n", rows) + } + + return nil +} + +// GetChangeLogRetentionDays returns the configured retention period +func GetChangeLogRetentionDays() (int, error) { + db := GetDB() + if db == nil { + return 0, fmt.Errorf("database not initialized") + } + + var days int + err := db.QueryRow("SELECT value FROM sync_config WHERE key = 'change_log_retention_days'").Scan(&days) + if err != nil { + return 30, nil // Default + } + + return days, nil +} + +// SetChangeLogRetentionDays sets the retention period +func SetChangeLogRetentionDays(days int) error { + db := GetDB() + if db == nil { + return fmt.Errorf("database not initialized") + } + + _, err := db.Exec("INSERT OR REPLACE INTO sync_config (key, value) VALUES ('change_log_retention_days', ?)", days) + return err +} diff --git a/opal-task/internal/engine/tags.go b/opal-task/internal/engine/tags.go new file mode 100644 index 0000000..fcdb334 --- /dev/null +++ b/opal-task/internal/engine/tags.go @@ -0,0 +1,55 @@ +package engine + +import ( + "fmt" +) + +// GetAllTags returns all unique tags from the database +func GetAllTags() ([]string, error) { + db := GetDB() + if db == nil { + return nil, fmt.Errorf("database not initialized") + } + + rows, err := db.Query("SELECT DISTINCT tag FROM tags ORDER BY tag") + if err != nil { + return nil, fmt.Errorf("failed to query tags: %w", err) + } + defer rows.Close() + + var tags []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, fmt.Errorf("failed to scan tag: %w", err) + } + tags = append(tags, tag) + } + + return tags, nil +} + +// GetAllProjects returns all unique projects from the database +func GetAllProjects() ([]string, error) { + db := GetDB() + if db == nil { + return nil, fmt.Errorf("database not initialized") + } + + rows, err := db.Query("SELECT DISTINCT project FROM tasks WHERE project IS NOT NULL ORDER BY project") + if err != nil { + return nil, fmt.Errorf("failed to query projects: %w", err) + } + defer rows.Close() + + var projects []string + for rows.Next() { + var project string + if err := rows.Scan(&project); err != nil { + return nil, fmt.Errorf("failed to scan project: %w", err) + } + projects = append(projects, project) + } + + return projects, nil +} diff --git a/opal-task/srv/README.md b/opal-task/srv/README.md new file mode 100644 index 0000000..4e150d4 --- /dev/null +++ b/opal-task/srv/README.md @@ -0,0 +1,12 @@ +# Server +Opal-task is now a great task CLI management tool, however I need my task list available elsewhere. For reading, yes, but also modification. + +## Current infrastructure +I host a VPS behind a Caddy reverse proxy. It hosts an authentik instance for SSO to my services. + +## Usecase +- Use within household to share tasks or filters. For our first version we should use one task database and share all data. This might branch off to sharing with access control (sharing specific tags/projects for instance) +- CRUD tasks on the go + +## Implementation +I was thinking of hosting an api, then building a minimal svelte-kit frontend that could be used as a PWA on our devices. What are your thoughts? We could use oauth for authentication through authentik. for now disregard the front-end implementation.