Files
2026-05-24 11:27:02 +07:00

186 lines
6.4 KiB
Go

package http
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const (
maxBrowserCookieSyncBodyBytes = 1 << 20
maxBrowserCookieSyncItems = 200
maxBrowserCookieValueBytes = 16 << 10
)
var (
errBrowserCookieInvalidURL = errors.New("invalid cookie url")
errBrowserCookieValueTooLarge = errors.New("cookie value too large")
errBrowserCookieTooManyCookies = errors.New("too many cookies")
)
// BrowserCookiesHandler stores selected browser cookies for server-side browser sessions.
type BrowserCookiesHandler struct {
cookies store.BrowserCookieStore
}
func NewBrowserCookiesHandler(cookies store.BrowserCookieStore) *BrowserCookiesHandler {
return &BrowserCookiesHandler{cookies: cookies}
}
func (h *BrowserCookiesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/browser/cookies/sync", requireAuth(permissions.RoleOperator, h.handleSync))
mux.HandleFunc("GET /v1/browser/cookies", requireAuth(permissions.RoleOperator, h.handleList))
mux.HandleFunc("DELETE /v1/browser/cookies", requireAuth(permissions.RoleOperator, h.handleDelete))
}
func (h *BrowserCookiesHandler) handleSync(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var req browserCookieSyncRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBrowserCookieSyncBodyBytes)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
agentID := firstBrowserCookieNonEmpty(req.AgentID, req.Agent)
scope, ok := h.scopeFromRequest(w, r, agentID)
if !ok {
return
}
if len(req.Cookies) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "cookies")})
return
}
if len(req.Cookies) > maxBrowserCookieSyncItems {
h.writeValidationError(w, locale, errBrowserCookieTooManyCookies)
return
}
source := strings.TrimSpace(req.Source)
if source == "" {
source = "chrome-extension"
}
cookies := make([]store.BrowserCookie, 0, len(req.Cookies))
for _, item := range req.Cookies {
c, err := item.toStoreCookie(source)
if err != nil {
h.writeValidationError(w, locale, err)
return
}
cookies = append(cookies, c)
}
count, err := h.cookies.Upsert(r.Context(), scope, cookies)
if err != nil {
h.writeStoreError(w, locale, "browser_cookie_sync.upsert", err)
return
}
slog.Info("browser_cookie_sync.synced",
"tenant_id", scope.TenantID,
"user_id", scope.UserID,
"agent_id", scope.AgentID,
"count", count,
"source", source,
)
writeJSON(w, http.StatusOK, map[string]any{"synced": count})
}
func (h *BrowserCookiesHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
scope, ok := h.scopeFromRequest(w, r, r.URL.Query().Get("agent_id"))
if !ok {
return
}
cookies, err := h.cookies.List(r.Context(), scope, browserCookieFilterFromQuery(r))
if err != nil {
h.writeStoreError(w, locale, "browser_cookie_sync.list", err)
return
}
items := make([]browserCookieMetadata, 0, len(cookies))
for _, c := range cookies {
items = append(items, browserCookieMetadata{
Domain: c.Domain,
Name: c.Name,
Path: c.Path,
Secure: c.Secure,
HTTPOnly: c.HTTPOnly,
SameSite: c.SameSite,
ExpiresAt: c.ExpiresAt,
Source: c.Source,
UpdatedAt: c.UpdatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (h *BrowserCookiesHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
scope, ok := h.scopeFromRequest(w, r, r.URL.Query().Get("agent_id"))
if !ok {
return
}
deleted, err := h.cookies.Delete(r.Context(), scope, browserCookieFilterFromQuery(r))
if err != nil {
h.writeStoreError(w, locale, "browser_cookie_sync.delete", err)
return
}
slog.Info("browser_cookie_sync.deleted",
"tenant_id", scope.TenantID,
"user_id", scope.UserID,
"agent_id", scope.AgentID,
"count", deleted,
)
writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted})
}
func (h *BrowserCookiesHandler) scopeFromRequest(w http.ResponseWriter, r *http.Request, agentID string) (store.BrowserCookieScope, bool) {
locale := store.LocaleFromContext(r.Context())
scope := store.BrowserCookieScopeFromContext(r.Context(), agentID)
if err := scope.Validate(); err != nil {
slog.Warn("security.browser_cookie_sync.scope_denied", "error", err, "path", r.URL.Path)
h.writeValidationError(w, locale, err)
return store.BrowserCookieScope{}, false
}
return scope, true
}
func (h *BrowserCookiesHandler) writeValidationError(w http.ResponseWriter, locale string, err error) {
msg := i18n.T(locale, i18n.MsgInvalidRequest, "browser cookies")
switch {
case errors.Is(err, store.ErrBrowserCookieTenantRequired):
msg = i18n.T(locale, i18n.MsgRequired, "tenant_id")
case errors.Is(err, store.ErrBrowserCookieUserRequired):
msg = i18n.T(locale, i18n.MsgRequired, "user_id")
case errors.Is(err, store.ErrBrowserCookieAgentRequired):
msg = i18n.T(locale, i18n.MsgRequired, "agent_id")
case errors.Is(err, store.ErrBrowserCookieDomainRequired):
msg = i18n.T(locale, i18n.MsgRequired, "domain")
case errors.Is(err, store.ErrBrowserCookieNameRequired):
msg = i18n.T(locale, i18n.MsgRequired, "name")
case errors.Is(err, store.ErrBrowserCookiePathRequired):
msg = i18n.T(locale, i18n.MsgRequired, "path")
case errors.Is(err, errBrowserCookieInvalidURL):
msg = i18n.T(locale, i18n.MsgInvalidCookieURL)
case errors.Is(err, errBrowserCookieValueTooLarge):
msg = i18n.T(locale, i18n.MsgBrowserCookieValueTooLarge)
case errors.Is(err, errBrowserCookieTooManyCookies):
msg = i18n.T(locale, i18n.MsgBrowserCookieTooMany)
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
}
func (h *BrowserCookiesHandler) writeStoreError(w http.ResponseWriter, locale, op string, err error) {
if errors.Is(err, store.ErrBrowserCookieEncryptionRequired) {
slog.Warn("security.browser_cookie_sync.encryption_required", "op", op)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgBrowserCookieEncryptionRequired)})
return
}
slog.Error(op, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "browser cookies")})
}