verstak/internal/core/bridge/bridge.go

255 lines
6.0 KiB
Go

// 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 secret is empty, no authentication is required.
func NewServer(secret string, handler EventHandler) *Server {
return &Server{
secret: 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) {
// If no secret is configured, skip authentication
if s.secret == "" {
next(w, r)
return
}
auth := r.Header.Get("X-Verstak-Secret")
if auth != s.secret {
http.Error(w, "unauthorized", 401)
return
}
next(w, r)
}
}