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 Attachments *sqlite.AttachmentStore Sync *syncbackend.Registry DataDir string // root for on-disk attachments (cfg.DataDir) 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), Attachments: sqlite.NewAttachmentStore(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 /project-groups", s.requireAuth(s.handleListGroups)) api.HandleFunc("POST /project-groups", s.requireAuth(s.handleCreateGroup)) api.HandleFunc("PATCH /project-groups/{id}", s.requireAuth(s.handlePatchGroup)) api.HandleFunc("DELETE /project-groups/{id}", s.requireAuth(s.handleDeleteGroup)) 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}/attachments", s.requireAuth(s.handleListAttachments)) api.HandleFunc("POST /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleUploadAttachment)) api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleGetAttachment)) api.HandleFunc("PUT /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handlePutAttachment)) api.HandleFunc("DELETE /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleDeleteAttachment)) 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) }) }