feat: require paired browser receiver
This commit is contained in:
@@ -14,14 +14,18 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"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 (
|
||||
capturePath = "/api/browser-inbox/v1/captures"
|
||||
DefaultAddr = "127.0.0.1:47731"
|
||||
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
|
||||
receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCaptureBodyBytes = 12 * 1024 * 1024
|
||||
@@ -44,6 +48,7 @@ const (
|
||||
type Receiver struct {
|
||||
bus *events.Bus
|
||||
workspaceProvider WorkspaceProvider
|
||||
optionsMu sync.RWMutex
|
||||
options Options
|
||||
}
|
||||
|
||||
@@ -112,6 +117,17 @@ func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProv
|
||||
return &Receiver{bus: bus, workspaceProvider: provider, options: options}
|
||||
}
|
||||
|
||||
// SetReceiverToken updates the active token without restarting the local server.
|
||||
func (r *Receiver) SetReceiverToken(token string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.optionsMu.Lock()
|
||||
defer r.optionsMu.Unlock()
|
||||
r.options.RequireToken = true
|
||||
r.options.ReceiverToken = strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func Start(addr string, receiver *Receiver) (*Server, error) {
|
||||
if receiver == nil {
|
||||
return nil, fmt.Errorf("receiver is required")
|
||||
@@ -213,10 +229,16 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
func (r *Receiver) validateReceiverToken(req *http.Request) error {
|
||||
if r == nil || !r.options.RequireToken {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.optionsMu.RLock()
|
||||
requireToken := r.options.RequireToken
|
||||
expected := strings.TrimSpace(r.options.ReceiverToken)
|
||||
r.optionsMu.RUnlock()
|
||||
if !requireToken {
|
||||
return nil
|
||||
}
|
||||
if expected == "" {
|
||||
return fmt.Errorf("receiver token required")
|
||||
}
|
||||
|
||||
@@ -281,6 +281,38 @@ func TestReceiverAcceptsPairedToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRotatesPairedToken(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
||||
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "old-token"})
|
||||
body := `{
|
||||
"schemaVersion": 1,
|
||||
"captureId": "capture-rotated-token",
|
||||
"capturedAt": "2026-06-27T00:00:00.000Z",
|
||||
"kind": "page",
|
||||
"page": {"url": "https://example.com"}
|
||||
}`
|
||||
|
||||
request := func(token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, capturePath, bytes.NewBufferString(body))
|
||||
req.Header.Set(receiverTokenHeader, token)
|
||||
res := httptest.NewRecorder()
|
||||
receiver.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
|
||||
if res := request("old-token"); res.Code != http.StatusAccepted {
|
||||
t.Fatalf("old token before rotation status = %d, want %d", res.Code, http.StatusAccepted)
|
||||
}
|
||||
receiver.SetReceiverToken("new-token")
|
||||
if res := request("old-token"); res.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old token after rotation status = %d, want %d", res.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if res := request("new-token"); res.Code != http.StatusAccepted {
|
||||
t.Fatalf("new token after rotation status = %d, want %d", res.Code, http.StatusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
||||
|
||||
Reference in New Issue
Block a user