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
This commit is contained in:
2026-01-05 16:14:49 +01:00
parent 9bde1aefea
commit ba0cfc08e3
16 changed files with 1423 additions and 7 deletions
+1 -1
View File
@@ -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{
+115
View File
@@ -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")
}
+4 -2
View File
@@ -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
)
+8 -4
View File
@@ -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=
+41
View File
@@ -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"})
}
@@ -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)
}
+167
View File
@@ -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,
})
}
+18
View File
@@ -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)
}
+435
View File
@@ -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)
}
+79
View File
@@ -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 <token>"
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)
})
}
}
+39
View File
@@ -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)
}
+81
View File
@@ -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)
}
+156
View File
@@ -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
}
+194
View File
@@ -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
}
+55
View File
@@ -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
}
+12
View File
@@ -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.