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