From c9e74b236d677dda3557df65b1e16fa689d4a6e1 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Fri, 18 Sep 2026 21:28:40 -0700 Subject: [PATCH] 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). --- .gitignore | 4 + README.md | 93 ++++++ go.mod | 25 ++ go.sum | 53 +++ internal/server/api.go | 437 ++++++++++++++++++++++++ internal/server/auth.go | 230 +++++++++++++ internal/server/server.go | 360 ++++++++++++++++++++ internal/server/static/style.css | 368 ++++++++++++++++++++ internal/server/templates/edit.html | 19 ++ internal/server/templates/error.html | 9 + internal/server/templates/home.html | 35 ++ internal/server/templates/keys.html | 33 ++ internal/server/templates/login.html | 17 + internal/server/templates/partial.html | 29 ++ internal/server/templates/project.html | 61 ++++ internal/server/templates/search.html | 27 ++ internal/store/auth.go | 127 +++++++ internal/store/store.go | 444 +++++++++++++++++++++++++ main.go | 108 ++++++ tracker.service | 10 + 20 files changed, 2489 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/server/api.go create mode 100644 internal/server/auth.go create mode 100644 internal/server/server.go create mode 100644 internal/server/static/style.css create mode 100644 internal/server/templates/edit.html create mode 100644 internal/server/templates/error.html create mode 100644 internal/server/templates/home.html create mode 100644 internal/server/templates/keys.html create mode 100644 internal/server/templates/login.html create mode 100644 internal/server/templates/partial.html create mode 100644 internal/server/templates/project.html create mode 100644 internal/server/templates/search.html create mode 100644 internal/store/auth.go create mode 100644 internal/store/store.go create mode 100644 main.go create mode 100644 tracker.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42fb354 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +tracker +tracker.db +tracker.db-shm +tracker.db-wal diff --git a/README.md b/README.md new file mode 100644 index 0000000..95edffb --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# tracker + +Single-binary project tracker with a Hackerman/terminal aesthetic. Go + SQLite, +server-rendered — no runtime dependencies, no build step, no JS. + +## Features + +- **Auth required** — username/passphrase login (bcrypt), server-side sessions +- **API keys** for remote/CLI access, managed from the web UI +- **Full JSON API** under `/api/v1/` +- **Projects** with name + description +- **Notes** per project, searchable via the global `grep the grid…` bar +- **Tasks** per project with `todo → doing → done` cycling (click the badge) +- **Global search** across project names, descriptions, notes, and task titles +- SQLite storage, WAL mode, everything in one file + +## Setup + +``` +go build -o tracker . +./tracker -adduser kyle # prompts for a passphrase (min 8 chars) +./tracker +``` + +Non-interactive variant: `TRACKER_PASSWORD=... ./tracker -adduser kyle`. + +Defaults: listens on `:8420` (all interfaces, so it's reachable over Tailscale), +database at `~/.local/share/tracker/tracker.db`. + +``` +./tracker -addr :9000 -db /path/to/tracker.db +``` + +## API keys + +Log in, go to **keys**, generate a key. It's shown once (only a SHA-256 hash is +stored). Keys grant full API access and track last-used time; revoke anytime. + +## API + +Authenticate with `X-API-Key: `, `Authorization: Bearer `, +`Authorization: Basic`, or just your browser session cookie. + +``` +GET /api/v1/projects list +POST /api/v1/projects {"name": "...", "description": "..."} +GET /api/v1/projects/{id} +PATCH /api/v1/projects/{id} partial update (name/description) +DELETE /api/v1/projects/{id} cascades tasks + notes + +GET /api/v1/projects/{id}/notes +POST /api/v1/projects/{id}/notes {"body": "..."} +GET /api/v1/notes/{id} +PATCH /api/v1/notes/{id} {"body": "..."} +DELETE /api/v1/notes/{id} + +GET /api/v1/projects/{id}/tasks +POST /api/v1/projects/{id}/tasks {"title": "...", "status": "todo"} +GET /api/v1/tasks/{id} +PATCH /api/v1/tasks/{id} {"title": "...", "status": "doing"} +DELETE /api/v1/tasks/{id} + +GET /api/v1/search?q=term +``` + +``` +curl -H "X-API-Key: trk_..." http://host:8420/api/v1/projects +curl -u kyle -X PATCH -d '{"status":"done"}' http://host:8420/api/v1/tasks/3 +``` + +## Autostart (systemd user unit) + +``` +cp tracker.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now tracker +``` + +Reach it from any device on your tailnet at `http://:8420`. + +## Theme + +Palette lifted from omarchy's Hackerman theme (`hackerman.nvim`): + +| token | hex | +|--------|-----------| +| bg | `#0B0C16` | +| panel | `#080911` | +| line | `#1a1d2b` | +| fg | `#ddf7ff` | +| muted | `#6a6e95` | +| neon | `#50f872` | +| cyan | `#7cf8f7` | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4a23007 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module tracker + +go 1.27 + +require ( + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 + modernc.org/sqlite v1.34.4 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.48.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9b59f25 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..a7d7703 --- /dev/null +++ b/internal/server/api.go @@ -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) +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..da901d9 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,230 @@ +package server + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + + "tracker/internal/store" +) + +const ( + sessionCookie = "tracker_session" + sessionLifetime = 30 * 24 * time.Hour + keyPrefix = "trk_" +) + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand unavailable: " + err.Error()) + } + return hex.EncodeToString(b) +} + +func (s *Server) checkPassword(username, password string) bool { + u, err := s.store.UserByName(username) + if err != nil { + // burn comparable time so missing users aren't distinguishable + bcrypt.CompareHashAndPassword([]byte("$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5B0X0PYmVlSkC0pR0uW9QlY0S3zKe"), []byte(password)) + return false + } + return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil +} + +// --- session helpers --- + +func (s *Server) sessionUser(r *http.Request) (string, bool) { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + return "", false + } + username, err := s.store.SessionByTokenHash(hashToken(c.Value)) + if err != nil { + return "", false + } + return username, true +} + +func (s *Server) startSession(w http.ResponseWriter, username string) error { + token := randomHex(32) + expires := time.Now().Add(sessionLifetime).Format("2006-01-02 15:04:05") + if err := s.store.CreateSession(hashToken(token), username, expires); err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionLifetime.Seconds()), + }) + return nil +} + +// --- login / logout --- + +func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { + if _, ok := s.sessionUser(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.render(w, http.StatusOK, "login.html", map[string]any{ + "Title": "login", + }) +} + +func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + if !s.checkPassword(username, password) { + time.Sleep(400 * time.Millisecond) + s.render(w, http.StatusUnauthorized, "login.html", map[string]any{ + "Title": "login", + "Error": "access denied: unknown handle or bad passphrase", + }) + return + } + s.store.DeleteExpiredSessions() + if err := s.startSession(w, username); err != nil { + s.error(w, http.StatusInternalServerError, err) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { + s.store.DeleteSession(hashToken(c.Value)) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// --- api keys page --- + +func (s *Server) handleKeys(w http.ResponseWriter, r *http.Request) { + s.renderKeys(w, r, http.StatusOK, nil, "") +} + +func (s *Server) renderKeys(w http.ResponseWriter, r *http.Request, status int, keys []store.APIKey, newKey string) { + if keys == nil { + var err error + keys, err = s.store.APIKeys() + if err != nil { + s.error(w, http.StatusInternalServerError, err) + return + } + } + s.render(w, status, "keys.html", map[string]any{ + "Title": "api keys", + "Keys": keys, + "NewKey": newKey, + }) +} + +func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + s.renderKeys(w, r, http.StatusBadRequest, nil, "") + return + } + plain := keyPrefix + randomHex(24) + if _, err := s.store.CreateAPIKey(name, hashToken(plain)); err != nil { + s.error(w, http.StatusInternalServerError, err) + return + } + s.renderKeys(w, r, http.StatusOK, nil, plain) +} + +func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) { + id, ok := s.pathID(w, r) + if !ok { + return + } + if err := s.store.DeleteAPIKey(id); err != nil { + s.error(w, http.StatusInternalServerError, err) + return + } + http.Redirect(w, r, "/keys", http.StatusSeeOther) +} + +// --- credential checks --- + +func apiKeyFromRequest(r *http.Request) string { + if key := r.Header.Get("X-API-Key"); key != "" { + return key + } + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + return "" +} + +func (s *Server) apiAuthorized(r *http.Request) bool { + if key := apiKeyFromRequest(r); key != "" { + k, err := s.store.KeyByHash(hashToken(key)) + if err != nil { + return false + } + s.store.TouchKey(k.ID) + return true + } + if user, pass, ok := r.BasicAuth(); ok { + return s.checkPassword(user, pass) + } + if _, ok := s.sessionUser(r); ok { + return true + } + return false +} + +// --- middleware --- + +func (s *Server) auth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + switch { + case strings.HasPrefix(p, "/static/"): + next.ServeHTTP(w, r) + return + case p == "/favicon.ico": + http.NotFound(w, r) + return + case strings.HasPrefix(p, "/api/"): + if s.apiAuthorized(r) { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) + return + case p == "/login": + next.ServeHTTP(w, r) + return + } + if _, ok := s.sessionUser(r); ok { + next.ServeHTTP(w, r) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..2a80d81 --- /dev/null +++ b/internal/server/server.go @@ -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(), + }) +} diff --git a/internal/server/static/style.css b/internal/server/static/style.css new file mode 100644 index 0000000..34a4843 --- /dev/null +++ b/internal/server/static/style.css @@ -0,0 +1,368 @@ +/* ── tracker :: hackerman theme ───────────────────────────── + palette lifted from omarchy/hackerman.nvim + bg #0B0C16 · panel #080911 · line #1a1d2b + fg #ddf7ff · muted #6a6e95 · neon #50f872 · cyan #7cf8f7 +─────────────────────────────────────────────────────────── */ + +:root { + --bg: #0B0C16; + --bg-deep: #080911; + --bg-line: #1a1d2b; + --fg: #ddf7ff; + --muted: #6a6e95; + --neon: #50f872; + --cyan: #7cf8f7; + --yellow: #7cf8d4; + --glow: 0 0 8px rgba(80, 248, 114, .55); + --glow-cyan: 0 0 8px rgba(124, 248, 247, .45); +} + +* { box-sizing: border-box; } + +html { color-scheme: dark; } + +body { + margin: 0; + background: radial-gradient(ellipse at 50% -20%, #101426 0%, var(--bg) 55%, #05060c 100%); + color: var(--fg); + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, "SF Mono", Menlo, monospace; + font-size: 15px; + line-height: 1.55; + min-height: 100vh; +} + +/* crt scanlines + flicker */ +.scanlines { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 999; + background: + repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 1px, transparent 1px 3px); + mix-blend-mode: multiply; + animation: flicker 4s infinite; +} +@keyframes flicker { + 0%, 100% { opacity: .9; } + 92% { opacity: .9; } + 93% { opacity: .55; } + 94% { opacity: .9; } + 97% { opacity: .75; } + 98% { opacity: .9; } +} + +::selection { background: var(--bg-line); color: var(--neon); } + +a { color: var(--cyan); text-decoration: none; } +a:hover { text-shadow: var(--glow-cyan); } + +/* ── topbar ── */ +.topbar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: .8rem 1.5rem; + border-bottom: 1px solid var(--bg-line); + background: rgba(8, 9, 17, .85); + position: sticky; + top: 0; + backdrop-filter: blur(6px); + z-index: 10; +} +.logo { + font-weight: 700; + color: var(--neon); + text-shadow: var(--glow); + letter-spacing: .05em; + white-space: nowrap; +} +.cursor { animation: blink 1.1s steps(1) infinite; } +@keyframes blink { 50% { opacity: 0; } } + +.searchbar { flex: 1; } +.searchbar input { + width: 100%; + max-width: 420px; +} + +/* ── layout ── */ +.wrap { + max-width: 860px; + margin: 2rem auto 4rem; + padding: 0 1.25rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.panel { + background: linear-gradient(180deg, var(--bg-deep), rgba(11,12,22,.6)); + border: 1px solid var(--bg-line); + border-radius: 6px; + padding: 1.25rem 1.5rem 1.5rem; + box-shadow: 0 0 0 1px rgba(80,248,114,.04), 0 12px 40px rgba(0,0,0,.5); + position: relative; +} +.panel::before { + content: ""; + position: absolute; + top: -1px; left: 1.5rem; + width: 4rem; height: 1px; + background: var(--neon); + box-shadow: var(--glow); +} + +h1, h2 { + font-size: 1rem; + text-transform: lowercase; + letter-spacing: .08em; + margin: 0 0 1rem; +} +.glow { color: var(--neon); text-shadow: var(--glow); } +.muted { color: var(--muted); } + +.description { + white-space: pre-wrap; + margin: 0; + color: var(--fg); +} + +.row { display: flex; align-items: center; gap: .75rem; } +.spread { justify-content: space-between; } +.push { margin-left: auto; } + +/* ── stats ── */ +.stats { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} +.stat { + flex: 1; + min-width: 90px; + border: 1px solid var(--bg-line); + border-radius: 6px; + background: var(--bg-deep); + padding: .75rem 1rem; + text-align: center; +} +.stat .num { + display: block; + font-size: 1.6rem; + font-weight: 700; + color: var(--neon); + text-shadow: var(--glow); +} +.stat.doing .num { color: var(--yellow); text-shadow: 0 0 8px rgba(124,248,212,.5); animation: pulse 2s infinite; } +.stat.done .num { color: var(--cyan); text-shadow: var(--glow-cyan); } +.stat .label { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .15em; } +@keyframes pulse { 50% { opacity: .55; } } + +/* ── forms ── */ +input, textarea { + width: 100%; + background: #05060c; + border: 1px solid var(--bg-line); + border-radius: 4px; + color: var(--fg); + font: inherit; + padding: .55rem .8rem; + outline: none; + transition: border-color .15s, box-shadow .15s; +} +input:focus, textarea:focus { + border-color: var(--neon); + box-shadow: var(--glow), inset 0 0 12px rgba(80,248,114,.06); +} +input::placeholder, textarea::placeholder { color: #3d4160; } +textarea { resize: vertical; } + +.stack { display: flex; flex-direction: column; gap: .75rem; } +.inline-form { + display: flex; + gap: .75rem; + margin-bottom: 1.25rem; +} +.inline-form input { flex: 1; } +.big-search input { font-size: 1.1rem; } +label { + display: flex; + flex-direction: column; + gap: .4rem; + color: var(--muted); + font-size: .8rem; + text-transform: uppercase; + letter-spacing: .1em; +} + +/* ── buttons ── */ +.btn { + display: inline-block; + background: transparent; + border: 1px solid var(--bg-line); + border-radius: 4px; + color: var(--cyan); + font: inherit; + font-size: .85rem; + padding: .45rem 1rem; + cursor: pointer; + transition: all .15s; + white-space: nowrap; +} +.btn:hover { + border-color: var(--cyan); + box-shadow: var(--glow-cyan); + text-shadow: var(--glow-cyan); +} +.btn.primary { color: var(--neon); border-color: rgba(80,248,114,.35); } +.btn.primary:hover { border-color: var(--neon); box-shadow: var(--glow); text-shadow: var(--glow); } +.btn.danger { color: #ff5c8a; border-color: rgba(255,92,138,.35); } +.btn.danger:hover { border-color: #ff5c8a; box-shadow: 0 0 8px rgba(255,92,138,.5); } +.btn.ghost { border: none; padding: .2rem .5rem; color: var(--muted); font-size: .8rem; } +.btn.ghost:hover { color: #ff5c8a; text-shadow: none; box-shadow: none; } + +/* ── project list ── */ +.project-list { list-style: none; margin: 0; padding: 0; } +.project-list li { + padding: .8rem .25rem; + border-bottom: 1px dashed var(--bg-line); +} +.project-list li:last-child { border-bottom: none; } +.project-link { + color: var(--fg); + font-weight: 700; +} +.project-link:hover { color: var(--neon); text-shadow: var(--glow); } +.project-link .arrow { color: var(--neon); } +.project-list p { margin: .2rem 0 0; font-size: .85rem; } + +/* ── tasks ── */ +.task-list { list-style: none; margin: 0; padding: 0; } +.task { + display: flex; + align-items: center; + gap: .75rem; + padding: .45rem .25rem; + border-bottom: 1px dashed var(--bg-line); +} +.task:last-child { border-bottom: none; } +.task form { margin: 0; } +.task-title { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.task-title.done { color: var(--muted); text-decoration: line-through; } +.status { + border: 1px solid var(--bg-line); + border-radius: 3px; + background: transparent; + font: inherit; + font-size: .72rem; + letter-spacing: .08em; + padding: .25rem .55rem; + cursor: pointer; + transition: all .15s; + min-width: 5.2rem; + text-align: left; +} +.status.todo { color: var(--muted); } +.status.doing { color: var(--yellow); border-color: rgba(124,248,212,.4); text-shadow: 0 0 6px rgba(124,248,212,.5); animation: pulse 2s infinite; } +.status.done { color: var(--neon); border-color: rgba(80,248,114,.4); text-shadow: 0 0 6px rgba(80,248,114,.5); } +.status:hover { border-color: currentColor; } + +/* ── notes ── */ +.note-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .9rem; } +.note { + border: 1px solid var(--bg-line); + border-left: 2px solid var(--cyan); + border-radius: 4px; + background: #05060c; + padding: .7rem 1rem; +} +.note .row { margin-bottom: .2rem; } +.note-body { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── search hits ── */ +.hit-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1rem; } +.hit { + border-bottom: 1px dashed var(--bg-line); + padding-bottom: 1rem; +} +.hit:last-child { border-bottom: none; padding-bottom: 0; } +.hit .badge { + font-size: .68rem; + text-transform: uppercase; + letter-spacing: .12em; + border: 1px solid; + border-radius: 3px; + padding: .1rem .5rem; + margin-right: .6rem; +} +.badge.project { color: var(--neon); border-color: rgba(80,248,114,.4); } +.badge.note { color: var(--cyan); border-color: rgba(124,248,247,.4); } +.badge.task { color: var(--yellow); border-color: rgba(124,248,212,.4); } +.hit-title { color: var(--fg); font-weight: 700; } +.hit-title:hover { color: var(--neon); text-shadow: var(--glow); } +.snippet { margin: .3rem 0 0; font-size: .85rem; } + +/* ── auth / keys ── */ +.nav { display: flex; align-items: center; gap: .9rem; } +.nav a { color: var(--muted); font-size: .85rem; } +.nav a:hover { color: var(--neon); } +.nav form { margin: 0; } + +.login-panel { max-width: 420px; margin: 15vh auto 0; } +.error-msg { + color: #ff5c8a; + border: 1px solid rgba(255,92,138,.4); + border-left: 2px solid #ff5c8a; + border-radius: 4px; + background: rgba(255,92,138,.06); + padding: .6rem 1rem; + margin: 0 0 1rem; + font-size: .85rem; +} + +.keybox { + background: #05060c; + border: 1px solid rgba(80,248,114,.4); + box-shadow: var(--glow), inset 0 0 20px rgba(80,248,114,.05); + border-radius: 4px; + padding: .9rem 1.2rem; + color: var(--neon); + text-shadow: var(--glow); + overflow-wrap: anywhere; + user-select: all; +} + +.key-list { list-style: none; margin: 1rem 0 0; padding: 0; } +.key-row { + display: flex; + align-items: center; + gap: 1rem; + padding: .55rem .25rem; + border-bottom: 1px dashed var(--bg-line); + font-size: .88rem; +} +.key-row:last-child { border-bottom: none; } +.key-row form { margin: 0; } +.key-name { color: var(--cyan); text-shadow: var(--glow-cyan); } + +code { + background: #05060c; + border: 1px solid var(--bg-line); + border-radius: 3px; + padding: .05rem .35rem; + font-size: .85em; + color: var(--yellow); +} + +/* ── footer ── */ +.foot { + text-align: center; + color: var(--muted); + font-size: .75rem; + padding: 1rem 0 2rem; + letter-spacing: .1em; +} diff --git a/internal/server/templates/edit.html b/internal/server/templates/edit.html new file mode 100644 index 0000000..45ef0e9 --- /dev/null +++ b/internal/server/templates/edit.html @@ -0,0 +1,19 @@ +{{template "header" .}} + +
+

// edit: {{.Project.Name}}

+
+ + +
+ + cancel +
+
+
+ +{{template "footer" .}} diff --git a/internal/server/templates/error.html b/internal/server/templates/error.html new file mode 100644 index 0000000..ee36ea3 --- /dev/null +++ b/internal/server/templates/error.html @@ -0,0 +1,9 @@ +{{template "header" .}} + +
+

// error {{.Status}}

+

{{.Message}}

+

◂ back to base

+
+ +{{template "footer" .}} diff --git a/internal/server/templates/home.html b/internal/server/templates/home.html new file mode 100644 index 0000000..ea837d8 --- /dev/null +++ b/internal/server/templates/home.html @@ -0,0 +1,35 @@ +{{template "header" .}} + +
+
{{.Stats.Projects}}projects
+
{{.Stats.Tasks.Todo}}todo
+
{{.Stats.Tasks.Doing}}doing
+
{{.Stats.Tasks.Done}}done
+
{{.Stats.Notes}}notes
+
+ +
+

// new project

+
+ + + +
+
+ +
+

// projects

+ {{if not .Projects}} +

no projects on the grid. create one above ▲

+ {{end}} +
    + {{range .Projects}} +
  • + {{.Name}} + {{if .Description}}

    {{.Description}}

    {{end}} +
  • + {{end}} +
+
+ +{{template "footer" .}} diff --git a/internal/server/templates/keys.html b/internal/server/templates/keys.html new file mode 100644 index 0000000..970ef67 --- /dev/null +++ b/internal/server/templates/keys.html @@ -0,0 +1,33 @@ +{{template "header" .}} + +{{if .NewKey}} +
+

// key generated

+

copy it now — it is stored hashed and will not be shown again:

+
{{.NewKey}}
+
+{{end}} + +
+

// api keys

+

keys grant full API access via X-API-Key or Authorization: Bearer.

+
+ + +
+ {{if not .Keys}}

no keys issued.

{{end}} +
    + {{range .Keys}} +
  • + {{.Name}} + created {{dateFmt .CreatedAt}} + {{if .LastUsed.Valid}}last used {{dateFmt .LastUsed.String}}{{else}}never used{{end}} +
    + +
    +
  • + {{end}} +
+
+ +{{template "footer" .}} diff --git a/internal/server/templates/login.html b/internal/server/templates/login.html new file mode 100644 index 0000000..fb611c6 --- /dev/null +++ b/internal/server/templates/login.html @@ -0,0 +1,17 @@ +{{template "header" .}} + + + +{{template "footer" .}} diff --git a/internal/server/templates/partial.html b/internal/server/templates/partial.html new file mode 100644 index 0000000..9fbbc45 --- /dev/null +++ b/internal/server/templates/partial.html @@ -0,0 +1,29 @@ +{{define "header"}} + + + + +{{.Title}} ∷ TRACKER + + + + + +
+{{end}} + +{{define "footer"}} +
+
// signal intact · {{.Title}}
+ + +{{end}} diff --git a/internal/server/templates/project.html b/internal/server/templates/project.html new file mode 100644 index 0000000..a944363 --- /dev/null +++ b/internal/server/templates/project.html @@ -0,0 +1,61 @@ +{{template "header" .}} + +
+
+

{{.Project.Name}}

+
+ edit +
+ +
+
+
+ {{if .Project.Description}}

{{.Project.Description}}

+ {{else}}

no description.

{{end}} +
+ +
+

// tasks

+
+ + +
+ {{if not .Tasks}}

no tasks queued.

{{end}} +
    + {{range .Tasks}} +
  • +
    + +
    + {{.Title}} +
    + +
    +
  • + {{end}} +
+
+ +
+

// notes

+
+ +
+
+ {{if not .Notes}}

no entries logged.

{{end}} +
    + {{range .Notes}} +
  • +
    + [{{dateFmt .CreatedAt}}] +
    + +
    +
    +

    {{.Body}}

    +
  • + {{end}} +
+
+ +{{template "footer" .}} diff --git a/internal/server/templates/search.html b/internal/server/templates/search.html new file mode 100644 index 0000000..bcb53c1 --- /dev/null +++ b/internal/server/templates/search.html @@ -0,0 +1,27 @@ +{{template "header" .}} + +
+

// search

+ +
+ +{{if .Query}} +
+

{{len .Hits}} hit(s) for “{{.Query}}”

+ {{if not .Hits}}

nothing found in the grid.

{{end}} +
    + {{range .Hits}} +
  • + {{.Kind}} + {{.Title}} +

    {{.Snippet}}

    +
  • + {{end}} +
+
+{{end}} + +{{template "footer" .}} diff --git a/internal/store/auth.go b/internal/store/auth.go new file mode 100644 index 0000000..6187356 --- /dev/null +++ b/internal/store/auth.go @@ -0,0 +1,127 @@ +package store + +import ( + "database/sql" + "errors" + "strings" +) + +var ErrConflict = errors.New("already exists") + +type User struct { + ID int64 + Username string + PasswordHash string + CreatedAt string +} + +type APIKey struct { + ID int64 `json:"id"` + Name string `json:"name"` + KeyHash string `json:"-"` + CreatedAt string `json:"created_at"` + LastUsed sql.NullString `json:"-"` +} + +// --- users --- + +func (s *Store) CreateUser(username, passwordHash string) (int64, error) { + res, err := s.db.Exec(`INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, passwordHash) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return 0, ErrConflict + } + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) UserByName(username string) (User, error) { + var u User + err := s.db.QueryRow(`SELECT id, username, password_hash, created_at FROM users WHERE username = ?`, username). + Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + return u, err +} + +// --- api keys --- + +func (s *Store) CreateAPIKey(name, keyHash string) (int64, error) { + res, err := s.db.Exec(`INSERT INTO api_keys (name, key_hash) VALUES (?, ?)`, name, keyHash) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return 0, ErrConflict + } + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) APIKeys() ([]APIKey, error) { + rows, err := s.db.Query(`SELECT id, name, key_hash, created_at, last_used FROM api_keys ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []APIKey + for rows.Next() { + var k APIKey + if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsed); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} + +func (s *Store) KeyByHash(keyHash string) (APIKey, error) { + var k APIKey + err := s.db.QueryRow(`SELECT id, name, key_hash, created_at, last_used FROM api_keys WHERE key_hash = ?`, keyHash). + Scan(&k.ID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsed) + if errors.Is(err, sql.ErrNoRows) { + return k, ErrNotFound + } + return k, err +} + +func (s *Store) TouchKey(id int64) { + s.db.Exec(`UPDATE api_keys SET last_used = datetime('now','localtime') WHERE id = ?`, id) +} + +func (s *Store) DeleteAPIKey(id int64) error { + res, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ?`, id) + if err != nil { + return err + } + return checkAffected(res) +} + +// --- sessions --- + +func (s *Store) CreateSession(tokenHash, username string, expiresAt string) error { + _, err := s.db.Exec(`INSERT INTO sessions (token_hash, username, expires_at) VALUES (?, ?, ?)`, tokenHash, username, expiresAt) + return err +} + +func (s *Store) SessionByTokenHash(tokenHash string) (string, error) { + var username string + err := s.db.QueryRow( + `SELECT username FROM sessions WHERE token_hash = ? AND expires_at > datetime('now','localtime')`, + tokenHash, + ).Scan(&username) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return username, err +} + +func (s *Store) DeleteSession(tokenHash string) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash) + return err +} + +func (s *Store) DeleteExpiredSessions() { + s.db.Exec(`DELETE FROM sessions WHERE expires_at <= datetime('now','localtime')`) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..809dce9 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,444 @@ +package store + +import ( + "database/sql" + "errors" + + _ "modernc.org/sqlite" +) + +var ( + ErrNotFound = errors.New("not found") +) + +type Project struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` +} + +type Note struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` +} + +type Task struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +type Hit struct { + Kind string `json:"kind"` + ProjectID int64 `json:"project_id"` + RefID int64 `json:"ref_id"` + Title string `json:"title"` + Snippet string `json:"snippet"` +} + +type Store struct { + db *sql.DB +} + +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) +); +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) +); +CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')), + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id); +CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) +); +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + last_used TEXT +); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + expires_at TEXT NOT NULL +); +`) + return err +} + +// --- projects --- + +func (s *Store) Projects() ([]Project, error) { + rows, err := s.db.Query(`SELECT id, name, description, created_at FROM projects ORDER BY name COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProjects(rows) +} + +func (s *Store) Project(id int64) (Project, error) { + var p Project + err := s.db.QueryRow(`SELECT id, name, description, created_at FROM projects WHERE id = ?`, id). + Scan(&p.ID, &p.Name, &p.Description, &p.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return p, ErrNotFound + } + return p, err +} + +func (s *Store) CreateProject(name, description string) (int64, error) { + res, err := s.db.Exec(`INSERT INTO projects (name, description) VALUES (?, ?)`, name, description) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) UpdateProject(id int64, name, description string) error { + res, err := s.db.Exec(`UPDATE projects SET name = ?, description = ? WHERE id = ?`, name, description, id) + if err != nil { + return err + } + return checkAffected(res) +} + +func (s *Store) DeleteProject(id int64) error { + res, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id) + if err != nil { + return err + } + return checkAffected(res) +} + +// --- notes --- + +func (s *Store) Notes(projectID int64) ([]Note, error) { + rows, err := s.db.Query(`SELECT id, project_id, body, created_at FROM notes WHERE project_id = ? ORDER BY id DESC`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var notes []Note + for rows.Next() { + var n Note + if err := rows.Scan(&n.ID, &n.ProjectID, &n.Body, &n.CreatedAt); err != nil { + return nil, err + } + notes = append(notes, n) + } + return notes, rows.Err() +} + +func (s *Store) Note(id int64) (Note, error) { + var n Note + err := s.db.QueryRow(`SELECT id, project_id, body, created_at FROM notes WHERE id = ?`, id). + Scan(&n.ID, &n.ProjectID, &n.Body, &n.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return n, ErrNotFound + } + return n, err +} + +func (s *Store) CreateNote(projectID int64, body string) (int64, error) { + res, err := s.db.Exec(`INSERT INTO notes (project_id, body) VALUES (?, ?)`, projectID, body) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) DeleteNote(id int64) error { + res, err := s.db.Exec(`DELETE FROM notes WHERE id = ?`, id) + if err != nil { + return err + } + return checkAffected(res) +} + +func (s *Store) UpdateNote(id int64, body string) error { + res, err := s.db.Exec(`UPDATE notes SET body = ? WHERE id = ?`, body, id) + if err != nil { + return err + } + return checkAffected(res) +} + +// --- tasks --- + +func (s *Store) Tasks(projectID int64) ([]Task, error) { + rows, err := s.db.Query(`SELECT id, project_id, title, status, created_at FROM tasks WHERE project_id = ? ORDER BY id`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var tasks []Task + for rows.Next() { + var t Task + if err := rows.Scan(&t.ID, &t.ProjectID, &t.Title, &t.Status, &t.CreatedAt); err != nil { + return nil, err + } + tasks = append(tasks, t) + } + return tasks, rows.Err() +} + +func (s *Store) Task(id int64) (Task, error) { + var t Task + err := s.db.QueryRow(`SELECT id, project_id, title, status, created_at FROM tasks WHERE id = ?`, id). + Scan(&t.ID, &t.ProjectID, &t.Title, &t.Status, &t.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return t, ErrNotFound + } + return t, err +} + +func (s *Store) CreateTask(projectID int64, title string) (int64, error) { + res, err := s.db.Exec(`INSERT INTO tasks (project_id, title) VALUES (?, ?)`, projectID, title) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// CycleTask advances status todo -> doing -> done -> todo. +func (s *Store) CycleTask(id int64) error { + _, err := s.db.Exec(`UPDATE tasks SET status = CASE status WHEN 'todo' THEN 'doing' WHEN 'doing' THEN 'done' ELSE 'todo' END WHERE id = ?`, id) + return err +} + +func (s *Store) SetTaskStatus(id int64, status string) error { + res, err := s.db.Exec(`UPDATE tasks SET status = ? WHERE id = ?`, status, id) + if err != nil { + return err + } + return checkAffected(res) +} + +func (s *Store) UpdateTaskTitle(id int64, title string) error { + res, err := s.db.Exec(`UPDATE tasks SET title = ? WHERE id = ?`, title, id) + if err != nil { + return err + } + return checkAffected(res) +} + +func (s *Store) DeleteTask(id int64) error { + res, err := s.db.Exec(`DELETE FROM tasks WHERE id = ?`, id) + if err != nil { + return err + } + return checkAffected(res) +} + +// --- search --- + +func (s *Store) Search(q string) ([]Hit, error) { + like := "%" + q + "%" + var hits []Hit + + rows, err := s.db.Query(`SELECT id, name, description FROM projects WHERE name LIKE ?1 OR description LIKE ?1 ORDER BY name COLLATE NOCASE`, like) + if err != nil { + return nil, err + } + for rows.Next() { + var id int64 + var name, desc string + if err := rows.Scan(&id, &name, &desc); err != nil { + rows.Close() + return nil, err + } + title, snip := name, desc + if snip == "" { + snip = name + } + hits = append(hits, Hit{Kind: "project", ProjectID: id, RefID: id, Title: title, Snippet: snip}) + } + rows.Close() + + rows, err = s.db.Query(`SELECT n.id, n.project_id, n.body, p.name FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.body LIKE ? ORDER BY n.id DESC`, like) + if err != nil { + return nil, err + } + for rows.Next() { + var id, pid int64 + var body, pname string + if err := rows.Scan(&id, &pid, &body, &pname); err != nil { + rows.Close() + return nil, err + } + hits = append(hits, Hit{Kind: "note", ProjectID: pid, RefID: id, Title: pname, Snippet: snippet(body, q)}) + } + rows.Close() + + rows, err = s.db.Query(`SELECT t.id, t.project_id, t.title, p.name FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.title LIKE ? ORDER BY t.id DESC`, like) + if err != nil { + return nil, err + } + for rows.Next() { + var id, pid int64 + var title, pname string + if err := rows.Scan(&id, &pid, &title, &pname); err != nil { + rows.Close() + return nil, err + } + hits = append(hits, Hit{Kind: "task", ProjectID: pid, RefID: id, Title: title, Snippet: pname}) + } + rows.Close() + + return hits, nil +} + +// --- stats --- + +type Stats struct { + Projects int + Tasks struct { + Todo, Doing, Done int + } + Notes int +} + +func (s *Store) Stats() (Stats, error) { + var st Stats + if err := s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&st.Projects); err != nil { + return st, err + } + if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes`).Scan(&st.Notes); err != nil { + return st, err + } + rows, err := s.db.Query(`SELECT status, COUNT(*) FROM tasks GROUP BY status`) + if err != nil { + return st, err + } + defer rows.Close() + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return st, err + } + switch status { + case "todo": + st.Tasks.Todo = n + case "doing": + st.Tasks.Doing = n + case "done": + st.Tasks.Done = n + } + } + return st, rows.Err() +} + +// --- helpers --- + +func scanProjects(rows *sql.Rows) ([]Project, error) { + var projects []Project + for rows.Next() { + var p Project + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedAt); err != nil { + return nil, err + } + projects = append(projects, p) + } + return projects, rows.Err() +} + +func checkAffected(res sql.Result) error { + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// snippet returns body trimmed to a window around the first match of q. +func snippet(body, q string) string { + lb, lq := lower(body), lower(q) + i := index(lb, lq) + if i < 0 { + if len(body) > 160 { + return body[:160] + "…" + } + return body + } + start := i - 40 + if start < 0 { + start = 0 + } + end := i + len(q) + 80 + if end > len(body) { + end = len(body) + } + out := body[start:end] + if start > 0 { + out = "…" + out + } + if end < len(body) { + out += "…" + } + return out +} + +// ASCII-only helpers to avoid pulling unicode/strings machinery into hot path readability. +func lower(s string) string { + b := []byte(s) + for i := range b { + if b[i] >= 'A' && b[i] <= 'Z' { + b[i] += 'a' - 'A' + } + } + return string(b) +} + +func index(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..32210b4 --- /dev/null +++ b/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" + + "tracker/internal/server" + "tracker/internal/store" +) + +func main() { + defaultDB := filepath.Join(os.Getenv("HOME"), ".local", "share", "tracker", "tracker.db") + dbPath := flag.String("db", defaultDB, "sqlite database path") + addr := flag.String("addr", ":8420", "listen address") + addUser := flag.String("adduser", "", "create a login user, then exit") + flag.Parse() + + if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil { + log.Fatal(err) + } + + if *addUser != "" { + if err := runAddUser(*dbPath, *addUser); err != nil { + log.Fatal(err) + } + return + } + + st, err := store.Open(*dbPath) + if err != nil { + log.Fatal(err) + } + defer st.Close() + + srv, err := server.New(st) + if err != nil { + log.Fatal(err) + } + + log.Printf("tracker online :: listening on %s", *addr) + if err := http.ListenAndServe(*addr, srv.Routes()); err != nil { + log.Fatal(err) + } +} + +func runAddUser(dbPath, username string) error { + username = strings.TrimSpace(username) + if username == "" { + return errors.New("username cannot be empty") + } + if strings.ContainsAny(username, " \t") { + return errors.New("username cannot contain whitespace") + } + + password := os.Getenv("TRACKER_PASSWORD") + confirm := password + if password == "" { + fmt.Print("passphrase: ") + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return err + } + fmt.Print("confirm: ") + confirmPw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return err + } + password, confirm = string(pw), string(confirmPw) + } + + if len(password) < 8 { + return errors.New("passphrase must be at least 8 characters") + } + if password != confirm { + return errors.New("passphrases do not match") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + st, err := store.Open(dbPath) + if err != nil { + return err + } + defer st.Close() + + if _, err := st.CreateUser(username, string(hash)); err != nil { + if errors.Is(err, store.ErrConflict) { + return fmt.Errorf("user %q already exists", username) + } + return err + } + fmt.Printf("user %q created. log in at /login\n", username) + return nil +} diff --git a/tracker.service b/tracker.service new file mode 100644 index 0000000..af441b2 --- /dev/null +++ b/tracker.service @@ -0,0 +1,10 @@ +[Unit] +Description=project tracker (hackerman edition) +After=network.target + +[Service] +ExecStart=%h/Projects/tracker/tracker -addr :8420 +Restart=on-failure + +[Install] +WantedBy=default.target