Files
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

361 lines
9.7 KiB
Go

package server
import (
"embed"
"encoding/json"
"errors"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"tracker/internal/store"
)
//go:embed all:templates
var templateFS embed.FS
type Server struct {
store *store.Store
tmpl *template.Template
}
func New(st *store.Store) (*Server, error) {
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
}
return &Server{store: st, tmpl: tmpl}, nil
}
var funcMap = template.FuncMap{
"statusLabel": func(s string) string {
switch s {
case "doing":
return "▶ DOING"
case "done":
return "✓ DONE"
default:
return "○ TODO"
}
},
"dateFmt": func(s string) string {
t, err := time.Parse("2006-01-02 15:04:05", s)
if err != nil {
return s
}
return t.Format("2006-01-02 15:04")
},
"json": func(v any) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
},
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /static/", s.serveStatic)
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /search", s.handleSearch)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /keys", s.handleKeys)
mux.HandleFunc("POST /keys/create", s.handleKeyCreate)
mux.HandleFunc("POST /keys/{id}/delete", s.handleKeyDelete)
mux.HandleFunc("POST /projects", s.handleCreateProject)
mux.HandleFunc("GET /projects/{id}", s.handleProject)
mux.HandleFunc("GET /projects/{id}/edit", s.handleProjectEdit)
mux.HandleFunc("POST /projects/{id}/update", s.handleProjectUpdate)
mux.HandleFunc("POST /projects/{id}/delete", s.handleProjectDelete)
mux.HandleFunc("POST /projects/{id}/notes", s.handleCreateNote)
mux.HandleFunc("POST /notes/{id}/delete", s.handleDeleteNote)
mux.HandleFunc("POST /projects/{id}/tasks", s.handleCreateTask)
mux.HandleFunc("POST /tasks/{id}/cycle", s.handleCycleTask)
mux.HandleFunc("POST /tasks/{id}/delete", s.handleDeleteTask)
// json api (behind api-key / basic auth / session)
mux.HandleFunc("GET /api/v1/projects", s.handleAPIProjectList)
mux.HandleFunc("POST /api/v1/projects", s.handleAPIProjectCreate)
mux.HandleFunc("GET /api/v1/projects/{id}", s.handleAPIProjectGet)
mux.HandleFunc("PATCH /api/v1/projects/{id}", s.handleAPIProjectUpdate)
mux.HandleFunc("PUT /api/v1/projects/{id}", s.handleAPIProjectUpdate)
mux.HandleFunc("DELETE /api/v1/projects/{id}", s.handleAPIProjectDelete)
mux.HandleFunc("GET /api/v1/projects/{id}/notes", s.handleAPINoteList)
mux.HandleFunc("POST /api/v1/projects/{id}/notes", s.handleAPINoteCreate)
mux.HandleFunc("GET /api/v1/notes/{id}", s.handleAPINoteGet)
mux.HandleFunc("PATCH /api/v1/notes/{id}", s.handleAPINoteUpdate)
mux.HandleFunc("PUT /api/v1/notes/{id}", s.handleAPINoteUpdate)
mux.HandleFunc("DELETE /api/v1/notes/{id}", s.handleAPINoteDelete)
mux.HandleFunc("GET /api/v1/projects/{id}/tasks", s.handleAPITaskList)
mux.HandleFunc("POST /api/v1/projects/{id}/tasks", s.handleAPITaskCreate)
mux.HandleFunc("GET /api/v1/tasks/{id}", s.handleAPITaskGet)
mux.HandleFunc("PATCH /api/v1/tasks/{id}", s.handleAPITaskUpdate)
mux.HandleFunc("PUT /api/v1/tasks/{id}", s.handleAPITaskUpdate)
mux.HandleFunc("DELETE /api/v1/tasks/{id}", s.handleAPITaskDelete)
mux.HandleFunc("GET /api/v1/search", s.handleAPISearch)
return s.auth(mux)
}
// --- static ---
//go:embed static
var staticFS embed.FS
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
http.FileServerFS(staticFS).ServeHTTP(w, r)
}
// --- pages ---
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.Projects()
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
stats, err := s.store.Stats()
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.render(w, http.StatusOK, "home.html", map[string]any{
"Title": "projects",
"Projects": projects,
"Stats": stats,
})
}
func (s *Server) handleProject(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
project, err := s.store.Project(id)
if errors.Is(err, store.ErrNotFound) {
s.error(w, http.StatusNotFound, errors.New("project not found"))
return
}
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
tasks, err := s.store.Tasks(id)
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
notes, err := s.store.Notes(id)
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.render(w, http.StatusOK, "project.html", map[string]any{
"Title": project.Name,
"Project": project,
"Tasks": tasks,
"Notes": notes,
})
}
func (s *Server) handleProjectEdit(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
project, err := s.store.Project(id)
if errors.Is(err, store.ErrNotFound) {
s.error(w, http.StatusNotFound, errors.New("project not found"))
return
}
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.render(w, http.StatusOK, "edit.html", map[string]any{
"Title": "edit: " + project.Name,
"Project": project,
})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
var hits []store.Hit
if q != "" {
var err error
hits, err = s.store.Search(q)
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
s.render(w, http.StatusOK, "search.html", map[string]any{
"Title": "search",
"Query": q,
"Hits": hits,
})
}
// --- actions ---
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
name, description := r.FormValue("name"), r.FormValue("description")
if strings.TrimSpace(name) == "" {
s.redirectBack(w, r, "/")
return
}
id, err := s.store.CreateProject(strings.TrimSpace(name), strings.TrimSpace(description))
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
name, description := r.FormValue("name"), r.FormValue("description")
if strings.TrimSpace(name) == "" {
http.Redirect(w, r, projectURL(id)+"/edit", http.StatusSeeOther)
return
}
if err := s.store.UpdateProject(id, strings.TrimSpace(name), strings.TrimSpace(description)); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteProject(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
body := strings.TrimSpace(r.FormValue("body"))
if body != "" {
if _, err := s.store.CreateNote(id, body); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteNote(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.redirectBack(w, r, "/")
}
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title != "" {
if _, err := s.store.CreateTask(id, title); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleCycleTask(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.CycleTask(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.redirectBack(w, r, "/")
}
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteTask(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.redirectBack(w, r, "/")
}
// --- plumbing ---
func (s *Server) pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
s.error(w, http.StatusNotFound, errors.New("bad id"))
return 0, false
}
return id, true
}
func projectURL(id int64) string { return "/projects/" + strconv.FormatInt(id, 10) }
// redirectBack returns the user to where they came from (Referer) so task
// toggles from the search page land back on the search page.
func (s *Server) redirectBack(w http.ResponseWriter, r *http.Request, fallback string) {
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && u.Path != "" {
http.Redirect(w, r, u.Path, http.StatusSeeOther)
return
}
}
http.Redirect(w, r, fallback, http.StatusSeeOther)
}
func (s *Server) render(w http.ResponseWriter, status int, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template %s: %v", name, err)
}
}
func (s *Server) error(w http.ResponseWriter, status int, err error) {
log.Printf("%d: %v", status, err)
s.render(w, status, "error.html", map[string]any{
"Title": "error",
"Status": status,
"Message": err.Error(),
})
}