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
+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)
})
}