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,360 @@
|
||||
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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user