Store uploads on disk under DataDir and expose them in the web ticket view so .xlsx files can be previewed and saved as cell values without YouTrack or TUI sync. Co-authored-by: Cursor <cursoragent@cursor.com>
189 lines
7.3 KiB
Go
189 lines
7.3 KiB
Go
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)
|
|
})
|
|
}
|