// Package browserreceiver hosts the local HTTP protocol used by the browser extension. package browserreceiver import ( "context" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "net/url" "strings" "time" "github.com/verstak/verstak-desktop/internal/core/events" ) const capturePath = "/api/browser-inbox/v1/captures" const DefaultAddr = "127.0.0.1:47731" const receiverTokenHeader = "X-Verstak-Receiver-Token" const ( maxCaptureBodyBytes = 12 * 1024 * 1024 maxCaptureIDBytes = 256 maxCapturedAtBytes = 64 maxCaptureSourceBytes = 128 maxPageURLBytes = 4096 maxPageTitleBytes = 512 maxPageDomainBytes = 255 maxSelectionTextBytes = 20 * 1024 maxLinkTextBytes = 512 maxBrowserNameBytes = 64 maxFileNameBytes = 255 maxFileMimeBytes = 128 maxFileTextBytes = 2 * 1024 * 1024 maxFileBytes = 8 * 1024 * 1024 maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3) ) type Receiver struct { bus *events.Bus workspaceProvider WorkspaceProvider options Options } type WorkspaceProvider func() string type Options struct { RequireToken bool ReceiverToken string } type Server struct { listener net.Listener server *http.Server } type CapturePayload struct { SchemaVersion int `json:"schemaVersion"` CaptureID string `json:"captureId"` CapturedAt string `json:"capturedAt"` Source string `json:"source"` Kind string `json:"kind"` Page CapturePage `json:"page"` Selection *CaptureSelection `json:"selection,omitempty"` Link *CaptureLink `json:"link,omitempty"` File *CaptureFile `json:"file,omitempty"` Browser *CaptureBrowser `json:"browser,omitempty"` Context interface{} `json:"context,omitempty"` } type CapturePage struct { URL string `json:"url"` Title string `json:"title"` Domain string `json:"domain"` } type CaptureSelection struct { Text string `json:"text"` } type CaptureLink struct { URL string `json:"url"` Text string `json:"text"` } type CaptureFile struct { Name string `json:"name"` Mime string `json:"mime"` Size int64 `json:"size"` Text string `json:"text"` DataBase64 string `json:"dataBase64"` } type CaptureBrowser struct { Name string `json:"name"` } func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver { return NewWithOptions(bus, Options{}, providers...) } func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProvider) *Receiver { var provider WorkspaceProvider if len(providers) > 0 { provider = providers[0] } return &Receiver{bus: bus, workspaceProvider: provider, options: options} } func Start(addr string, receiver *Receiver) (*Server, error) { if receiver == nil { return nil, fmt.Errorf("receiver is required") } listener, err := net.Listen("tcp", addr) if err != nil { return nil, err } s := &Server{ listener: listener, server: &http.Server{ Handler: receiver, }, } go func() { if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed { log.Printf("[browserreceiver] serve: %v", err) } }() return s, nil } func (s *Server) URL() string { if s == nil || s.listener == nil { return "" } return "http://" + s.listener.Addr().String() } func (s *Server) Close() error { if s == nil || s.server == nil { return nil } return s.server.Shutdown(context.Background()) } func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") if req.URL.Path != capturePath { http.NotFound(w, req) return } if req.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) _ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) return } if err := r.validateReceiverToken(req); err != nil { writeError(w, http.StatusUnauthorized, err.Error()) return } defer req.Body.Close() decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes)) var payload CapturePayload if err := decoder.Decode(&payload); err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if err := decoder.Decode(&struct{}{}); err != io.EOF { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit") return } writeError(w, http.StatusBadRequest, "invalid JSON") return } if err := payload.Validate(); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } eventName := "browser.capture." + payload.Kind if r.bus == nil || !r.bus.HasSubscribers(eventName) { writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable") return } eventPayload := payload.EventPayload() r.annotateWorkspace(eventPayload) r.bus.Publish(events.Event{ Name: eventName, Timestamp: time.Now().UTC().Format(time.RFC3339Nano), Payload: eventPayload, }) w.WriteHeader(http.StatusAccepted) _ = json.NewEncoder(w).Encode(map[string]string{ "status": "accepted", "captureId": payload.CaptureID, }) } func (r *Receiver) validateReceiverToken(req *http.Request) error { if r == nil || !r.options.RequireToken { return nil } expected := strings.TrimSpace(r.options.ReceiverToken) if expected == "" { return fmt.Errorf("receiver token required") } supplied := strings.TrimSpace(req.Header.Get(receiverTokenHeader)) if supplied == "" { return fmt.Errorf("receiver token required") } if subtle.ConstantTimeCompare([]byte(supplied), []byte(expected)) != 1 { return fmt.Errorf("receiver token invalid") } return nil } func (r *Receiver) annotateWorkspace(payload map[string]interface{}) { if r == nil || r.workspaceProvider == nil || payload == nil { return } if _, ok := payload["workspaceRootPath"]; ok { return } workspaceRoot := strings.TrimSpace(r.workspaceProvider()) if workspaceRoot == "" { return } payload["workspaceRootPath"] = workspaceRoot payload["workspaceName"] = workspaceRoot } func (p CapturePayload) Validate() error { if p.SchemaVersion != 1 { return fmt.Errorf("unsupported schemaVersion") } if strings.TrimSpace(p.CaptureID) == "" { return fmt.Errorf("captureId is required") } if err := validateCaptureText(p.CaptureID, "captureId", maxCaptureIDBytes); err != nil { return err } if strings.TrimSpace(p.CapturedAt) == "" { return fmt.Errorf("capturedAt is required") } if err := validateCaptureText(p.CapturedAt, "capturedAt", maxCapturedAtBytes); err != nil { return err } if p.Kind != "page" && p.Kind != "selection" && p.Kind != "link" && p.Kind != "file" { return fmt.Errorf("unsupported kind") } if strings.TrimSpace(p.Page.URL) == "" { return fmt.Errorf("page.url is required") } if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil { return err } if err := validateCaptureText(p.Page.URL, "page.url", maxPageURLBytes); err != nil { return err } if err := validateCaptureText(p.Page.Title, "page.title", maxPageTitleBytes); err != nil { return err } if err := validateCaptureText(p.Page.Domain, "page.domain", maxPageDomainBytes); err != nil { return err } if p.Browser != nil { if err := validateCaptureText(p.Browser.Name, "browser.name", maxBrowserNameBytes); err != nil { return err } } if p.Kind == "selection" && (p.Selection == nil || strings.TrimSpace(p.Selection.Text) == "") { return fmt.Errorf("selection.text is required") } if p.Kind == "selection" { if err := validateCaptureText(p.Selection.Text, "selection.text", maxSelectionTextBytes); err != nil { return err } } if p.Kind == "link" { if p.Link == nil || strings.TrimSpace(p.Link.URL) == "" { return fmt.Errorf("link.url is required") } if err := validateCaptureText(p.Link.URL, "link.url", maxPageURLBytes); err != nil { return err } if err := validateCaptureText(p.Link.Text, "link.text", maxLinkTextBytes); err != nil { return err } } if p.Kind == "file" { if err := p.validateFile(); err != nil { return err } } return nil } func (p CapturePayload) validateFile() error { if p.File == nil || strings.TrimSpace(p.File.Name) == "" { return fmt.Errorf("file.name is required") } if p.File.Text == "" && strings.TrimSpace(p.File.DataBase64) == "" { return fmt.Errorf("file.text or file.dataBase64 is required") } if p.File.Size < 0 || p.File.Size > maxFileBytes { return fmt.Errorf("file.size exceeds limit") } if err := validateCaptureText(p.File.Name, "file.name", maxFileNameBytes); err != nil { return err } if err := validateCaptureText(p.File.Mime, "file.mime", maxFileMimeBytes); err != nil { return err } if err := validateCaptureText(p.File.Text, "file.text", maxFileTextBytes); err != nil { return err } dataBase64 := strings.TrimSpace(p.File.DataBase64) if dataBase64 == "" { return nil } if len(dataBase64) > maxFileDataBase64Bytes { return fmt.Errorf("file.dataBase64 exceeds limit") } data, err := base64.StdEncoding.DecodeString(dataBase64) if err != nil { return fmt.Errorf("file.dataBase64 is invalid") } if len(data) > maxFileBytes { return fmt.Errorf("file.dataBase64 exceeds limit") } return nil } func validateCaptureText(value, field string, maxBytes int) error { if len(value) > maxBytes { return fmt.Errorf("%s exceeds limit", field) } return nil } func (p CapturePayload) EventPayload() map[string]interface{} { pageURL := strings.TrimSpace(p.Page.URL) result := map[string]interface{}{ "captureId": strings.TrimSpace(p.CaptureID), "capturedAt": strings.TrimSpace(p.CapturedAt), "source": strings.TrimSpace(p.Source), "kind": p.Kind, "url": pageURL, "title": strings.TrimSpace(p.Page.Title), "domain": captureDomain(pageURL, p.Page.Domain), } if p.Browser != nil { result["browserName"] = strings.TrimSpace(p.Browser.Name) } if p.Context != nil { result["context"] = p.Context } switch p.Kind { case "selection": result["text"] = strings.TrimSpace(p.Selection.Text) case "link": linkURL := strings.TrimSpace(p.Link.URL) result["url"] = linkURL result["title"] = strings.TrimSpace(p.Link.Text) result["domain"] = captureDomain(linkURL, "") case "file": result["fileName"] = strings.TrimSpace(p.File.Name) result["fileMime"] = strings.TrimSpace(p.File.Mime) result["fileSize"] = p.File.Size result["fileText"] = p.File.Text result["fileDataBase64"] = strings.TrimSpace(p.File.DataBase64) } return result } func captureDomain(rawURL, fallback string) string { if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" { return u.Hostname() } return strings.TrimSpace(fallback) } func writeError(w http.ResponseWriter, status int, message string) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) }