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
+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,
})
}