// Package bridge provides a local HTTP API for browser extension // integration. The server is started when a vault is opened and // accepts events pushed by the browser extension. package bridge import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log" "net" "net/http" "sync" "time" ) // Server is a lightweight HTTP server for browser extension events. type Server struct { mu sync.RWMutex server *http.Server listener net.Listener handler EventHandler secret string running bool } // EventHandler is called when an event batch arrives. type EventHandler func(events []Event) // Event represents a single browser event (page visit, note capture, etc.). type Event struct { ID string `json:"id"` DeviceID string `json:"device_id,omitempty"` Type string `json:"type"` // page_visit, note_capture, screenshot URL string `json:"url"` Title string `json:"title"` Domain string `json:"domain"` ActiveSeconds int `json:"active_seconds,omitempty"` TSStart string `json:"ts_start,omitempty"` TSEnd string `json:"ts_end,omitempty"` TS string `json:"ts,omitempty"` SelectedText string `json:"selected_text,omitempty"` Note string `json:"note,omitempty"` Screenshot string `json:"screenshot,omitempty"` // base64 data URI } // EventBatch is the payload POSTed by the extension. type EventBatch struct { Version int `json:"version"` DeviceID string `json:"device_id"` Events []Event `json:"events"` } // Config holds bridge server settings. type Config struct { Port int `json:"port"` Secret string `json:"secret"` // auto-generated if empty AutoGenPort bool `json:"auto_gen_port"` // pick random available port } // DefaultConfig returns sensible defaults. func DefaultConfig() Config { return Config{ Port: 9786, AutoGenPort: false, } } // GenerateSecret creates a 32-char hex secret. func GenerateSecret() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // fallback to time-based return fmt.Sprintf("vs%x", time.Now().UnixNano()) } return hex.EncodeToString(b) } // NewServer creates a bridge server. // If cfg.Secret is empty, no authentication is required. func NewServer(cfg Config, handler EventHandler) *Server { return &Server{ secret: cfg.Secret, handler: handler, } } // Port returns the actual listening port (useful when AutoGenPort is true). func (s *Server) Port() int { s.mu.RLock() defer s.mu.RUnlock() if s.listener != nil { return s.listener.Addr().(*net.TCPAddr).Port } return 0 } // Secret returns the shared secret. func (s *Server) Secret() string { s.mu.RLock() defer s.mu.RUnlock() return s.secret } // Running returns true if the server is listening. func (s *Server) Running() bool { s.mu.RLock() defer s.mu.RUnlock() return s.running } // Start begins listening. Returns the actual port if AutoGenPort is used. func (s *Server) Start(cfg Config) (int, error) { s.mu.Lock() defer s.mu.Unlock() if s.running { // return existing port without re-reading (we already have the lock) if s.listener != nil { return s.listener.Addr().(*net.TCPAddr).Port, nil } return 0, nil } addr := fmt.Sprintf("127.0.0.1:%d", cfg.Port) if cfg.AutoGenPort { addr = "127.0.0.1:0" } listener, err := net.Listen("tcp", addr) if err != nil { return 0, fmt.Errorf("bridge listen: %w", err) } s.listener = listener actualPort := listener.Addr().(*net.TCPAddr).Port mux := http.NewServeMux() mux.HandleFunc("/api/ping", s.handlePing) mux.HandleFunc("/api/events", s.withAuth(s.handleEvents)) s.server = &http.Server{ Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } s.running = true go func() { if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed { log.Printf("[bridge] serve error: %v", err) } }() secretPreview := s.secret if len(secretPreview) > 8 { secretPreview = secretPreview[:8] + "..." } log.Printf("[bridge] listening on 127.0.0.1:%d (secret=%s)", actualPort, secretPreview) return actualPort, nil } // Stop shuts down the HTTP server. func (s *Server) Stop() { s.mu.Lock() defer s.mu.Unlock() if !s.running { return } if s.server != nil { _ = s.server.Close() } s.server = nil s.listener = nil s.running = false log.Println("[bridge] stopped") } // --- handlers --- func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", 405) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "status": "ok", "port": s.Port(), }) } func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var batch EventBatch if err := json.NewDecoder(r.Body).Decode(&batch); err != nil { http.Error(w, fmt.Sprintf("bad request: %v", err), 400) return } if batch.Version < 1 { http.Error(w, "unsupported version", 400) return } if len(batch.Events) == 0 { w.WriteHeader(204) return } if s.handler != nil { // Enrich events with device_id from the batch. for i := range batch.Events { if batch.Events[i].DeviceID == "" { batch.Events[i].DeviceID = batch.DeviceID } } s.handler(batch.Events) } log.Printf("[bridge] received %d events from device %s", len(batch.Events), batch.DeviceID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "status": "ok", "count": len(batch.Events), "version": batch.Version, }) } // withAuth wraps a handler with shared-secret authentication. func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("X-Verstak-Secret") if auth != s.secret { http.Error(w, "unauthorized", 401) return } next(w, r) } }