blob: 0253f6dd7a10d831bc2a57194d6f93b8e54d03bd [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package server provides HTTP server functionality for the sketch loop.
2package server
3
4import (
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "html"
9 "io"
10 "io/fs"
11 "log/slog"
12 "net/http"
13 "net/http/pprof"
14 "os"
15 "os/exec"
16 "strconv"
17 "strings"
18 "sync"
19 "syscall"
20 "time"
21
22 "github.com/creack/pty"
23 "sketch.dev/ant"
24 "sketch.dev/loop"
25 "sketch.dev/loop/webui"
26)
27
28// terminalSession represents a terminal session with its PTY and the event channel
29type terminalSession struct {
30 pty *os.File
31 eventsClients map[chan []byte]bool
32 lastEventClientID int
33 eventsClientsMutex sync.Mutex
34 cmd *exec.Cmd
35}
36
37// TerminalMessage represents a message sent from the client for terminal resize events
38type TerminalMessage struct {
39 Type string `json:"type"`
40 Cols uint16 `json:"cols"`
41 Rows uint16 `json:"rows"`
42}
43
44// TerminalResponse represents the response for a new terminal creation
45type TerminalResponse struct {
46 SessionID string `json:"sessionId"`
47}
48
Sean McCulloughd9f13372025-04-21 15:08:49 -070049type State struct {
50 MessageCount int `json:"message_count"`
51 TotalUsage *ant.CumulativeUsage `json:"total_usage,omitempty"`
52 Hostname string `json:"hostname"`
53 WorkingDir string `json:"working_dir"`
54 InitialCommit string `json:"initial_commit"`
55 Title string `json:"title"`
56 OS string `json:"os"`
57}
58
Earl Lee2e463fb2025-04-17 11:22:22 -070059// Server serves sketch HTTP. Server implements http.Handler.
60type Server struct {
61 mux *http.ServeMux
62 agent loop.CodingAgent
63 hostname string
64 logFile *os.File
65 // Mutex to protect terminalSessions
66 ptyMutex sync.Mutex
67 terminalSessions map[string]*terminalSession
68}
69
70func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
71 s.mux.ServeHTTP(w, r)
72}
73
74// New creates a new HTTP server.
75func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
76 s := &Server{
77 mux: http.NewServeMux(),
78 agent: agent,
79 hostname: getHostname(),
80 logFile: logFile,
81 terminalSessions: make(map[string]*terminalSession),
82 }
83
84 webBundle, err := webui.Build()
85 if err != nil {
86 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
87 }
88
89 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
90 // Check if a specific commit hash was requested
91 commit := r.URL.Query().Get("commit")
92
93 // Get the diff, optionally for a specific commit
94 var diff string
95 var err error
96 if commit != "" {
97 // Validate the commit hash format
98 if !isValidGitSHA(commit) {
99 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
100 return
101 }
102
103 diff, err = agent.Diff(&commit)
104 } else {
105 diff, err = agent.Diff(nil)
106 }
107
108 if err != nil {
109 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
110 return
111 }
112
113 w.Header().Set("Content-Type", "text/plain")
114 w.Write([]byte(diff))
115 })
116
117 // Handler for initialization called by host sketch binary when inside docker.
118 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
119 defer func() {
120 if err := recover(); err != nil {
121 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
122
123 // Return an error response to the client
124 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
125 }
126 }()
127
128 if r.Method != "POST" {
129 http.Error(w, "POST required", http.StatusBadRequest)
130 return
131 }
132
133 body, err := io.ReadAll(r.Body)
134 r.Body.Close()
135 if err != nil {
136 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
137 return
138 }
139 m := make(map[string]string)
140 if err := json.Unmarshal(body, &m); err != nil {
141 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
142 return
143 }
144 hostAddr := m["host_addr"]
145 gitRemoteAddr := m["git_remote_addr"]
146 commit := m["commit"]
147 ini := loop.AgentInit{
148 WorkingDir: "/app",
149 InDocker: true,
150 Commit: commit,
151 GitRemoteAddr: gitRemoteAddr,
152 HostAddr: hostAddr,
153 }
154 if err := agent.Init(ini); err != nil {
155 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
156 return
157 }
158 w.Header().Set("Content-Type", "application/json")
159 io.WriteString(w, "{}\n")
160 })
161
162 // Handler for /messages?start=N&end=M (start/end are optional)
163 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
164 w.Header().Set("Content-Type", "application/json")
165
166 // Extract query parameters for range
167 var start, end int
168 var err error
169
170 currentCount := agent.MessageCount()
171
172 startParam := r.URL.Query().Get("start")
173 if startParam != "" {
174 start, err = strconv.Atoi(startParam)
175 if err != nil {
176 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
177 return
178 }
179 }
180
181 endParam := r.URL.Query().Get("end")
182 if endParam != "" {
183 end, err = strconv.Atoi(endParam)
184 if err != nil {
185 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
186 return
187 }
188 } else {
189 end = currentCount
190 }
191
192 if start < 0 || start > end || end > currentCount {
193 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
194 return
195 }
196
197 start = max(0, start)
198 end = min(agent.MessageCount(), end)
199 messages := agent.Messages(start, end)
200
201 // Create a JSON encoder with indentation for pretty-printing
202 encoder := json.NewEncoder(w)
203 encoder.SetIndent("", " ") // Two spaces for each indentation level
204
205 err = encoder.Encode(messages)
206 if err != nil {
207 http.Error(w, err.Error(), http.StatusInternalServerError)
208 }
209 })
210
211 // Handler for /logs - displays the contents of the log file
212 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
213 if s.logFile == nil {
214 http.Error(w, "log file not set", http.StatusNotFound)
215 return
216 }
217 logContents, err := os.ReadFile(s.logFile.Name())
218 if err != nil {
219 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
220 return
221 }
222 w.Header().Set("Content-Type", "text/html; charset=utf-8")
223 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
224 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
225 fmt.Fprintf(w, "</body>\n</html>")
226 })
227
228 // Handler for /download - downloads both messages and status as a JSON file
229 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
230 // Set headers for file download
231 w.Header().Set("Content-Type", "application/octet-stream")
232
233 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
234 timestamp := time.Now().Format("20060102-150405")
235 filename := fmt.Sprintf("sketch-%s.json", timestamp)
236
237 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
238
239 // Get all messages
240 messageCount := agent.MessageCount()
241 messages := agent.Messages(0, messageCount)
242
243 // Get status information (usage and other metadata)
244 totalUsage := agent.TotalUsage()
245 hostname := getHostname()
246 workingDir := getWorkingDir()
247
248 // Create a combined structure with all information
249 downloadData := struct {
250 Messages []loop.AgentMessage `json:"messages"`
251 MessageCount int `json:"message_count"`
252 TotalUsage ant.CumulativeUsage `json:"total_usage"`
253 Hostname string `json:"hostname"`
254 WorkingDir string `json:"working_dir"`
255 DownloadTime string `json:"download_time"`
256 }{
257 Messages: messages,
258 MessageCount: messageCount,
259 TotalUsage: totalUsage,
260 Hostname: hostname,
261 WorkingDir: workingDir,
262 DownloadTime: time.Now().Format(time.RFC3339),
263 }
264
265 // Marshal the JSON with indentation for better readability
266 jsonData, err := json.MarshalIndent(downloadData, "", " ")
267 if err != nil {
268 http.Error(w, err.Error(), http.StatusInternalServerError)
269 return
270 }
271 w.Write(jsonData)
272 })
273
274 // The latter doesn't return until the number of messages has changed (from seen
275 // or from when this was called.)
276 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
277 pollParam := r.URL.Query().Get("poll")
278 seenParam := r.URL.Query().Get("seen")
279
280 // Get the client's current message count (if provided)
281 clientMessageCount := -1
282 var err error
283 if seenParam != "" {
284 clientMessageCount, err = strconv.Atoi(seenParam)
285 if err != nil {
286 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
287 return
288 }
289 }
290
291 serverMessageCount := agent.MessageCount()
292
293 // Let lazy clients not have to specify this.
294 if clientMessageCount == -1 {
295 clientMessageCount = serverMessageCount
296 }
297
298 if pollParam == "true" {
299 ch := make(chan string)
300 go func() {
301 // This is your blocking operation
302 agent.WaitForMessageCount(r.Context(), clientMessageCount)
303 close(ch)
304 }()
305 select {
306 case <-r.Context().Done():
307 slog.DebugContext(r.Context(), "abandoned poll request")
308 return
309 case <-time.After(90 * time.Second):
310 // Let the user call /state again to get the latest to limit how long our long polls hang out.
311 slog.DebugContext(r.Context(), "longish poll request")
312 break
313 case <-ch:
314 break
315 }
316 }
317
318 serverMessageCount = agent.MessageCount()
319 totalUsage := agent.TotalUsage()
320
321 w.Header().Set("Content-Type", "application/json")
322
Sean McCulloughd9f13372025-04-21 15:08:49 -0700323 state := State{
Earl Lee2e463fb2025-04-17 11:22:22 -0700324 MessageCount: serverMessageCount,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700325 TotalUsage: &totalUsage,
Earl Lee2e463fb2025-04-17 11:22:22 -0700326 Hostname: s.hostname,
327 WorkingDir: getWorkingDir(),
328 InitialCommit: agent.InitialCommit(),
329 Title: agent.Title(),
330 OS: agent.OS(),
331 }
332
333 // Create a JSON encoder with indentation for pretty-printing
334 encoder := json.NewEncoder(w)
335 encoder.SetIndent("", " ") // Two spaces for each indentation level
336
337 err = encoder.Encode(state)
338 if err != nil {
339 http.Error(w, err.Error(), http.StatusInternalServerError)
340 }
341 })
342
343 s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(webBundle)))
344
345 // Terminal WebSocket handler
346 // Terminal endpoints - predefined terminals 1-9
347 // TODO: The UI doesn't actually know how to use terminals 2-9!
348 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
349 if r.Method != http.MethodGet {
350 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
351 return
352 }
353 pathParts := strings.Split(r.URL.Path, "/")
354 if len(pathParts) < 4 {
355 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
356 return
357 }
358
359 sessionID := pathParts[3]
360 // Validate that the terminal ID is between 1-9
361 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
362 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
363 return
364 }
365
366 s.handleTerminalEvents(w, r, sessionID)
367 })
368
369 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
370 if r.Method != http.MethodPost {
371 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
372 return
373 }
374 pathParts := strings.Split(r.URL.Path, "/")
375 if len(pathParts) < 4 {
376 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
377 return
378 }
379 sessionID := pathParts[3]
380 s.handleTerminalInput(w, r, sessionID)
381 })
382
383 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Sean McCullough86b56862025-04-18 13:04:03 -0700384 // Serve the sketch-app-shell.html file directly from the embedded filesystem
385 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700386 if err != nil {
387 http.Error(w, "File not found", http.StatusNotFound)
388 return
389 }
390 w.Header().Set("Content-Type", "text/html")
391 w.Write(data)
392 })
393
394 // Handler for POST /chat
395 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
396 if r.Method != http.MethodPost {
397 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
398 return
399 }
400
401 // Parse the request body
402 var requestBody struct {
403 Message string `json:"message"`
404 }
405
406 decoder := json.NewDecoder(r.Body)
407 if err := decoder.Decode(&requestBody); err != nil {
408 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
409 return
410 }
411 defer r.Body.Close()
412
413 if requestBody.Message == "" {
414 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
415 return
416 }
417
418 agent.UserMessage(r.Context(), requestBody.Message)
419
420 w.WriteHeader(http.StatusOK)
421 })
422
423 // Handler for /cancel - cancels the current inner loop in progress
424 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
425 if r.Method != http.MethodPost {
426 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
427 return
428 }
429
430 // Parse the request body (optional)
431 var requestBody struct {
432 Reason string `json:"reason"`
433 ToolCallID string `json:"tool_call_id"`
434 }
435
436 decoder := json.NewDecoder(r.Body)
437 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
438 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
439 return
440 }
441 defer r.Body.Close()
442
443 cancelReason := "user requested cancellation"
444 if requestBody.Reason != "" {
445 cancelReason = requestBody.Reason
446 }
447
448 if requestBody.ToolCallID != "" {
449 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
450 if err != nil {
451 http.Error(w, err.Error(), http.StatusBadRequest)
452 return
453 }
454 // Return a success response
455 w.Header().Set("Content-Type", "application/json")
456 json.NewEncoder(w).Encode(map[string]string{
457 "status": "cancelled",
458 "too_use_id": requestBody.ToolCallID,
459 "reason": cancelReason})
460 return
461 }
462 // Call the CancelInnerLoop method
463 agent.CancelInnerLoop(fmt.Errorf("%s", cancelReason))
464 // Return a success response
465 w.Header().Set("Content-Type", "application/json")
466 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
467 })
468
469 debugMux := initDebugMux()
470 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
471 debugMux.ServeHTTP(w, r)
472 })
473
474 return s, nil
475}
476
477// Utility functions
478func getHostname() string {
479 hostname, err := os.Hostname()
480 if err != nil {
481 return "unknown"
482 }
483 return hostname
484}
485
486func getWorkingDir() string {
487 wd, err := os.Getwd()
488 if err != nil {
489 return "unknown"
490 }
491 return wd
492}
493
494// createTerminalSession creates a new terminal session with the given ID
495func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
496 // Start a new shell process
497 shellPath := getShellPath()
498 cmd := exec.Command(shellPath)
499
500 // Get working directory from the agent if possible
501 workDir := getWorkingDir()
502 cmd.Dir = workDir
503
504 // Set up environment
505 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
506
507 // Start the command with a pty
508 ptmx, err := pty.Start(cmd)
509 if err != nil {
510 slog.Error("Failed to start pty", "error", err)
511 return nil, err
512 }
513
514 // Create the terminal session
515 session := &terminalSession{
516 pty: ptmx,
517 eventsClients: make(map[chan []byte]bool),
518 cmd: cmd,
519 }
520
521 // Start goroutine to read from pty and broadcast to all connected SSE clients
522 go s.readFromPtyAndBroadcast(sessionID, session)
523
524 return session, nil
525} // handleTerminalEvents handles SSE connections for terminal output
526func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
527 // Check if the session exists, if not, create it
528 s.ptyMutex.Lock()
529 session, exists := s.terminalSessions[sessionID]
530
531 if !exists {
532 // Create a new terminal session
533 var err error
534 session, err = s.createTerminalSession(sessionID)
535 if err != nil {
536 s.ptyMutex.Unlock()
537 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
538 return
539 }
540
541 // Store the new session
542 s.terminalSessions[sessionID] = session
543 }
544 s.ptyMutex.Unlock()
545
546 // Set headers for SSE
547 w.Header().Set("Content-Type", "text/event-stream")
548 w.Header().Set("Cache-Control", "no-cache")
549 w.Header().Set("Connection", "keep-alive")
550 w.Header().Set("Access-Control-Allow-Origin", "*")
551
552 // Create a channel for this client
553 events := make(chan []byte, 4096) // Buffer to prevent blocking
554
555 // Register this client's channel
556 session.eventsClientsMutex.Lock()
557 clientID := session.lastEventClientID + 1
558 session.lastEventClientID = clientID
559 session.eventsClients[events] = true
560 session.eventsClientsMutex.Unlock()
561
562 // When the client disconnects, remove their channel
563 defer func() {
564 session.eventsClientsMutex.Lock()
565 delete(session.eventsClients, events)
566 close(events)
567 session.eventsClientsMutex.Unlock()
568 }()
569
570 // Flush to send headers to client immediately
571 if f, ok := w.(http.Flusher); ok {
572 f.Flush()
573 }
574
575 // Send events to the client as they arrive
576 for {
577 select {
578 case <-r.Context().Done():
579 return
580 case data := <-events:
581 // Format as SSE with base64 encoding
582 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
583
584 // Flush the data immediately
585 if f, ok := w.(http.Flusher); ok {
586 f.Flush()
587 }
588 }
589 }
590}
591
592// handleTerminalInput processes input to the terminal
593func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
594 // Check if the session exists
595 s.ptyMutex.Lock()
596 session, exists := s.terminalSessions[sessionID]
597 s.ptyMutex.Unlock()
598
599 if !exists {
600 http.Error(w, "Terminal session not found", http.StatusNotFound)
601 return
602 }
603
604 // Read the request body (terminal input or resize command)
605 body, err := io.ReadAll(r.Body)
606 if err != nil {
607 http.Error(w, "Failed to read request body", http.StatusBadRequest)
608 return
609 }
610
611 // Check if it's a resize message
612 if len(body) > 0 && body[0] == '{' {
613 var msg TerminalMessage
614 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
615 if msg.Cols > 0 && msg.Rows > 0 {
616 pty.Setsize(session.pty, &pty.Winsize{
617 Cols: msg.Cols,
618 Rows: msg.Rows,
619 })
620
621 // Respond with success
622 w.WriteHeader(http.StatusOK)
623 return
624 }
625 }
626 }
627
628 // Regular terminal input
629 _, err = session.pty.Write(body)
630 if err != nil {
631 slog.Error("Failed to write to pty", "error", err)
632 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
633 return
634 }
635
636 // Respond with success
637 w.WriteHeader(http.StatusOK)
638}
639
640// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
641func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
642 buf := make([]byte, 4096)
643 defer func() {
644 // Clean up when done
645 s.ptyMutex.Lock()
646 delete(s.terminalSessions, sessionID)
647 s.ptyMutex.Unlock()
648
649 // Close the PTY
650 session.pty.Close()
651
652 // Ensure process is terminated
653 if session.cmd.Process != nil {
654 session.cmd.Process.Signal(syscall.SIGTERM)
655 time.Sleep(100 * time.Millisecond)
656 session.cmd.Process.Kill()
657 }
658
659 // Close all client channels
660 session.eventsClientsMutex.Lock()
661 for ch := range session.eventsClients {
662 delete(session.eventsClients, ch)
663 close(ch)
664 }
665 session.eventsClientsMutex.Unlock()
666 }()
667
668 for {
669 n, err := session.pty.Read(buf)
670 if err != nil {
671 if err != io.EOF {
672 slog.Error("Failed to read from pty", "error", err)
673 }
674 break
675 }
676
677 // Make a copy of the data for each client
678 data := make([]byte, n)
679 copy(data, buf[:n])
680
681 // Broadcast to all connected clients
682 session.eventsClientsMutex.Lock()
683 for ch := range session.eventsClients {
684 // Try to send, but don't block if channel is full
685 select {
686 case ch <- data:
687 default:
688 // Channel is full, drop the message for this client
689 }
690 }
691 session.eventsClientsMutex.Unlock()
692 }
693}
694
695// getShellPath returns the path to the shell to use
696func getShellPath() string {
697 // Try to use the user's preferred shell
698 shell := os.Getenv("SHELL")
699 if shell != "" {
700 return shell
701 }
702
703 // Default to bash on Unix-like systems
704 if _, err := os.Stat("/bin/bash"); err == nil {
705 return "/bin/bash"
706 }
707
708 // Fall back to sh
709 return "/bin/sh"
710}
711
712func initDebugMux() *http.ServeMux {
713 mux := http.NewServeMux()
714 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
715 w.Header().Set("Content-Type", "text/html; charset=utf-8")
716 fmt.Fprintf(w, `<!doctype html>
717 <html><head><title>sketch debug</title></head><body>
718 <h1>sketch debug</h1>
719 <ul>
720 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
721 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
722 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
723 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
724 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
725 <li><a href="/debug/metrics">metrics</a></li>
726 </ul>
727 </body>
728 </html>
729 `)
730 })
731 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
732 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
733 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
734 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
735 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
736 return mux
737}
738
739// isValidGitSHA validates if a string looks like a valid git SHA hash.
740// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
741func isValidGitSHA(sha string) bool {
742 // Git SHA must be a hexadecimal string with at least 4 characters
743 if len(sha) < 4 || len(sha) > 40 {
744 return false
745 }
746
747 // Check if the string only contains hexadecimal characters
748 for _, char := range sha {
749 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
750 return false
751 }
752 }
753
754 return true
755}