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