feat: require paired browser receiver
This commit is contained in:
parent
89c261824f
commit
0f293640f9
|
|
@ -439,6 +439,21 @@ export function createPluginAPI(pluginId) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
browserReceiver: {
|
||||||
|
pairing: function() {
|
||||||
|
assertActive('browserReceiver.pairing');
|
||||||
|
return callBackend(pluginId, 'browserReceiver.pairing', function() {
|
||||||
|
return App.PluginBrowserReceiverPairing(pluginId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rotateToken: function() {
|
||||||
|
assertActive('browserReceiver.rotateToken');
|
||||||
|
return callBackend(pluginId, 'browserReceiver.rotateToken', function() {
|
||||||
|
return App.PluginRotateBrowserReceiverToken(pluginId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
workbench: {
|
workbench: {
|
||||||
openResource: async function(request) {
|
openResource: async function(request) {
|
||||||
assertActive('workbench.openResource');
|
assertActive('workbench.openResource');
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@ export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
|
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
|
||||||
|
|
||||||
|
export function PluginRotateBrowserReceiverToken(arg1:string):Promise<Record<string, string>|string>;
|
||||||
|
|
||||||
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||||
|
|
||||||
export function PluginSyncDisconnect(arg1:string):Promise<string>;
|
export function PluginSyncDisconnect(arg1:string):Promise<string>;
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,14 @@ export function PluginSecretsWrite(arg1, arg2) {
|
||||||
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
|
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PluginBrowserReceiverPairing(arg1) {
|
||||||
|
return window['go']['api']['App']['PluginBrowserReceiverPairing'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginRotateBrowserReceiverToken(arg1) {
|
||||||
|
return window['go']['api']['App']['PluginRotateBrowserReceiverToken'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) {
|
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4);
|
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/browserreceiver"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
"github.com/verstak/verstak-desktop/internal/core/capability"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
|
|
@ -62,6 +63,7 @@ type App struct {
|
||||||
workbench *coreworkbench.Router
|
workbench *coreworkbench.Router
|
||||||
workspace *workspace.Manager
|
workspace *workspace.Manager
|
||||||
syncSvc *syncsvc.Service
|
syncSvc *syncsvc.Service
|
||||||
|
browserReceiver *browserreceiver.Receiver
|
||||||
secretsSession *coresecrets.VaultSession
|
secretsSession *coresecrets.VaultSession
|
||||||
fileWatcher *filewatcher.Service
|
fileWatcher *filewatcher.Service
|
||||||
debug bool
|
debug bool
|
||||||
|
|
@ -87,6 +89,7 @@ func NewApp(
|
||||||
pluginStateMgr *pluginstate.Manager,
|
pluginStateMgr *pluginstate.Manager,
|
||||||
workspaceMgr *workspace.Manager,
|
workspaceMgr *workspace.Manager,
|
||||||
syncService *syncsvc.Service,
|
syncService *syncsvc.Service,
|
||||||
|
browserReceiverService *browserreceiver.Receiver,
|
||||||
debugEnabled bool,
|
debugEnabled bool,
|
||||||
) *App {
|
) *App {
|
||||||
app := &App{
|
app := &App{
|
||||||
|
|
@ -104,6 +107,7 @@ func NewApp(
|
||||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||||
workspace: workspaceMgr,
|
workspace: workspaceMgr,
|
||||||
syncSvc: syncService,
|
syncSvc: syncService,
|
||||||
|
browserReceiver: browserReceiverService,
|
||||||
fileWatcher: filewatcher.NewService(bus, 0),
|
fileWatcher: filewatcher.NewService(bus, 0),
|
||||||
debug: debugEnabled,
|
debug: debugEnabled,
|
||||||
activityEvents: make(map[string]bool),
|
activityEvents: make(map[string]bool),
|
||||||
|
|
@ -2105,6 +2109,62 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string)
|
||||||
return string(data), ""
|
return string(data), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Browser Receiver API ──────────────────────────────────
|
||||||
|
|
||||||
|
func (a *App) requirePluginBrowserReceiverAccess(pluginID string) error {
|
||||||
|
_, err := a.requirePluginAccess(pluginID, "browser.receiver.manage")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) browserReceiverPairing() (map[string]string, error) {
|
||||||
|
if a.browserReceiver == nil {
|
||||||
|
return nil, fmt.Errorf("browser receiver is unavailable")
|
||||||
|
}
|
||||||
|
if a.appSettings == nil {
|
||||||
|
return nil, fmt.Errorf("app settings not initialized")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(a.appSettings.Get().BrowserReceiver.Token)
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("browser receiver token is unavailable")
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"receiverUrl": browserreceiver.DefaultCaptureURL,
|
||||||
|
"receiverToken": token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginBrowserReceiverPairing returns the local receiver settings for authorized plugins.
|
||||||
|
func (a *App) PluginBrowserReceiverPairing(pluginID string) (map[string]string, string) {
|
||||||
|
if err := a.requirePluginBrowserReceiverAccess(pluginID); err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
pairing, err := a.browserReceiverPairing()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
return pairing, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginRotateBrowserReceiverToken invalidates previous extension pairings.
|
||||||
|
func (a *App) PluginRotateBrowserReceiverToken(pluginID string) (map[string]string, string) {
|
||||||
|
if err := a.requirePluginBrowserReceiverAccess(pluginID); err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
if _, err := a.browserReceiverPairing(); err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
token, err := a.appSettings.RotateBrowserReceiverToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
a.browserReceiver.SetReceiverToken(token)
|
||||||
|
pairing, err := a.browserReceiverPairing()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
return pairing, ""
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Sync API ──────────────────────────────────────────────
|
// ─── Sync API ──────────────────────────────────────────────
|
||||||
|
|
||||||
func (a *App) requirePluginSyncAccess(pluginID string, remote bool) error {
|
func (a *App) requirePluginSyncAccess(pluginID string, remote bool) error {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/browserreceiver"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
"github.com/verstak/verstak-desktop/internal/core/capability"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
|
|
@ -2000,6 +2001,54 @@ func TestPluginSyncBridgeRequiresDeclaredPermissions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPluginBrowserReceiverPairingRequiresPermissionAndRotatesToken(t *testing.T) {
|
||||||
|
app := newBridgeTestApp(t)
|
||||||
|
app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if err := app.appSettings.Load(); err != nil {
|
||||||
|
t.Fatalf("settings Load: %v", err)
|
||||||
|
}
|
||||||
|
initialToken, err := app.appSettings.EnsureBrowserReceiverToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureBrowserReceiverToken: %v", err)
|
||||||
|
}
|
||||||
|
app.browserReceiver = browserreceiver.NewWithOptions(app.eventBus, browserreceiver.Options{
|
||||||
|
RequireToken: true,
|
||||||
|
ReceiverToken: initialToken,
|
||||||
|
})
|
||||||
|
app.plugins = append(app.plugins, plugin.Plugin{
|
||||||
|
Manifest: plugin.Manifest{
|
||||||
|
ID: "browser.local",
|
||||||
|
Name: "Browser Local",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Permissions: []string{"browser.receiver.manage"},
|
||||||
|
},
|
||||||
|
Status: plugin.StatusLoaded,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
pairing, errStr := app.PluginBrowserReceiverPairing("browser.local")
|
||||||
|
if errStr != "" {
|
||||||
|
t.Fatalf("PluginBrowserReceiverPairing: %s", errStr)
|
||||||
|
}
|
||||||
|
if pairing["receiverToken"] != initialToken {
|
||||||
|
t.Fatalf("pairing token = %q, want %q", pairing["receiverToken"], initialToken)
|
||||||
|
}
|
||||||
|
if pairing["receiverUrl"] != browserreceiver.DefaultCaptureURL {
|
||||||
|
t.Fatalf("pairing receiver URL = %q, want %q", pairing["receiverUrl"], browserreceiver.DefaultCaptureURL)
|
||||||
|
}
|
||||||
|
if _, errStr := app.PluginBrowserReceiverPairing("no.storage"); !strings.Contains(errStr, "browser.receiver.manage") {
|
||||||
|
t.Fatalf("missing permission error = %q", errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated, errStr := app.PluginRotateBrowserReceiverToken("browser.local")
|
||||||
|
if errStr != "" {
|
||||||
|
t.Fatalf("PluginRotateBrowserReceiverToken: %s", errStr)
|
||||||
|
}
|
||||||
|
if rotated["receiverToken"] == "" || rotated["receiverToken"] == initialToken {
|
||||||
|
t.Fatalf("rotated pairing token = %q, want new non-empty token", rotated["receiverToken"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPluginSyncStatusReportsPersistedError(t *testing.T) {
|
func TestPluginSyncStatusReportsPersistedError(t *testing.T) {
|
||||||
app := newBridgeTestApp(t)
|
app := newBridgeTestApp(t)
|
||||||
app.plugins = append(app.plugins,
|
app.plugins = append(app.plugins,
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,30 @@
|
||||||
package appsettings
|
package appsettings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application settings stored in ~/.config/verstak/config.json.
|
// Config represents the application settings stored in ~/.config/verstak/config.json.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
CurrentVaultPath string `json:"currentVaultPath"`
|
CurrentVaultPath string `json:"currentVaultPath"`
|
||||||
RecentVaults []string `json:"recentVaults"`
|
RecentVaults []string `json:"recentVaults"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
DevMode bool `json:"devMode"`
|
DevMode bool `json:"devMode"`
|
||||||
UserPluginsDir string `json:"userPluginsDir"`
|
UserPluginsDir string `json:"userPluginsDir"`
|
||||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||||
Sync SyncSettings `json:"sync,omitempty"`
|
Sync SyncSettings `json:"sync,omitempty"`
|
||||||
WindowState *WindowState `json:"windowState,omitempty"`
|
BrowserReceiver BrowserReceiverSettings `json:"browserReceiver,omitempty"`
|
||||||
LastOpenedAt string `json:"lastOpenedAt"`
|
WindowState *WindowState `json:"windowState,omitempty"`
|
||||||
|
LastOpenedAt string `json:"lastOpenedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkbenchPreferences struct {
|
type WorkbenchPreferences struct {
|
||||||
|
|
@ -44,6 +48,11 @@ type SyncSettings struct {
|
||||||
LastError string `json:"lastError,omitempty"`
|
LastError string `json:"lastError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BrowserReceiverSettings holds the installation-local browser capture pairing secret.
|
||||||
|
type BrowserReceiverSettings struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// WindowState stores the last window position and size.
|
// WindowState stores the last window position and size.
|
||||||
type WindowState struct {
|
type WindowState struct {
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
|
|
@ -205,6 +214,42 @@ func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
|
||||||
return m.saveLocked()
|
return m.saveLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureBrowserReceiverToken returns the persisted pairing token, creating it when absent.
|
||||||
|
func (m *Manager) EnsureBrowserReceiverToken() (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.updateBrowserReceiverTokenLocked(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateBrowserReceiverToken replaces the persisted pairing token.
|
||||||
|
func (m *Manager) RotateBrowserReceiverToken() (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.updateBrowserReceiverTokenLocked(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) updateBrowserReceiverTokenLocked(force bool) (string, error) {
|
||||||
|
if m.config == nil {
|
||||||
|
m.config = defaultConfig()
|
||||||
|
}
|
||||||
|
current := strings.TrimSpace(m.config.BrowserReceiver.Token)
|
||||||
|
if current != "" && !force {
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("generate browser receiver token: %w", err)
|
||||||
|
}
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
m.config.BrowserReceiver.Token = token
|
||||||
|
if err := m.saveLocked(); err != nil {
|
||||||
|
m.config.BrowserReceiver.Token = current
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetCurrentVault updates the current vault path and adds to recents.
|
// SetCurrentVault updates the current vault path and adds to recents.
|
||||||
func (m *Manager) SetCurrentVault(path string) error {
|
func (m *Manager) SetCurrentVault(path string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|
@ -262,6 +307,7 @@ func copyConfig(c *Config) *Config {
|
||||||
UserPluginsDir: c.UserPluginsDir,
|
UserPluginsDir: c.UserPluginsDir,
|
||||||
Workbench: c.Workbench,
|
Workbench: c.Workbench,
|
||||||
Sync: c.Sync,
|
Sync: c.Sync,
|
||||||
|
BrowserReceiver: c.BrowserReceiver,
|
||||||
LastOpenedAt: c.LastOpenedAt,
|
LastOpenedAt: c.LastOpenedAt,
|
||||||
}
|
}
|
||||||
if c.WindowState != nil {
|
if c.WindowState != nil {
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,43 @@ func TestLoad_CorruptConfig(t *testing.T) {
|
||||||
// Just verify no panic
|
// Just verify no panic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrowserReceiverTokenPersistsAndRotates(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
manager := NewManager(path)
|
||||||
|
if err := manager.Load(); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
firstToken, err := manager.EnsureBrowserReceiverToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureBrowserReceiverToken: %v", err)
|
||||||
|
}
|
||||||
|
if firstToken == "" {
|
||||||
|
t.Fatal("EnsureBrowserReceiverToken returned an empty token")
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded := NewManager(path)
|
||||||
|
if err := reloaded.Load(); err != nil {
|
||||||
|
t.Fatalf("reload settings: %v", err)
|
||||||
|
}
|
||||||
|
persistedToken, err := reloaded.EnsureBrowserReceiverToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureBrowserReceiverToken after reload: %v", err)
|
||||||
|
}
|
||||||
|
if persistedToken != firstToken {
|
||||||
|
t.Fatalf("persisted token = %q, want %q", persistedToken, firstToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
rotatedToken, err := reloaded.RotateBrowserReceiverToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RotateBrowserReceiverToken: %v", err)
|
||||||
|
}
|
||||||
|
if rotatedToken == "" || rotatedToken == firstToken {
|
||||||
|
t.Fatalf("rotated token = %q, want new non-empty token", rotatedToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetCurrentVault(t *testing.T) {
|
func TestSetCurrentVault(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.json")
|
path := filepath.Join(dir, "config.json")
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,18 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
const capturePath = "/api/browser-inbox/v1/captures"
|
const (
|
||||||
const DefaultAddr = "127.0.0.1:47731"
|
capturePath = "/api/browser-inbox/v1/captures"
|
||||||
const receiverTokenHeader = "X-Verstak-Receiver-Token"
|
DefaultAddr = "127.0.0.1:47731"
|
||||||
|
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
|
||||||
|
receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxCaptureBodyBytes = 12 * 1024 * 1024
|
maxCaptureBodyBytes = 12 * 1024 * 1024
|
||||||
|
|
@ -44,6 +48,7 @@ const (
|
||||||
type Receiver struct {
|
type Receiver struct {
|
||||||
bus *events.Bus
|
bus *events.Bus
|
||||||
workspaceProvider WorkspaceProvider
|
workspaceProvider WorkspaceProvider
|
||||||
|
optionsMu sync.RWMutex
|
||||||
options Options
|
options Options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +117,17 @@ func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProv
|
||||||
return &Receiver{bus: bus, workspaceProvider: provider, options: options}
|
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) {
|
func Start(addr string, receiver *Receiver) (*Server, error) {
|
||||||
if receiver == nil {
|
if receiver == nil {
|
||||||
return nil, fmt.Errorf("receiver is required")
|
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 {
|
func (r *Receiver) validateReceiverToken(req *http.Request) error {
|
||||||
if r == nil || !r.options.RequireToken {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
r.optionsMu.RLock()
|
||||||
|
requireToken := r.options.RequireToken
|
||||||
expected := strings.TrimSpace(r.options.ReceiverToken)
|
expected := strings.TrimSpace(r.options.ReceiverToken)
|
||||||
|
r.optionsMu.RUnlock()
|
||||||
|
if !requireToken {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if expected == "" {
|
if expected == "" {
|
||||||
return fmt.Errorf("receiver token required")
|
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) {
|
func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) {
|
||||||
bus := events.NewBus()
|
bus := events.NewBus()
|
||||||
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ func (r *Registry) registerDefaults() {
|
||||||
{Name: "secrets.read", Description: "Read secrets from the secret store", Dangerous: true},
|
{Name: "secrets.read", Description: "Read secrets from the secret store", Dangerous: true},
|
||||||
{Name: "secrets.write", Description: "Write secrets to the secret store", Dangerous: true},
|
{Name: "secrets.write", Description: "Write secrets to the secret store", Dangerous: true},
|
||||||
{Name: "sync.participate", Description: "Participate in vault sync", Dangerous: true},
|
{Name: "sync.participate", Description: "Participate in vault sync", Dangerous: true},
|
||||||
|
{Name: "browser.receiver.manage", Description: "View and rotate the local browser receiver pairing token", Dangerous: true},
|
||||||
}
|
}
|
||||||
for _, e := range defaults {
|
for _, e := range defaults {
|
||||||
r.permissions[e.Name] = e
|
r.permissions[e.Name] = e
|
||||||
|
|
|
||||||
40
main.go
40
main.go
|
|
@ -251,20 +251,36 @@ func main() {
|
||||||
if vaultService.GetVaultStatus() == vault.StatusOpen {
|
if vaultService.GetVaultStatus() == vault.StatusOpen {
|
||||||
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
||||||
}
|
}
|
||||||
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled)
|
receiverToken, err := appSettingsMgr.EnsureBrowserReceiverToken()
|
||||||
browserReceiver := browserreceiver.New(eventBus, func() string {
|
|
||||||
current := app.GetCurrentWorkspace()
|
|
||||||
if root, ok := current["rootPath"].(string); ok {
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[browserreceiver] local receiver disabled: %v", err)
|
log.Printf("[browserreceiver] local receiver disabled: %v", err)
|
||||||
} else {
|
}
|
||||||
defer browserReceiverServer.Close()
|
var app *api.App
|
||||||
log.Printf("[browserreceiver] local receiver listening at %s", browserReceiverServer.URL())
|
var browserReceiver *browserreceiver.Receiver
|
||||||
|
if receiverToken != "" {
|
||||||
|
browserReceiver = browserreceiver.NewWithOptions(eventBus, browserreceiver.Options{
|
||||||
|
RequireToken: true,
|
||||||
|
ReceiverToken: receiverToken,
|
||||||
|
}, func() string {
|
||||||
|
if app == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
current := app.GetCurrentWorkspace()
|
||||||
|
if root, ok := current["rootPath"].(string); ok {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
|
||||||
|
if browserReceiver != nil {
|
||||||
|
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[browserreceiver] local receiver disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
defer browserReceiverServer.Close()
|
||||||
|
log.Printf("[browserreceiver] paired local receiver listening at %s", browserReceiverServer.URL())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Wails App ───────────────────────────────────────────
|
// ─── Wails App ───────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue