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).
361 lines
9.7 KiB
Go
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(),
|
|
})
|
|
}
|