Files
Ant-Browser/backend/internal/launchcode/automation_public_api.go
T

554 lines
16 KiB
Go

package launchcode
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"strconv"
"strings"
"ant-chrome/backend/internal/automation"
)
const automationPublicHookRoutePrefix = "/api/automation/hooks/"
func (s *LaunchServer) handleAutomationPublicHook(w http.ResponseWriter, r *http.Request) {
hookPath, ok := parseAutomationPublicHookPath(r.URL.Path)
if !ok {
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
return
}
record, err := s.findAutomationPublicHookScript(hookPath)
if err != nil {
if err == errAutomationHookServiceUnavailable {
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
if os.IsNotExist(err) {
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
return
}
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
return
}
if !record.PublicAPI.Enabled {
writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "")
return
}
if r.Method != record.PublicAPI.Method {
writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "")
return
}
runner, ok := s.starter.(AutomationScriptRunner)
if !ok {
writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body", "")
return
}
input, err := buildAutomationPublicHookRunRequest(*record, r, body)
if err != nil {
writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", err.Error(), automationRequestErrorField(err))
return
}
run, err := runner.AutomationScriptRunWithOptions(input)
if err != nil {
writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "")
return
}
writeAutomationPublicHookResponse(w, *record, run)
}
var errAutomationHookServiceUnavailable = automationRequestError("automation hook service unavailable")
func (s *LaunchServer) findAutomationPublicHookScript(hookPath string) (*automation.ScriptRecord, error) {
lister, ok := s.starter.(AutomationScriptLister)
if !ok {
return nil, errAutomationHookServiceUnavailable
}
items, err := lister.AutomationScriptList()
if err != nil {
return nil, err
}
for _, item := range items {
if normalizeAutomationPublicHookPath(item.PublicAPI.Path) != hookPath {
continue
}
record := item
return &record, nil
}
return nil, os.ErrNotExist
}
func parseAutomationPublicHookPath(urlPath string) (string, bool) {
trimmed := strings.TrimSpace(urlPath)
if !strings.HasPrefix(trimmed, automationPublicHookRoutePrefix) {
return "", false
}
trimmed = strings.TrimPrefix(trimmed, automationPublicHookRoutePrefix)
trimmed = normalizeAutomationPublicHookPath(trimmed)
if trimmed == "" {
return "", false
}
return trimmed, true
}
func normalizeAutomationPublicHookPath(value string) string {
value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if value == "" {
return ""
}
cleaned := strings.Trim(path.Clean("/"+value), "/")
if cleaned == "" || cleaned == "." {
return ""
}
return strings.ToLower(cleaned)
}
func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http.Request, body []byte) (automation.ScriptRunRequest, error) {
if shouldApplyAutomationPublicHookVariables(record) {
resolvedBody, err := resolveAutomationPublicHookRequestBody(record, body)
if err != nil {
return automation.ScriptRunRequest{}, err
}
body = resolvedBody
}
input, err := decodeAutomationPublicHookRequestBody(body)
if err != nil {
return automation.ScriptRunRequest{}, err
}
selectorText := ""
useScriptSelector := true
if strings.TrimSpace(input.Code) != "" {
encodedSelectorText, err := encodeAutomationPublicHookJSONObject(map[string]interface{}{"code": strings.TrimSpace(input.Code)})
if err != nil {
return automation.ScriptRunRequest{}, badAutomationRequest("code is invalid")
}
selectorText = encodedSelectorText
useScriptSelector = false
}
if err := validateAutomationTimeoutMs(input.TimeoutMs); err != nil {
return automation.ScriptRunRequest{}, err
}
params := mergeAutomationPublicHookDefaultParamsObject(record, input.Params)
paramsText, err := encodeAutomationPublicHookJSONObject(params)
if err != nil {
return automation.ScriptRunRequest{}, badAutomationRequest("params must be a JSON object")
}
return automation.ScriptRunRequest{
ScriptID: record.ID,
SelectorText: selectorText,
ParamsText: paramsText,
UseScriptSelector: useScriptSelector,
UseScriptParams: false,
TimeoutMs: resolveAutomationPublicHookTimeout(r, input.TimeoutMs, record.PublicAPI.TimeoutMs),
}, nil
}
type automationPublicHookRequestBody struct {
Code string `json:"code"`
Params map[string]interface{} `json:"params"`
TimeoutMs int `json:"timeoutMs"`
}
func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookRequestBody, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return automationPublicHookRequestBody{}, nil
}
var input automationPublicHookRequestBody
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&input); err != nil {
return automationPublicHookRequestBody{}, badAutomationRequest("invalid request body")
}
if input.Params == nil {
input.Params = map[string]interface{}{}
}
return input, nil
}
func encodeAutomationPublicHookJSONObject(obj map[string]interface{}) (string, error) {
encoded, err := json.Marshal(obj)
if err != nil {
return "", err
}
return string(encoded), nil
}
func shouldApplyAutomationPublicHookVariables(record automation.ScriptRecord) bool {
if strings.TrimSpace(record.PublicAPI.RequestBodyText) == "" {
return false
}
for _, variable := range record.PublicAPI.Variables {
name := strings.TrimSpace(variable.Name)
if name == "" {
continue
}
if strings.Contains(record.PublicAPI.RequestBodyText, "{{"+name+"}}") || strings.Contains(record.PublicAPI.RequestBodyText, "${"+name+"}") {
return true
}
}
return false
}
func replaceAutomationPublicHookPlaceholderValue(bodyText string, name string, rawValue interface{}) string {
value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue))
escapedValue := escapeAutomationPublicHookJSONString(value)
for _, placeholder := range []string{"{{" + name + "}}", "${" + name + "}"} {
bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue)
}
return bodyText
}
func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body []byte) ([]byte, error) {
config := record.PublicAPI
input, err := decodeAutomationPublicHookRequestBody(body)
if err != nil {
return nil, err
}
values := input.Params
bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", input.Code)
for _, variable := range config.Variables {
name := strings.TrimSpace(variable.Name)
if name == "" {
continue
}
placeholders := []string{"{{" + name + "}}", "${" + name + "}"}
used := false
for _, placeholder := range placeholders {
if strings.Contains(bodyText, placeholder) {
used = true
break
}
}
if !used {
continue
}
rawValue := interface{}(variable.DefaultValue)
if incomingValue, ok := values[name]; ok {
rawValue = incomingValue
}
value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue))
if variable.Required && value == "" {
return nil, badAutomationRequest("missing required variable: " + name)
}
escapedValue := escapeAutomationPublicHookJSONString(value)
for _, placeholder := range placeholders {
bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue)
}
}
var decoded interface{}
if err := json.Unmarshal([]byte(bodyText), &decoded); err != nil {
return nil, badAutomationRequest("resolved request body must be a JSON object")
}
decodedBody, ok := decoded.(map[string]interface{})
if !ok {
return nil, badAutomationRequest("resolved request body must be a JSON object")
}
mergedBody := mergeAutomationPublicHookDefaultParams(record, decodedBody)
encoded, err := json.Marshal(mergedBody)
if err != nil {
return nil, badAutomationRequest("resolved request body must be a JSON object")
}
return encoded, nil
}
func mergeAutomationPublicHookDefaultParams(record automation.ScriptRecord, body map[string]interface{}) map[string]interface{} {
defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText)
if !ok || len(defaultParams) == 0 {
return body
}
if record.PublicAPI.RequestMode == "params-only" {
return mergeAutomationPublicHookJSONObjects(defaultParams, body)
}
rawParams, ok := body["params"].(map[string]interface{})
if !ok {
return body
}
nextBody := make(map[string]interface{}, len(body))
for key, value := range body {
nextBody[key] = value
}
nextBody["params"] = mergeAutomationPublicHookJSONObjects(defaultParams, rawParams)
return nextBody
}
func mergeAutomationPublicHookDefaultParamsObject(record automation.ScriptRecord, param map[string]interface{}) map[string]interface{} {
if param == nil {
param = map[string]interface{}{}
}
defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText)
if !ok || len(defaultParams) == 0 {
return param
}
return mergeAutomationPublicHookJSONObjects(defaultParams, param)
}
func parseAutomationPublicHookJSONObject(text string) (map[string]interface{}, bool) {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return nil, false
}
var value interface{}
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
return nil, false
}
object, ok := value.(map[string]interface{})
return object, ok
}
func mergeAutomationPublicHookJSONObjects(base map[string]interface{}, patch map[string]interface{}) map[string]interface{} {
merged := make(map[string]interface{}, len(base)+len(patch))
for key, value := range base {
merged[key] = value
}
for key, value := range patch {
baseObject, baseOK := merged[key].(map[string]interface{})
patchObject, patchOK := value.(map[string]interface{})
if baseOK && patchOK {
merged[key] = mergeAutomationPublicHookJSONObjects(baseObject, patchObject)
continue
}
merged[key] = value
}
return merged
}
func formatAutomationPublicHookVariableValue(value interface{}) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case float64, bool, int, int64, json.Number:
return strings.TrimSpace(strings.Trim(fmt.Sprint(typed), "\""))
default:
encoded, err := json.Marshal(typed)
if err != nil {
return fmt.Sprint(typed)
}
return string(encoded)
}
}
func escapeAutomationPublicHookJSONString(value string) string {
encoded, err := json.Marshal(value)
if err != nil {
return value
}
text := string(encoded)
if len(text) >= 2 {
return text[1 : len(text)-1]
}
return text
}
func decodeAutomationRunAPIRequestBody(body []byte) (automationScriptRunAPIRequest, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return automationScriptRunAPIRequest{}, nil
}
var req automationScriptRunAPIRequest
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
return automationScriptRunAPIRequest{}, badAutomationRequest("invalid request body")
}
return req, nil
}
func decodeJSONObjectBody(body []byte, fieldName string) (map[string]interface{}, bool, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, false, nil
}
var value interface{}
if err := json.Unmarshal(trimmed, &value); err != nil {
return nil, false, badAutomationRequest(fieldName + " must be a JSON object")
}
obj, ok := value.(map[string]interface{})
if !ok {
return nil, false, badAutomationRequest(fieldName + " must be a JSON object")
}
return obj, true, nil
}
func resolveAutomationPublicHookTimeout(r *http.Request, requestTimeout int, fallback int) int {
if requestTimeout > 0 {
return requestTimeout
}
if r != nil {
if raw := strings.TrimSpace(r.URL.Query().Get("timeoutMs")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
return parsed
}
}
}
return fallback
}
func writeAutomationPublicHookResponse(w http.ResponseWriter, record automation.ScriptRecord, run *automation.ScriptRunRecord) {
_ = record
parsedPayload, resultPayload, hasResult := decodeAutomationRunPayloadValue(run.ResultText)
if run.Status != "success" {
writeJSON(w, http.StatusOK, compactAutomationPublicHookFailure(run))
return
}
response := map[string]interface{}{
"ok": true,
"status": run.Status,
"summary": run.Summary,
"message": run.Summary,
"data": map[string]interface{}{},
"result": map[string]interface{}{},
}
if hasResult {
data := compactAutomationPublicHookData(resultPayload, run)
response["data"] = data
response["result"] = data
} else if parsedPayload != nil {
data := compactAutomationPublicHookData(parsedPayload, run)
response["data"] = data
response["result"] = data
}
writeJSON(w, http.StatusOK, response)
}
func compactAutomationPublicHookFailure(run *automation.ScriptRunRecord) map[string]interface{} {
response := map[string]interface{}{
"ok": false,
"status": run.Status,
"summary": run.Summary,
"message": run.Summary,
"data": map[string]interface{}{},
"result": map[string]interface{}{},
}
if strings.TrimSpace(run.Error) != "" {
response["error"] = run.Error
}
return response
}
func compactAutomationPublicHookData(payload interface{}, run *automation.ScriptRunRecord) interface{} {
data := compactAutomationPublicHookResult(payload, run)
delete(data, "ok")
delete(data, "summary")
return data
}
func compactAutomationPublicHookResult(payload interface{}, run *automation.ScriptRunRecord) map[string]interface{} {
obj, ok := payload.(map[string]interface{})
if !ok {
result := map[string]interface{}{"ok": true}
if strings.TrimSpace(run.Summary) != "" {
result["summary"] = run.Summary
}
if payload != nil {
result["result"] = payload
}
return result
}
if !hasAutomationPublicHookDownloadField(obj) {
result := make(map[string]interface{}, len(obj)+1)
result["ok"] = true
for key, value := range obj {
if key != "ok" && value != nil {
result[key] = value
}
}
if _, exists := result["summary"]; !exists && strings.TrimSpace(run.Summary) != "" {
result["summary"] = run.Summary
}
return result
}
result := map[string]interface{}{"ok": true}
for _, key := range []string{
"downloadAddress",
"downloadPath",
"outputPath",
"sourceImageUrl",
"sourceDownloadUrl",
"screenshotPath",
"pageScreenshotPath",
"contentType",
"imageWidth",
"imageHeight",
"status",
"summary",
"error",
} {
if value, exists := obj[key]; exists && value != nil {
result[key] = value
}
}
return result
}
func hasAutomationPublicHookDownloadField(obj map[string]interface{}) bool {
for _, key := range []string{"downloadAddress", "downloadPath", "outputPath"} {
if value, exists := obj[key]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" {
return true
}
}
return false
}
func decodeAutomationRunPayloadValue(raw string) (interface{}, interface{}, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil, false
}
var payload interface{}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, nil, false
}
if obj, ok := payload.(map[string]interface{}); ok {
result, exists := obj["result"]
return payload, result, exists
}
return payload, nil, false
}