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