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).
438 lines
10 KiB
Go
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)
|
|
}
|