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,4 @@
|
||||
tracker
|
||||
tracker.db
|
||||
tracker.db-shm
|
||||
tracker.db-wal
|
||||
@@ -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: <key>`, `Authorization: Bearer <key>`,
|
||||
`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://<tailscale-ip>: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` |
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<section class="panel">
|
||||
<h1 class="glow">// edit: {{.Project.Name}}</h1>
|
||||
<form method="post" action="/projects/{{.Project.ID}}/update" class="stack">
|
||||
<label>name
|
||||
<input type="text" name="name" value="{{.Project.Name}}" required maxlength="120">
|
||||
</label>
|
||||
<label>description
|
||||
<textarea name="description" rows="6">{{.Project.Description}}</textarea>
|
||||
</label>
|
||||
<div class="row">
|
||||
<button type="submit" class="btn primary">save ▸</button>
|
||||
<a class="btn" href="/projects/{{.Project.ID}}">cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<section class="panel">
|
||||
<h1 class="glow">// error {{.Status}}</h1>
|
||||
<p class="description">{{.Message}}</p>
|
||||
<p><a class="btn" href="/">◂ back to base</a></p>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<section class="stats">
|
||||
<div class="stat"><span class="num">{{.Stats.Projects}}</span><span class="label">projects</span></div>
|
||||
<div class="stat todo"><span class="num">{{.Stats.Tasks.Todo}}</span><span class="label">todo</span></div>
|
||||
<div class="stat doing"><span class="num">{{.Stats.Tasks.Doing}}</span><span class="label">doing</span></div>
|
||||
<div class="stat done"><span class="num">{{.Stats.Tasks.Done}}</span><span class="label">done</span></div>
|
||||
<div class="stat"><span class="num">{{.Stats.Notes}}</span><span class="label">notes</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h1 class="glow">// new project</h1>
|
||||
<form method="post" action="/projects" class="stack">
|
||||
<input type="text" name="name" placeholder="project name" required maxlength="120">
|
||||
<textarea name="description" placeholder="description…" rows="3"></textarea>
|
||||
<button type="submit" class="btn primary">init ▸</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h1 class="glow">// projects</h1>
|
||||
{{if not .Projects}}
|
||||
<p class="muted">no projects on the grid. create one above ▲</p>
|
||||
{{end}}
|
||||
<ul class="project-list">
|
||||
{{range .Projects}}
|
||||
<li>
|
||||
<a class="project-link" href="/projects/{{.ID}}">{{.Name}}<span class="arrow"> ▸</span></a>
|
||||
{{if .Description}}<p class="muted">{{.Description}}</p>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{template "header" .}}
|
||||
|
||||
{{if .NewKey}}
|
||||
<section class="panel">
|
||||
<h1 class="glow">// key generated</h1>
|
||||
<p>copy it now — it is stored hashed and will not be shown again:</p>
|
||||
<pre class="keybox">{{.NewKey}}</pre>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="panel">
|
||||
<h1 class="glow">// api keys</h1>
|
||||
<p class="muted">keys grant full API access via <code>X-API-Key</code> or <code>Authorization: Bearer</code>.</p>
|
||||
<form method="post" action="/keys/create" class="inline-form">
|
||||
<input type="text" name="name" placeholder="key name (e.g. laptop-cli)" required maxlength="60">
|
||||
<button type="submit" class="btn primary">generate ▸</button>
|
||||
</form>
|
||||
{{if not .Keys}}<p class="muted">no keys issued.</p>{{end}}
|
||||
<ul class="key-list">
|
||||
{{range .Keys}}
|
||||
<li class="key-row">
|
||||
<span class="key-name">{{.Name}}</span>
|
||||
<span class="muted">created {{dateFmt .CreatedAt}}</span>
|
||||
<span class="muted push">{{if .LastUsed.Valid}}last used {{dateFmt .LastUsed.String}}{{else}}never used{{end}}</span>
|
||||
<form method="post" action="/keys/{{.ID}}/delete" onsubmit="return confirm('revoke this key?')">
|
||||
<button type="submit" class="btn ghost">revoke ✕</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<section class="panel login-panel">
|
||||
<h1 class="glow">// access</h1>
|
||||
{{if .Error}}<p class="error-msg">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login" class="stack">
|
||||
<label>handle
|
||||
<input type="text" name="username" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>passphrase
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit" class="btn primary">jack in ▸</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{define "header"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} ∷ TRACKER</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="scanlines" aria-hidden="true"></div>
|
||||
<nav class="topbar">
|
||||
<a class="logo" href="/">❯ tracker<span class="cursor">▊</span></a>
|
||||
<form class="searchbar" action="/search" method="get">
|
||||
<input type="search" name="q" placeholder="grep the grid…" value="" autocomplete="off">
|
||||
</form>
|
||||
<div class="nav">
|
||||
<a href="/keys">keys</a>
|
||||
<form method="post" action="/logout"><button type="submit" class="btn ghost">logout</button></form>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="wrap">
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
</main>
|
||||
<footer class="foot">// signal intact · {{.Title}}</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<section class="panel">
|
||||
<div class="row spread">
|
||||
<h1 class="glow">{{.Project.Name}}</h1>
|
||||
<div class="row">
|
||||
<a class="btn" href="/projects/{{.Project.ID}}/edit">edit</a>
|
||||
<form method="post" action="/projects/{{.Project.ID}}/delete" onsubmit="return confirm('delete project and all its data?')">
|
||||
<button type="submit" class="btn danger">rm -rf</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Project.Description}}<p class="description">{{.Project.Description}}</p>
|
||||
{{else}}<p class="muted">no description.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2 class="glow">// tasks</h2>
|
||||
<form method="post" action="/projects/{{.Project.ID}}/tasks" class="inline-form">
|
||||
<input type="text" name="title" placeholder="new task…" required maxlength="240">
|
||||
<button type="submit" class="btn primary">add ▸</button>
|
||||
</form>
|
||||
{{if not .Tasks}}<p class="muted">no tasks queued.</p>{{end}}
|
||||
<ul class="task-list">
|
||||
{{range .Tasks}}
|
||||
<li class="task">
|
||||
<form method="post" action="/tasks/{{.ID}}/cycle">
|
||||
<button type="submit" class="status {{.Status}}" title="cycle status">{{statusLabel .Status}}</button>
|
||||
</form>
|
||||
<span class="task-title {{.Status}}">{{.Title}}</span>
|
||||
<form method="post" action="/tasks/{{.ID}}/delete" class="push">
|
||||
<button type="submit" class="btn ghost" title="delete">✕</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2 class="glow">// notes</h2>
|
||||
<form method="post" action="/projects/{{.Project.ID}}/notes" class="stack">
|
||||
<textarea name="body" placeholder="log an entry…" rows="3" required></textarea>
|
||||
<div class="row"><button type="submit" class="btn primary">log ▸</button></div>
|
||||
</form>
|
||||
{{if not .Notes}}<p class="muted">no entries logged.</p>{{end}}
|
||||
<ul class="note-list">
|
||||
{{range .Notes}}
|
||||
<li class="note">
|
||||
<div class="row spread">
|
||||
<span class="muted">[{{dateFmt .CreatedAt}}]</span>
|
||||
<form method="post" action="/notes/{{.ID}}/delete">
|
||||
<button type="submit" class="btn ghost" title="delete">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="note-body">{{.Body}}</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{template "header" .}}
|
||||
|
||||
<section class="panel">
|
||||
<h1 class="glow">// search</h1>
|
||||
<form action="/search" method="get" class="inline-form big-search">
|
||||
<input type="search" name="q" value="{{.Query}}" placeholder="grep the grid…" autofocus autocomplete="off">
|
||||
<button type="submit" class="btn primary">run ▸</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{if .Query}}
|
||||
<section class="panel">
|
||||
<h2 class="muted">{{len .Hits}} hit(s) for “{{.Query}}”</h2>
|
||||
{{if not .Hits}}<p class="muted">nothing found in the grid.</p>{{end}}
|
||||
<ul class="hit-list">
|
||||
{{range .Hits}}
|
||||
<li class="hit">
|
||||
<span class="badge {{.Kind}}">{{.Kind}}</span>
|
||||
<a href="/projects/{{.ProjectID}}" class="hit-title">{{.Title}}</a>
|
||||
<p class="muted snippet">{{.Snippet}}</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{template "footer" .}}
|
||||
@@ -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')`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user