package server import ( "context" "encoding/json" "fmt" "net/http" "net/url" "regexp" "strconv" "strings" ) // Error codes returned by the /.runtime/objects/* endpoints. Kept in one // place so backend, handler, and HTTP-status mapping stay in sync. const ( codeNotFound = "not_found" codeUnknownTag = "unknown_tag" codeBadField = "bad_field" codeBadQuery = "bad_query" codeUnknownOperator = "unknown_operator" codeBadLimit = "bad_limit" codeBridgeUnavailable = "bridge_unavailable" codeTimeout = "timeout" codeInternal = "internal_error" ) // parseErr is a typed parser error so callers don't have to parse error // strings back out. type parseErr struct { Code string Msg string } func (e parseErr) Error() string { return e.Code + ": " + e.Msg } // Filter is one REST query filter clause. type Filter struct { Field string `json:"field"` Op string `json:"op"` Value string `json:"value"` } // OrderKey is one sort key. type OrderKey struct { Field string `json:"field"` Desc bool `json:"desc"` } // ObjectsListQuery is the parsed query for GET /.runtime/objects/{tag}. type ObjectsListQuery struct { Filters []Filter `json:"filters"` Order []OrderKey `json:"order"` Limit int `json:"limit"` Offset int `json:"offset"` Select []string `json:"select,omitempty"` Debug bool `json:"debug,omitempty"` } var ( fieldPathRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`) whereParamRe = regexp.MustCompile(`^where\[([^\]]+)\](?:\[([^\]]+)\])?$`) allowedOps = map[string]struct{}{ "eq": {}, "ne": {}, "gt": {}, "gte": {}, "lt": {}, "lte": {}, "in": {}, "contains": {}, "startsWith": {}, } ) const ( defaultLimit = 100 maxLimit = 1000 ) // parseObjectsQuery extracts an ObjectsListQuery from URL values. func parseObjectsQuery(v url.Values) (ObjectsListQuery, error) { // Always initialize Filters/Order to non-nil empty slices so that the // JSON envelope sent across the bridge has `[]` rather than `null` — // the TS side does `.map(...)` on these fields unconditionally. q := ObjectsListQuery{ Limit: defaultLimit, Filters: []Filter{}, Order: []OrderKey{}, } for key, vals := range v { m := whereParamRe.FindStringSubmatch(key) if m == nil { continue } field, op := m[1], m[2] if op == "" { op = "eq" } if !fieldPathRe.MatchString(field) { return q, parseErr{codeBadField, fmt.Sprintf("%q is not a valid field path", field)} } if _, ok := allowedOps[op]; !ok { return q, parseErr{codeUnknownOperator, fmt.Sprintf("%q", op)} } for _, val := range vals { q.Filters = append(q.Filters, Filter{Field: field, Op: op, Value: val}) } } for _, raw := range v["order"] { field, desc := raw, false if i := strings.IndexByte(raw, ':'); i > 0 { field = raw[:i] switch raw[i+1:] { case "asc": desc = false case "desc": desc = true default: return q, parseErr{codeBadQuery, fmt.Sprintf("order direction must be asc|desc, got %q", raw[i+1:])} } } if !fieldPathRe.MatchString(field) { return q, parseErr{codeBadField, fmt.Sprintf("%q is not a valid field path", field)} } q.Order = append(q.Order, OrderKey{Field: field, Desc: desc}) } if s := v.Get("limit"); s != "" { n, err := strconv.Atoi(s) if err != nil || n <= 0 || n > maxLimit { return q, parseErr{codeBadLimit, fmt.Sprintf("limit must be 1..%d", maxLimit)} } q.Limit = n } if s := v.Get("offset"); s != "" { n, err := strconv.Atoi(s) if err != nil || n < 0 { return q, parseErr{codeBadQuery, "offset must be >= 0"} } q.Offset = n } if s := v.Get("select"); s != "" { for _, f := range strings.Split(s, ",") { f = strings.TrimSpace(f) if !fieldPathRe.MatchString(f) { return q, parseErr{codeBadField, fmt.Sprintf("%q is not a valid field path", f)} } q.Select = append(q.Select, f) } } if v.Get("debug") == "1" { q.Debug = true } return q, nil } type objectsResponse struct { OK bool `json:"ok"` Items json.RawMessage `json:"items,omitempty"` Item json.RawMessage `json:"item,omitempty"` EquivalentLua *string `json:"equivalentLua,omitempty"` Error string `json:"error,omitempty"` Code string `json:"code,omitempty"` } func writeError(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, map[string]any{"error": msg, "code": code}) } func errorStatus(code string) int { switch code { case codeNotFound, codeUnknownTag: return http.StatusNotFound case codeBadField, codeBadQuery, codeUnknownOperator, codeBadLimit: return http.StatusBadRequest case codeBridgeUnavailable: return http.StatusServiceUnavailable case codeTimeout: return http.StatusGatewayTimeout default: return http.StatusInternalServerError } } func (b *RuntimeBridge) callObjectsAPI(ctx context.Context, browser *HeadlessBrowser, req any) (objectsResponse, error) { reqJSON, err := json.Marshal(req) if err != nil { return objectsResponse{}, err } raw, err := browser.evalViaGlobal(ctx, "sbRuntime.objectsAPI", string(reqJSON)) if err != nil { return objectsResponse{}, err } s, ok := raw.(string) if !ok { return objectsResponse{}, fmt.Errorf("unexpected bridge return type %T", raw) } var resp objectsResponse if err := json.Unmarshal([]byte(s), &resp); err != nil { return objectsResponse{}, err } return resp, nil } // HandleObjectsListTags handles GET /.runtime/objects. func (b *RuntimeBridge) HandleObjectsListTags(w http.ResponseWriter, r *http.Request) { res, ok := b.withBridge(w, r, func(ctx context.Context, browser *HeadlessBrowser) (any, error) { return b.callObjectsAPI(ctx, browser, map[string]any{"kind": "list_tags"}) }) if !ok { return } resp := res.(objectsResponse) if !resp.OK { writeError(w, errorStatus(resp.Code), resp.Code, resp.Error) return } w.Header().Set("Content-Type", "application/json") w.Write(resp.Items) } // splitObjectsPath parses the encoded path suffix after /.runtime/objects/ // into (tag, ref). Tags or refs containing `/` must be percent-encoded // (`%2F`); we split on the first un-encoded `/` and then decode. Returns // ("", "", err) if either segment has an invalid percent escape. func splitObjectsPath(escapedSuffix string) (tag, ref string, err error) { rawTag, rawRef, hasRef := strings.Cut(escapedSuffix, "/") tag, err = url.PathUnescape(rawTag) if err != nil { return "", "", fmt.Errorf("invalid tag encoding: %w", err) } if hasRef { ref, err = url.PathUnescape(rawRef) if err != nil { return "", "", fmt.Errorf("invalid ref encoding: %w", err) } } return tag, ref, nil } // HandleObjectsByPath routes /.runtime/objects/{tag}[/{ref}] to either the // list or get handler. A wildcard route plus manual percent-decoding lets // tags and refs contain `/` characters (encoded as `%2F` on the wire). func (b *RuntimeBridge) HandleObjectsByPath(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.EscapedPath(), "/.runtime/objects/") tag, ref, err := splitObjectsPath(suffix) if err != nil { writeError(w, http.StatusBadRequest, codeBadQuery, err.Error()) return } if tag == "" { writeError(w, http.StatusBadRequest, codeBadQuery, "missing tag") return } if ref == "" { b.handleObjectsList(w, r, tag) return } b.handleObjectsGet(w, r, tag, ref) } func (b *RuntimeBridge) handleObjectsList(w http.ResponseWriter, r *http.Request, tag string) { q, err := parseObjectsQuery(r.URL.Query()) if err != nil { pe := err.(parseErr) writeError(w, errorStatus(pe.Code), pe.Code, pe.Msg) return } req := map[string]any{ "kind": "list", "tag": tag, "filters": q.Filters, "order": q.Order, "limit": q.Limit, "offset": q.Offset, "debug": q.Debug, } if len(q.Select) > 0 { req["select"] = q.Select } res, ok := b.withBridge(w, r, func(ctx context.Context, browser *HeadlessBrowser) (any, error) { return b.callObjectsAPI(ctx, browser, req) }) if !ok { return } resp := res.(objectsResponse) if !resp.OK { writeError(w, errorStatus(resp.Code), resp.Code, resp.Error) return } w.Header().Set("Content-Type", "application/json") if q.Debug && resp.EquivalentLua != nil { w.Header().Set("X-Equivalent-Lua", *resp.EquivalentLua) } w.Write(resp.Items) } func (b *RuntimeBridge) handleObjectsGet(w http.ResponseWriter, r *http.Request, tag, ref string) { req := map[string]any{"kind": "get", "tag": tag, "ref": ref} res, ok := b.withBridge(w, r, func(ctx context.Context, browser *HeadlessBrowser) (any, error) { return b.callObjectsAPI(ctx, browser, req) }) if !ok { return } resp := res.(objectsResponse) if !resp.OK { writeError(w, errorStatus(resp.Code), resp.Code, resp.Error) return } w.Header().Set("Content-Type", "application/json") w.Write(resp.Item) }