mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
554 lines
16 KiB
Go
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
|
|
}
|