Files
tracker/internal/server/api.go
T
kyle c9e74b236d tracker: single-binary project tracker with auth, api keys, and json api
Go + SQLite web app, server-rendered, hackerman theme. Projects with
descriptions, searchable notes, todo/doing/done task tracking. Bcrypt
login sessions, hashed api keys, full /api/v1 crud. Deploys on straylight
via configs/tracker.nix (prebuilt binary).
2026-09-18 21:28:40 -07:00

438 lines
10 KiB
Go

package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"tracker/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func apiError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
apiError(w, http.StatusBadRequest, "invalid json body: "+err.Error())
return false
}
return true
}
func (s *Server) apiProjectID(w http.ResponseWriter, r *http.Request) (int64, bool) {
return s.apiID(w, r)
}
func (s *Server) apiID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
apiError(w, http.StatusBadRequest, "invalid id")
return 0, false
}
return id, true
}
func (s *Server) apiNotFound(w http.ResponseWriter, err error) bool {
if errors.Is(err, store.ErrNotFound) {
apiError(w, http.StatusNotFound, "not found")
return true
}
return false
}
// --- projects ---
type projectPayload struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
func (s *Server) handleAPIProjectList(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.Projects()
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if projects == nil {
projects = []store.Project{}
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleAPIProjectCreate(w http.ResponseWriter, r *http.Request) {
var p projectPayload
if !readJSON(w, r, &p) {
return
}
if p.Name == nil || strings.TrimSpace(*p.Name) == "" {
apiError(w, http.StatusBadRequest, "name is required")
return
}
desc := ""
if p.Description != nil {
desc = strings.TrimSpace(*p.Description)
}
id, err := s.store.CreateProject(strings.TrimSpace(*p.Name), desc)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
project, err := s.store.Project(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, project)
}
func (s *Server) handleAPIProjectGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
project, err := s.store.Project(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, project)
}
func (s *Server) handleAPIProjectUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var p projectPayload
if !readJSON(w, r, &p) {
return
}
project, err := s.store.Project(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if p.Name != nil {
if strings.TrimSpace(*p.Name) == "" {
apiError(w, http.StatusBadRequest, "name cannot be empty")
return
}
project.Name = strings.TrimSpace(*p.Name)
}
if p.Description != nil {
project.Description = strings.TrimSpace(*p.Description)
}
if err := s.store.UpdateProject(id, project.Name, project.Description); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, project)
}
func (s *Server) handleAPIProjectDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
if err := s.store.DeleteProject(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- notes ---
type notePayload struct {
Body *string `json:"body"`
}
func (s *Server) handleAPINoteList(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
notes, err := s.store.Notes(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if notes == nil {
notes = []store.Note{}
}
writeJSON(w, http.StatusOK, notes)
}
func (s *Server) handleAPINoteCreate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var n notePayload
if !readJSON(w, r, &n) {
return
}
if n.Body == nil || strings.TrimSpace(*n.Body) == "" {
apiError(w, http.StatusBadRequest, "body is required")
return
}
if _, err := s.store.Project(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
noteID, err := s.store.CreateNote(id, strings.TrimSpace(*n.Body))
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
notes, err := s.store.Notes(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
for _, note := range notes {
if note.ID == noteID {
writeJSON(w, http.StatusCreated, note)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": noteID})
}
func (s *Server) handleAPINoteGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
note, err := s.store.Note(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, note)
}
func (s *Server) handleAPINoteUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
var n notePayload
if !readJSON(w, r, &n) {
return
}
if n.Body == nil || strings.TrimSpace(*n.Body) == "" {
apiError(w, http.StatusBadRequest, "body is required")
return
}
if err := s.store.UpdateNote(id, strings.TrimSpace(*n.Body)); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (s *Server) handleAPINoteDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
if err := s.store.DeleteNote(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- tasks ---
type taskPayload struct {
Title *string `json:"title"`
Status *string `json:"status"`
}
var validStatuses = map[string]bool{"todo": true, "doing": true, "done": true}
func (s *Server) handleAPITaskList(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
tasks, err := s.store.Tasks(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if tasks == nil {
tasks = []store.Task{}
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleAPITaskCreate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var t taskPayload
if !readJSON(w, r, &t) {
return
}
if t.Title == nil || strings.TrimSpace(*t.Title) == "" {
apiError(w, http.StatusBadRequest, "title is required")
return
}
if t.Status != nil && !validStatuses[*t.Status] {
apiError(w, http.StatusBadRequest, "status must be todo, doing, or done")
return
}
if _, err := s.store.Project(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
taskID, err := s.store.CreateTask(id, strings.TrimSpace(*t.Title))
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if t.Status != nil && *t.Status != "todo" {
if err := s.store.SetTaskStatus(taskID, *t.Status); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
tasks, err := s.store.Tasks(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
for _, task := range tasks {
if task.ID == taskID {
writeJSON(w, http.StatusCreated, task)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": taskID})
}
func (s *Server) handleAPITaskGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
task, err := s.store.Task(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleAPITaskUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
var t taskPayload
if !readJSON(w, r, &t) {
return
}
if t.Status != nil && !validStatuses[*t.Status] {
apiError(w, http.StatusBadRequest, "status must be todo, doing, or done")
return
}
task, err := s.store.Task(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if t.Title != nil {
if strings.TrimSpace(*t.Title) == "" {
apiError(w, http.StatusBadRequest, "title cannot be empty")
return
}
task.Title = strings.TrimSpace(*t.Title)
if err := s.store.UpdateTaskTitle(id, task.Title); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
if t.Status != nil {
task.Status = *t.Status
if err := s.store.SetTaskStatus(id, task.Status); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleAPITaskDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
if err := s.store.DeleteTask(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- search ---
func (s *Server) handleAPISearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
apiError(w, http.StatusBadRequest, "q parameter is required")
return
}
hits, err := s.store.Search(q)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if hits == nil {
hits = []store.Hit{}
}
writeJSON(w, http.StatusOK, hits)
}