feat: persist passive browser activity batches
This commit is contained in:
@@ -12,18 +12,20 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||
"github.com/verstak/verstak-desktop/internal/core/hostname"
|
||||
)
|
||||
|
||||
const (
|
||||
capturePath = "/api/browser-inbox/v1/captures"
|
||||
activityBatchPath = "/api/browser-activity/v1/batches"
|
||||
DefaultAddr = "127.0.0.1:47731"
|
||||
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
|
||||
DefaultActivityURL = "http://" + DefaultAddr + activityBatchPath
|
||||
receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||
)
|
||||
|
||||
@@ -43,6 +45,9 @@ const (
|
||||
maxFileTextBytes = 2 * 1024 * 1024
|
||||
maxFileBytes = 8 * 1024 * 1024
|
||||
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
|
||||
maxActivityBodyBytes = 256 * 1024
|
||||
maxActivityEntries = 100
|
||||
maxActivityDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Receiver struct {
|
||||
@@ -55,10 +60,12 @@ type Receiver struct {
|
||||
type WorkspaceProvider func() string
|
||||
|
||||
type Options struct {
|
||||
RequireToken bool
|
||||
ReceiverToken string
|
||||
Available func() bool
|
||||
Persist func(events.Event) error
|
||||
RequireToken bool
|
||||
ReceiverToken string
|
||||
Available func() bool
|
||||
Persist func(events.Event) error
|
||||
ActivityAvailable func() bool
|
||||
PersistActivity func(events.Event) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -107,6 +114,23 @@ type CaptureBrowser struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ActivityBatchPayload contains only domain-level time accounting. It never
|
||||
// accepts or emits page URLs, titles, content, or navigation history.
|
||||
type ActivityBatchPayload struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
BatchID string `json:"batchId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Source string `json:"source"`
|
||||
Entries []ActivityEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type ActivityEntry struct {
|
||||
Hostname string `json:"hostname"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
DurationSeconds int64 `json:"durationSeconds"`
|
||||
}
|
||||
|
||||
func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
|
||||
return NewWithOptions(bus, Options{}, providers...)
|
||||
}
|
||||
@@ -141,6 +165,18 @@ func (r *Receiver) SetPersistence(available func() bool, persist func(events.Eve
|
||||
r.options.Persist = persist
|
||||
}
|
||||
|
||||
// SetActivityPersistence configures durable passive browser activity storage.
|
||||
// An acknowledgement is sent only after persist returns successfully.
|
||||
func (r *Receiver) SetActivityPersistence(available func() bool, persist func(events.Event) error) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.optionsMu.Lock()
|
||||
defer r.optionsMu.Unlock()
|
||||
r.options.ActivityAvailable = available
|
||||
r.options.PersistActivity = persist
|
||||
}
|
||||
|
||||
func Start(addr string, receiver *Receiver) (*Server, error) {
|
||||
if receiver == nil {
|
||||
return nil, fmt.Errorf("receiver is required")
|
||||
@@ -181,6 +217,10 @@ func (s *Server) Close() error {
|
||||
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.URL.Path == activityBatchPath {
|
||||
r.serveActivityBatch(w, req)
|
||||
return
|
||||
}
|
||||
if req.URL.Path != capturePath {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
@@ -256,6 +296,59 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Receiver) serveActivityBatch(w http.ResponseWriter, req *http.Request) {
|
||||
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
|
||||
}
|
||||
options := r.currentOptions()
|
||||
if options.ActivityAvailable == nil || !options.ActivityAvailable() || options.PersistActivity == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
defer req.Body.Close()
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxActivityBodyBytes))
|
||||
var batch ActivityBatchPayload
|
||||
if err := decoder.Decode(&batch); err != nil {
|
||||
writeActivityDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeActivityDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if err := batch.NormalizeAndValidate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
event := events.Event{
|
||||
Name: "browser.activity.batch",
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Payload: batch.EventPayload(),
|
||||
}
|
||||
if err := options.PersistActivity(event); err != nil {
|
||||
log.Printf("[browserreceiver] persist activity %s: %v", batch.BatchID, err)
|
||||
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
|
||||
return
|
||||
}
|
||||
if r.bus != nil {
|
||||
r.bus.Publish(event)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "accepted",
|
||||
"batchId": batch.BatchID,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Receiver) currentOptions() Options {
|
||||
if r == nil {
|
||||
return Options{}
|
||||
@@ -370,6 +463,80 @@ func (p CapturePayload) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeAndValidate validates a batch before it is made durable and replaces
|
||||
// each submitted hostname with the shared canonical A-label form.
|
||||
func (p *ActivityBatchPayload) NormalizeAndValidate() error {
|
||||
if p == nil || p.SchemaVersion != 1 {
|
||||
return fmt.Errorf("unsupported schemaVersion")
|
||||
}
|
||||
if strings.TrimSpace(p.BatchID) == "" {
|
||||
return fmt.Errorf("batchId is required")
|
||||
}
|
||||
if err := validateCaptureText(p.BatchID, "batchId", maxCaptureIDBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(p.CreatedAt) == "" {
|
||||
return fmt.Errorf("createdAt is required")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, p.CreatedAt); err != nil {
|
||||
return fmt.Errorf("createdAt is invalid")
|
||||
}
|
||||
if err := validateCaptureText(p.CreatedAt, "createdAt", maxCapturedAtBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(p.Source) == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(p.Entries) == 0 || len(p.Entries) > maxActivityEntries {
|
||||
return fmt.Errorf("entries must contain between 1 and %d items", maxActivityEntries)
|
||||
}
|
||||
for index := range p.Entries {
|
||||
entry := &p.Entries[index]
|
||||
canonical := hostname.NormalizeHostnameV1(entry.Hostname)
|
||||
if canonical == "" {
|
||||
return fmt.Errorf("entries[%d].hostname is invalid", index)
|
||||
}
|
||||
startedAt, err := time.Parse(time.RFC3339, entry.StartedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entries[%d].startedAt is invalid", index)
|
||||
}
|
||||
endedAt, err := time.Parse(time.RFC3339, entry.EndedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entries[%d].endedAt is invalid", index)
|
||||
}
|
||||
interval := endedAt.Sub(startedAt)
|
||||
if interval <= 0 || interval > maxActivityDuration {
|
||||
return fmt.Errorf("entries[%d] interval must be between 1 second and %s", index, maxActivityDuration)
|
||||
}
|
||||
if entry.DurationSeconds <= 0 || entry.DurationSeconds > int64(maxActivityDuration/time.Second) || time.Duration(entry.DurationSeconds)*time.Second > interval {
|
||||
return fmt.Errorf("entries[%d].durationSeconds is invalid", index)
|
||||
}
|
||||
entry.Hostname = canonical
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ActivityBatchPayload) EventPayload() map[string]interface{} {
|
||||
entries := make([]map[string]interface{}, 0, len(p.Entries))
|
||||
for _, entry := range p.Entries {
|
||||
entries = append(entries, map[string]interface{}{
|
||||
"hostname": entry.Hostname,
|
||||
"startedAt": entry.StartedAt,
|
||||
"endedAt": entry.EndedAt,
|
||||
"durationSeconds": entry.DurationSeconds,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"batchId": strings.TrimSpace(p.BatchID),
|
||||
"createdAt": strings.TrimSpace(p.CreatedAt),
|
||||
"source": strings.TrimSpace(p.Source),
|
||||
"entries": entries,
|
||||
}
|
||||
}
|
||||
|
||||
func (p CapturePayload) validateFile() error {
|
||||
if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
|
||||
return fmt.Errorf("file.name is required")
|
||||
@@ -450,10 +617,19 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
|
||||
}
|
||||
|
||||
func captureDomain(rawURL, fallback string) string {
|
||||
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
if normalized := hostname.NormalizeURLHostnameV1(rawURL); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
return hostname.NormalizeHostnameV1(fallback)
|
||||
}
|
||||
|
||||
func writeActivityDecodeError(w http.ResponseWriter, err error) {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "activity payload exceeds limit")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
|
||||
@@ -238,6 +238,101 @@ func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverAcceptsDomainActivityBatchAfterDurablePersistence(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
received := make(chan events.Event, 1)
|
||||
bus.Subscribe("browser.activity.batch", func(event events.Event) {
|
||||
received <- event
|
||||
})
|
||||
persisted := 0
|
||||
receiver := NewWithOptions(bus, Options{
|
||||
ActivityAvailable: func() bool { return true },
|
||||
PersistActivity: func(event events.Event) error {
|
||||
persisted++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
body := `{
|
||||
"schemaVersion": 1,
|
||||
"batchId": "batch-123",
|
||||
"createdAt": "2026-07-12T10:05:00.000Z",
|
||||
"source": "verstak-browser-extension",
|
||||
"entries": [{
|
||||
"hostname": "пример.рф",
|
||||
"startedAt": "2026-07-12T10:00:00.000Z",
|
||||
"endedAt": "2026-07-12T10:05:00.000Z",
|
||||
"durationSeconds": 300
|
||||
}]
|
||||
}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", bytes.NewBufferString(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
receiver.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
|
||||
}
|
||||
if persisted != 1 {
|
||||
t.Fatalf("persisted = %d, want 1 before acknowledgement", persisted)
|
||||
}
|
||||
var response map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("response json: %v", err)
|
||||
}
|
||||
if response["batchId"] != "batch-123" || response["status"] != "accepted" {
|
||||
t.Fatalf("response = %+v, want accepted batch-123", response)
|
||||
}
|
||||
event := <-received
|
||||
payload, ok := event.Payload.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
|
||||
}
|
||||
if payload["batchId"] != "batch-123" {
|
||||
t.Fatalf("event payload = %+v, want batch id", payload)
|
||||
}
|
||||
entries, ok := payload["entries"].([]map[string]interface{})
|
||||
if !ok || len(entries) != 1 || entries[0]["hostname"] != "xn--e1afmkfd.xn--p1ai" {
|
||||
t.Fatalf("event entries = %+v, want one canonical hostname", payload["entries"])
|
||||
}
|
||||
if _, ok := payload["url"]; ok {
|
||||
t.Fatalf("activity payload must not contain URL: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRejectsDomainActivityWithoutConsumerOrValidInterval(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
receiver *Receiver
|
||||
body string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "consumer unavailable",
|
||||
receiver: New(events.NewBus()),
|
||||
body: `{"schemaVersion":1,"batchId":"batch-a","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:05:00Z","durationSeconds":300}]}`,
|
||||
wantCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "duration too long",
|
||||
receiver: NewWithOptions(events.NewBus(), Options{
|
||||
ActivityAvailable: func() bool { return true },
|
||||
PersistActivity: func(events.Event) error { return nil },
|
||||
}),
|
||||
body: `{"schemaVersion":1,"batchId":"batch-b","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:20:01Z","durationSeconds":1201}]}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", strings.NewReader(tc.body))
|
||||
rec := httptest.NewRecorder()
|
||||
tc.receiver.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.wantCode {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.wantCode, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
received := make(chan events.Event, 1)
|
||||
|
||||
Reference in New Issue
Block a user