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).
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user