Add tm_server with SQLite, Svelte UI, and task-master TUI sync.

Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:23:11 +01:00
co-authored by Cursor
parent 98d2253f44
commit 4adfcdb95a
67 changed files with 7188 additions and 129 deletions
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"net/http"
"strings"
"time"
"task/internal/sqlite"
)
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Username = strings.TrimSpace(body.Username)
u, err := s.DB.GetUserByUsername(body.Username)
if err != nil || !sqlite.CheckPassword(body.Password, u.PasswordHash) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, exp, err := s.DB.CreateSession(u.ID, 7*24*time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "session failed")
return
}
s.setSessionCookie(w, token, exp)
u.PasswordHash = ""
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
_ = s.DB.DeleteSession(c.Value)
}
s.clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.DB.ListUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
u, err := s.DB.CreateUser(body.Username, body.Password, body.IsAdmin)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
u.PasswordHash = ""
writeJSON(w, http.StatusCreated, u)
}
+72
View File
@@ -0,0 +1,72 @@
package server
import (
"fmt"
"task/internal/project"
"task/internal/ticket"
)
// ImportJSONDataDir copies projects/tickets/time from a local JSON DataDir into SQLite.
func (s *Server) ImportJSONDataDir(dataDir string, ownerUserID string) error {
ps := project.NewDirStore(dataDir)
ts := ticket.NewJSONStore(dataDir)
timeStore := ticket.NewJSONTimeStore(dataDir)
projects, err := ps.List()
if err != nil {
return err
}
for _, p := range projects {
if err := s.Projects.Ensure(p.ID); err != nil {
return fmt.Errorf("project %s: %w", p.ID, err)
}
if ownerUserID != "" {
_ = s.DB.SetMember(p.ID, ownerUserID, "owner")
}
meta, err := ps.LoadMeta(p.ID)
if err != nil {
return err
}
if meta.YouTrack != nil && meta.YouTrack.Configured() {
if err := s.Projects.SaveMeta(p.ID, meta); err != nil {
return fmt.Errorf("meta %s: %w", p.ID, err)
}
}
tickets, err := ts.List(p.ID)
if err != nil {
return err
}
for _, t := range tickets {
if _, err := s.Tickets.Get(t.ID); err == nil {
continue // already imported
}
if _, err := s.Tickets.Create(t); err != nil {
return fmt.Errorf("ticket %s: %w", t.ID, err)
}
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "import", ownerUserID, t)
}
}
entries, err := timeStore.List()
if err != nil {
return err
}
existing, _ := s.Times.List()
have := map[string]bool{}
for _, e := range existing {
have[e.ID] = true
}
for _, e := range entries {
if have[e.ID] {
continue
}
if _, err := s.Times.Create(e); err != nil {
return fmt.Errorf("time %s: %w", e.ID, err)
}
}
if active, err := timeStore.LoadActiveTimer(); err == nil && active != nil {
_ = s.Times.SaveActiveTimer(active)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"os"
"path/filepath"
"testing"
"task/internal/project"
"task/internal/sqlite"
"task/internal/ticket"
)
func TestImportJSONDataDir(t *testing.T) {
dataDir := t.TempDir()
ps := project.NewDirStore(dataDir)
if err := ps.Ensure("inbox"); err != nil {
t.Fatal(err)
}
ts := ticket.NewJSONStore(dataDir)
created, err := ts.Create(ticket.Ticket{Title: "From JSON", ProjectID: "inbox"})
if err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(t.TempDir(), "import.db")
db, err := sqlite.Open(dbPath, []byte("sec"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
u, err := db.CreateUser("admin", "x", true)
if err != nil {
t.Fatal(err)
}
srv := New(db)
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
t.Fatal(err)
}
got, err := srv.Tickets.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "From JSON" {
t.Fatalf("title=%q", got.Title)
}
role, _ := db.MemberRole("inbox", u.ID)
if role != sqlite.RoleOwner {
t.Fatalf("role=%s", role)
}
// second import is idempotent
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
t.Fatal(err)
}
_ = os.RemoveAll(dataDir) // ensure we don't rely on JSON after import for this test cleanup
}
+23
View File
@@ -0,0 +1,23 @@
package server
import (
"encoding/json"
"net/http"
)
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 writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(r *http.Request, dest any) error {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dest)
}
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"context"
"net/http"
"strings"
"task/internal/sqlite"
)
type ctxKey int
const (
userKey ctxKey = 1
tokenAuthKey ctxKey = 2
)
func (s *Server) currentUser(r *http.Request) (sqlite.User, bool) {
u, ok := r.Context().Value(userKey).(sqlite.User)
return u, ok
}
func (s *Server) currentTokenAuth(r *http.Request) (sqlite.TokenAuth, bool) {
t, ok := r.Context().Value(tokenAuthKey).(sqlite.TokenAuth)
return t, ok
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := s.authenticate(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r.WithContext(ctx))
}
}
func (s *Server) authenticate(r *http.Request) (context.Context, bool) {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
raw := strings.TrimSpace(auth[len("Bearer "):])
ta, err := s.DB.ResolveBearerToken(raw)
if err != nil {
return r.Context(), false
}
ctx := context.WithValue(r.Context(), userKey, ta.User)
ctx = context.WithValue(ctx, tokenAuthKey, ta)
return ctx, true
}
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return r.Context(), false
}
u, err := s.DB.UserFromSession(c.Value)
if err != nil {
return r.Context(), false
}
u.PasswordHash = ""
return context.WithValue(r.Context(), userKey, u), true
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "admin required")
return
}
u, _ := s.currentUser(r)
if !u.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
next(w, r)
})
}
func (s *Server) requireProjectRole(projectID string, minRole string, r *http.Request) (sqlite.User, bool, int) {
u, ok := s.currentUser(r)
if !ok {
return sqlite.User{}, false, http.StatusUnauthorized
}
if ta, ok := s.currentTokenAuth(r); ok {
if ta.ProjectID != projectID {
return u, false, http.StatusForbidden
}
need := sqlite.TokenScopeRead
if minRole == sqlite.RoleMember || minRole == sqlite.RoleAdmin || minRole == sqlite.RoleOwner {
need = sqlite.TokenScopeWrite
}
// viewer → read; member+ → write
if minRole == sqlite.RoleViewer {
need = sqlite.TokenScopeRead
}
if !sqlite.TokenAllows(ta.Scope, need) {
return u, false, http.StatusForbidden
}
return u, true, 0
}
if u.IsAdmin {
return u, true, 0
}
role, err := s.DB.MemberRole(projectID, u.ID)
if err != nil || role == "" {
return u, false, http.StatusForbidden
}
if !sqlite.RoleAtLeast(role, minRole) {
return u, false, http.StatusForbidden
}
return u, true, 0
}
+139
View File
@@ -0,0 +1,139 @@
package server
import (
"net/http"
"strings"
"task/internal/sqlite"
)
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Name = strings.TrimSpace(body.Name)
if body.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
p, err := s.Projects.Create("", body.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
p, err := s.Projects.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Projects.Rename(id, body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
p, _ := s.Projects.Get(id)
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleOwner, r); !ok {
writeError(w, code, "forbidden")
return
}
_ = s.Times.DeleteByProject(id)
if err := s.Projects.Delete(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
members, err := s.DB.ListMembers(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
userID := r.PathValue("userId")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Role string `json:"role"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.DB.SetMember(id, userID, body.Role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
userID := r.PathValue("userId")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if err := s.DB.RemoveMember(id, userID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"net/http"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
groupBy := ticket.GroupBy(r.URL.Query().Get("groupBy"))
if groupBy == "" {
groupBy = ticket.GroupByTicket
}
valid := false
for _, g := range ticket.AllGroupBys {
if g == groupBy {
valid = true
break
}
}
if !valid {
writeError(w, http.StatusBadRequest, "invalid groupBy")
return
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tickets, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
byID := make(map[string]ticket.Ticket, len(tickets))
for _, t := range tickets {
byID[t.ID] = t
}
rows := ticket.Aggregate(entries, byID, groupBy)
total := ticket.TotalDuration(entries)
type rowJSON struct {
Key string `json:"key"`
Label string `json:"label"`
DurationNs int64 `json:"durationNs"`
}
out := make([]rowJSON, 0, len(rows))
for _, row := range rows {
out = append(out, rowJSON{
Key: row.Key,
Label: row.Label,
DurationNs: int64(row.Duration),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"groupBy": groupBy,
"totalDurationNs": int64(total),
"rows": out,
"entryCount": len(entries),
})
}
+130
View File
@@ -0,0 +1,130 @@
package server
import (
"fmt"
"os"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
func (s *Server) SeedDemo() error {
projects, err := s.Projects.List()
if err != nil {
return err
}
if len(projects) > 0 {
return nil
}
users, err := s.DB.ListUsers()
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("seed: no users (bootstrap admin first)")
}
admin := users[0]
for _, u := range users {
if u.IsAdmin {
admin = u
break
}
}
member, err := s.DB.CreateUser("alice", "alice", false)
if err != nil {
member, err = s.DB.GetUserByUsername("alice")
if err != nil {
return fmt.Errorf("seed alice: %w", err)
}
}
type seedTicket struct {
Title, Desc string
Status ticket.Status
}
demos := []struct {
Name string
Tickets []seedTicket
}{
{
Name: "Test",
Tickets: []seedTicket{
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
{Title: "TUI anbinden", Desc: "Settings → Token (write) erzeugen → in `tm` Projekt-Edit backend=taskmaster eintragen (URL, Token, diese Project-ID).", Status: ticket.StatusTodo},
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
},
},
{
Name: "Website Relaunch",
Tickets: []seedTicket{
{Title: "Landing Hero entwerfen", Desc: "Dunkles Layout, klare CTAs.\n\n- [ ] Moodboard\n- [ ] Wireframe", Status: ticket.StatusInProgress},
{Title: "CMS-Inhalte migrieren", Desc: "Alte Seiten nach Markdown überführen.", Status: ticket.StatusTodo},
{Title: "Impressum & Datenschutz", Desc: "Rechtstexte finalisieren.", Status: ticket.StatusDone},
},
},
{
Name: "API Platform",
Tickets: []seedTicket{
{Title: "Auth-Middleware härten", Desc: "Session-Cookies dokumentieren.", Status: ticket.StatusInProgress},
{Title: "Rate-Limits festlegen", Desc: "Pro User und pro IP.", Status: ticket.StatusTodo},
{Title: "Health-Check", Desc: "Bereits live.", Status: ticket.StatusDone},
},
},
}
now := time.Now().UTC()
for _, d := range demos {
p, err := s.Projects.Create("", d.Name)
if err != nil {
return err
}
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
// Explicit local-only sync (no remote backend in the test environment).
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
ProjectID: p.ID,
Kind: "none",
})
var firstID string
for i, st := range d.Tickets {
t, err := s.Tickets.Create(ticket.Ticket{
Title: st.Title,
Description: st.Desc,
Status: st.Status,
ProjectID: p.ID,
})
if err != nil {
return err
}
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "seed", admin.ID, t)
if i == 0 {
firstID = t.ID
}
}
if firstID != "" {
_, err := s.Times.Create(ticket.TimeEntry{
TicketID: firstID,
ProjectID: p.ID,
Start: now.Add(-2 * time.Hour),
End: now.Add(-90 * time.Minute),
Duration: 30 * time.Minute,
Source: ticket.SourceManual,
Note: "seed",
})
if err != nil {
return err
}
}
}
fmt.Fprintf(os.Stderr, "seeded demo: Test + sample projects (users %s + alice/alice), sync=none\n", admin.Username)
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package server
import (
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestSeedDemo(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "seed.db"), []byte("s"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "admin", true); err != nil {
t.Fatal(err)
}
srv := New(db)
if err := srv.SeedDemo(); err != nil {
t.Fatal(err)
}
projects, err := srv.Projects.List()
if err != nil || len(projects) < 3 {
t.Fatalf("projects=%v err=%v", projects, err)
}
var testID string
for _, p := range projects {
if p.Name == "Test" {
testID = p.ID
break
}
}
if testID == "" {
t.Fatal("missing Test project")
}
sync, err := srv.Projects.GetSyncBackend(testID)
if err != nil || sync.Kind != "none" {
t.Fatalf("sync=%+v err=%v", sync, err)
}
tickets, err := srv.Tickets.List(testID)
if err != nil || len(tickets) == 0 {
t.Fatalf("tickets empty")
}
conns, err := db.ListConnections()
if err != nil || len(conns) != 0 {
t.Fatalf("expected no remote connections, got %v", conns)
}
// idempotent when projects already exist
if err := srv.SeedDemo(); err != nil {
t.Fatal(err)
}
projects2, _ := srv.Projects.List()
if len(projects2) != len(projects) {
t.Fatalf("re-seed changed project count")
}
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"io/fs"
"net/http"
"strings"
"time"
"task/internal/sqlite"
"task/internal/syncbackend"
"task/internal/webembed"
)
// Server is the HTTP API + embedded UI.
type Server struct {
DB *sqlite.DB
Projects *sqlite.ProjectStore
Tickets *sqlite.TicketStore
Times *sqlite.TimeStore
Sync *syncbackend.Registry
Secure bool // set Secure on cookies when behind TLS
Static fs.FS
}
// New constructs a server from an open DB.
func New(db *sqlite.DB) *Server {
return &Server{
DB: db,
Projects: sqlite.NewProjectStore(db),
Tickets: sqlite.NewTicketStore(db),
Times: sqlite.NewTimeStore(db),
Sync: syncbackend.NewRegistry(),
Static: webembed.FS(),
}
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return withCORS(mux)
}
func (s *Server) routes(mux *http.ServeMux) {
api := http.NewServeMux()
api.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
api.HandleFunc("POST /auth/login", s.handleLogin)
api.HandleFunc("POST /auth/logout", s.requireAuth(s.handleLogout))
api.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
api.HandleFunc("GET /users", s.requireAdmin(s.handleListUsers))
api.HandleFunc("POST /users", s.requireAdmin(s.handleCreateUser))
api.HandleFunc("PATCH /users/{id}", s.requireAdmin(s.handlePatchUser))
api.HandleFunc("DELETE /users/{id}", s.requireAdmin(s.handleDeleteUser))
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
api.HandleFunc("PATCH /projects/{id}", s.requireAuth(s.handlePatchProject))
api.HandleFunc("DELETE /projects/{id}", s.requireAuth(s.handleDeleteProject))
api.HandleFunc("GET /projects/{id}/members", s.requireAuth(s.handleListMembers))
api.HandleFunc("PUT /projects/{id}/members/{userId}", s.requireAuth(s.handleSetMember))
api.HandleFunc("DELETE /projects/{id}/members/{userId}", s.requireAuth(s.handleRemoveMember))
api.HandleFunc("GET /projects/{id}/tokens", s.requireAuth(s.handleListTokens))
api.HandleFunc("POST /projects/{id}/tokens", s.requireAuth(s.handleCreateToken))
api.HandleFunc("DELETE /projects/{id}/tokens/{tid}", s.requireAuth(s.handleRevokeToken))
api.HandleFunc("GET /projects/{id}/sync", s.requireAuth(s.handleGetSync))
api.HandleFunc("PUT /projects/{id}/sync", s.requireAuth(s.handlePutSync))
api.HandleFunc("POST /projects/{id}/sync/pull", s.requireAuth(s.handleSyncPull))
api.HandleFunc("POST /projects/{id}/sync/push", s.requireAuth(s.handleSyncPush))
api.HandleFunc("GET /projects/{id}/tickets", s.requireAuth(s.handleListTickets))
api.HandleFunc("POST /projects/{id}/tickets", s.requireAuth(s.handleCreateTicket))
api.HandleFunc("GET /projects/{id}/tickets/{tid}", s.requireAuth(s.handleGetTicket))
api.HandleFunc("PATCH /projects/{id}/tickets/{tid}", s.requireAuth(s.handlePatchTicket))
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
api.HandleFunc("GET /projects/{id}/report", s.requireAuth(s.handleProjectReport))
api.HandleFunc("GET /projects/{id}/time", s.requireAuth(s.handleListProjectTime))
api.HandleFunc("POST /projects/{id}/time", s.requireAuth(s.handleCreateTime))
api.HandleFunc("PATCH /projects/{id}/time/{eid}", s.requireAuth(s.handleUpdateTime))
api.HandleFunc("DELETE /projects/{id}/time/{eid}", s.requireAuth(s.handleDeleteTime))
api.HandleFunc("GET /timer", s.requireAuth(s.handleGetTimer))
api.HandleFunc("POST /timer/start", s.requireAuth(s.handleStartTimer))
api.HandleFunc("POST /timer/stop", s.requireAuth(s.handleStopTimer))
api.HandleFunc("GET /connections", s.requireAdmin(s.handleListConnections))
api.HandleFunc("POST /connections", s.requireAdmin(s.handleCreateConnection))
api.HandleFunc("DELETE /connections/{id}", s.requireAdmin(s.handleDeleteConnection))
api.HandleFunc("GET /connections/{id}/projects", s.requireAdmin(s.handleConnectionProjects))
api.HandleFunc("POST /connections/{id}/import", s.requireAdmin(s.handleImportProject))
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
mux.Handle("/", s.spaHandler())
}
const sessionCookie = "tm_session"
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.Secure,
Expires: expires,
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.Secure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) spaHandler() http.Handler {
static := s.Static
if static == nil {
static = webembed.FS()
}
fileServer := http.FileServer(http.FS(static))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if f, err := static.Open(path); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback
r2 := r.Clone(r.Context())
r2.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r2)
})
}
+90
View File
@@ -0,0 +1,90 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestAPIAuthAndTickets(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()
if len(cookie) == 0 {
t.Fatal("no session cookie")
}
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie[0])
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
if pid == "" {
t.Fatal("expected auto project id")
}
ticketBody, _ := json.Marshal(map[string]string{"title": "First", "description": "hi"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie[0])
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
if tid == "" {
t.Fatal("no ticket id")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/revisions", nil)
req.AddCookie(cookie[0])
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("revisions %d", rec.Code)
}
var revs []any
_ = json.Unmarshal(rec.Body.Bytes(), &revs)
if len(revs) < 1 {
t.Fatal("expected revision")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health %d", rec.Code)
}
}
+237
View File
@@ -0,0 +1,237 @@
package server
import (
"net/http"
"strings"
"task/internal/sqlite"
"task/internal/syncbackend"
)
func (s *Server) handleGetSync(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
b, err := s.Projects.GetSyncBackend(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, b)
}
func (s *Server) handlePutSync(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body sqlite.SyncBackend
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.ProjectID = id
if body.Kind == "" {
body.Kind = "none"
}
if err := s.Projects.SetSyncBackend(body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, body)
}
func (s *Server) projectSyncConfig(projectID string) (syncbackend.ProjectConfig, error) {
b, err := s.Projects.GetSyncBackend(projectID)
if err != nil {
return syncbackend.ProjectConfig{}, err
}
cfg := syncbackend.ProjectConfig{
Kind: b.Kind,
BaseURL: b.BaseURL,
ProjectKey: b.ProjectKey,
Query: b.Query,
}
if b.ConnectionID != "" {
conn, err := s.DB.GetConnection(b.ConnectionID)
if err != nil {
return cfg, err
}
cfg.Token = conn.Token
if cfg.BaseURL == "" {
cfg.BaseURL = conn.BaseURL
}
}
return cfg, nil
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, true)
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, false)
}
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, pull bool) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
cfg, err := s.projectSyncConfig(id)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
backend, err := s.Sync.Get(cfg.Kind)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var res syncbackend.Result
if pull {
res, err = backend.Pull(r.Context(), id, cfg, s.Tickets, s.Times)
} else {
res, err = backend.Push(r.Context(), id, cfg, s.Tickets, s.Times)
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
op := "sync_push"
if pull {
op = "sync_pull"
}
tickets, _ := s.Tickets.List(id)
for _, t := range tickets {
_, _ = s.DB.AppendRevision("ticket", t.ID, id, op, u.ID, t)
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListConnections()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateConnection(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Scope string `json:"scope"`
Token string `json:"token"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Kind == "" {
body.Kind = "youtrack"
}
c, err := s.DB.CreateConnection(body.Name, body.Kind, body.BaseURL, body.Scope, body.Token, u.ID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, c)
}
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.DB.DeleteConnection(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleConnectionProjects(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
conn, err := s.DB.GetConnection(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
backend, err := s.Sync.Get(conn.Kind)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
projects, err := backend.ListProjects(r.Context(), syncbackend.Connection{
Kind: conn.Kind, BaseURL: conn.BaseURL, Token: conn.Token, Scope: conn.Scope,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleImportProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
connID := r.PathValue("id")
var body struct {
RemoteKey string `json:"remoteKey"`
LocalID string `json:"localId"`
Name string `json:"name"`
Pull bool `json:"pull"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.RemoteKey = strings.TrimSpace(body.RemoteKey)
if body.RemoteKey == "" {
writeError(w, http.StatusBadRequest, "remoteKey required")
return
}
conn, err := s.DB.GetConnection(connID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
name := body.Name
if name == "" {
name = body.RemoteKey
}
// LocalID optional; empty → auto UUID. Explicit id only for advanced clients.
p, err := s.Projects.Create(strings.TrimSpace(body.LocalID), name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
ProjectID: p.ID,
Kind: conn.Kind,
BaseURL: conn.BaseURL,
ProjectKey: body.RemoteKey,
ConnectionID: conn.ID,
})
result := map[string]any{"project": p}
if body.Pull {
cfg, err := s.projectSyncConfig(p.ID)
if err == nil {
if backend, err := s.Sync.Get(cfg.Kind); err == nil {
res, err := backend.Pull(r.Context(), p.ID, cfg, s.Tickets, s.Times)
if err != nil {
result["pullError"] = err.Error()
} else {
result["pull"] = res
}
}
}
}
writeJSON(w, http.StatusCreated, result)
}
+234
View File
@@ -0,0 +1,234 @@
package server
import (
"encoding/json"
"net/http"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
list, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Title string `json:"title"`
Description string `json:"description"`
Status ticket.Status `json:"status"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
t, err := s.Tickets.Create(ticket.Ticket{
Title: body.Title,
Description: body.Description,
Status: body.Status,
ProjectID: id,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "create", u.ID, t)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handlePatchTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
var body struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *ticket.Status `json:"status"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Title != nil {
t.Title = *body.Title
}
if body.Description != nil {
t.Description = *body.Description
}
if body.Status != nil {
t.Status = *body.Status
}
if err := s.Tickets.Update(t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
t, _ = s.Tickets.Get(tid)
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "update", u.ID, t)
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "delete", u.ID, t)
if err := s.Tickets.Delete(tid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
ToProjectID string `json:"toProjectId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, ok, code := s.requireProjectRole(body.ToProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden target project")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
moved, err := s.Tickets.Move(tid, body.ToProjectID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.Times.RetargetTicket(tid, body.ToProjectID)
_, _ = s.DB.AppendRevision("ticket", moved.ID, body.ToProjectID, "move", u.ID, moved)
writeJSON(w, http.StatusOK, moved)
}
func (s *Server) handleListTicketRevisions(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
revs, err := s.DB.ListRevisions("ticket", tid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, revs)
}
func (s *Server) handleRestoreTicketRevision(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
rid := r.PathValue("rid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var revID int64
if _, err := parseInt64(rid, &revID); err != nil {
writeError(w, http.StatusBadRequest, "invalid revision id")
return
}
rev, err := s.DB.GetRevision(revID)
if err != nil || rev.EntityID != tid {
writeError(w, http.StatusNotFound, "revision not found")
return
}
var snap ticket.Ticket
if err := json.Unmarshal(rev.Snapshot, &snap); err != nil {
writeError(w, http.StatusInternalServerError, "bad snapshot")
return
}
snap.ID = tid
snap.ProjectID = id
if err := s.Tickets.Update(snap); err != nil {
// recreate if deleted
if _, cerr := s.Tickets.Create(snap); cerr != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
t, _ := s.Tickets.Get(tid)
_, _ = s.DB.AppendRevision("ticket", tid, id, "restore", u.ID, t)
writeJSON(w, http.StatusOK, t)
}
func parseInt64(s string, dest *int64) (int64, error) {
var n int64
for _, c := range s {
if c < '0' || c > '9' {
return 0, errInvalid
}
n = n*10 + int64(c-'0')
}
*dest = n
return n, nil
}
type invalidErr struct{}
func (invalidErr) Error() string { return "invalid" }
var errInvalid = invalidErr{}
+197
View File
@@ -0,0 +1,197 @@
package server
import (
"net/http"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleListProjectTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
list, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
TicketID string `json:"ticketId"`
Duration int64 `json:"durationNs"`
Note string `json:"note"`
Source string `json:"source"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: body.TicketID,
ProjectID: id,
Duration: time.Duration(body.Duration),
Note: body.Note,
Source: body.Source,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "create", u.ID, e)
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Duration int64 `json:"durationNs"`
Note string `json:"note"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var found *ticket.TimeEntry
for i := range entries {
if entries[i].ID == eid {
found = &entries[i]
break
}
}
if found == nil {
writeError(w, http.StatusNotFound, "not found")
return
}
if body.Duration > 0 {
found.Duration = time.Duration(body.Duration)
}
found.Note = body.Note
if err := s.Times.Update(*found); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", found.ID, id, "update", u.ID, found)
writeJSON(w, http.StatusOK, found)
}
func (s *Server) handleDeleteTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
entries, _ := s.Times.ListByProject(id)
for _, e := range entries {
if e.ID == eid {
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "delete", u.ID, e)
break
}
}
if err := s.Times.Delete(eid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
t, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if t == nil {
writeJSON(w, http.StatusOK, map[string]any{"timer": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{"timer": t})
}
func (s *Server) handleStartTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, ok, code := s.requireProjectRole(body.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
t := &ticket.ActiveTimer{
TicketID: body.TicketID,
ProjectID: body.ProjectID,
StartedAt: time.Now().UTC(),
}
if err := s.Times.SaveActiveTimer(t); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = u
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleStopTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
active, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if active == nil {
writeError(w, http.StatusBadRequest, "no active timer")
return
}
if _, ok, code := s.requireProjectRole(active.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
dur := time.Since(active.StartedAt)
if dur < time.Second {
dur = time.Second
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: active.TicketID,
ProjectID: active.ProjectID,
Start: active.StartedAt,
End: time.Now().UTC(),
Duration: dur,
Source: ticket.SourceTimer,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.Times.SaveActiveTimer(nil)
_, _ = s.DB.AppendRevision("time_entry", e.ID, active.ProjectID, "create", u.ID, e)
writeJSON(w, http.StatusOK, e)
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"net/http"
"task/internal/sqlite"
)
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
list, err := s.DB.ListProjectTokens(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r)
if !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
var body struct {
Name string `json:"name"`
Scope string `json:"scope"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Scope == "" {
body.Scope = sqlite.TokenScopeWrite
}
t, err := s.DB.CreateProjectToken(id, body.Name, body.Scope, u.ID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
if err := s.DB.RevokeProjectToken(id, tid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
me, _ := s.currentUser(r)
var body struct {
IsAdmin *bool `json:"isAdmin"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.IsAdmin == nil {
writeError(w, http.StatusBadRequest, "isAdmin required")
return
}
if id == me.ID && !*body.IsAdmin {
writeError(w, http.StatusBadRequest, "cannot remove your own admin flag")
return
}
if err := s.DB.SetUserAdmin(id, *body.IsAdmin); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
u, err := s.DB.GetUserByID(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
u.PasswordHash = ""
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
me, _ := s.currentUser(r)
if id == me.ID {
writeError(w, http.StatusBadRequest, "cannot delete yourself")
return
}
if err := s.DB.DeleteUser(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleDashboard returns a lightweight overview for the front page.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type openTicket struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
ProjectID string `json:"projectId"`
Project string `json:"projectName"`
}
type projSum struct {
ID string `json:"id"`
Name string `json:"name"`
OpenCount int `json:"openCount"`
DurationNs int64 `json:"durationNs"`
}
open := make([]openTicket, 0)
sums := make([]projSum, 0, len(projects))
for _, p := range projects {
tickets, err := s.Tickets.List(p.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
openCount := 0
for _, t := range tickets {
if string(t.Status) != "done" {
openCount++
if len(open) < 20 {
open = append(open, openTicket{
ID: t.ID, Title: t.Title, Status: string(t.Status),
ProjectID: p.ID, Project: p.Name,
})
}
}
}
entries, _ := s.Times.ListByProject(p.ID)
var dur int64
for _, e := range entries {
dur += int64(e.Duration)
}
sums = append(sums, projSum{ID: p.ID, Name: p.Name, OpenCount: openCount, DurationNs: dur})
}
writeJSON(w, http.StatusOK, map[string]any{
"projects": sums,
"openTickets": open,
})
}
+104
View File
@@ -0,0 +1,104 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestProjectTokenBearerAuth(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
admin, err := db.CreateUser("admin", "pass", true)
if err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid := proj["id"].(string)
tokBody, _ := json.Marshal(map[string]string{"name": "tui", "scope": "write"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tokens", bytes.NewReader(tokBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create token %d %s", rec.Code, rec.Body.String())
}
var tok map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
raw := tok["token"].(string)
if raw == "" {
t.Fatal("expected raw token")
}
ticketBody, _ := json.Marshal(map[string]string{"title": "Via token"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+raw)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("bearer create ticket %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer list %d", rec.Code)
}
// delete other user
_, _ = db.CreateUser("bob", "x", false)
users, _ := db.ListUsers()
var bobID string
for _, u := range users {
if u.Username == "bob" {
bobID = u.ID
}
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/users/"+bobID, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete user %d %s", rec.Code, rec.Body.String())
}
patch, _ := json.Marshal(map[string]bool{"isAdmin": false})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/users/"+admin.ID, bytes.NewReader(patch))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatal("should not demote self")
}
}