Files

373 lines
10 KiB
Go

package automation
import (
"fmt"
"path"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
func normalizeScriptRecord(input ScriptRecord, existing ScriptRecord) (ScriptRecord, error) {
now := time.Now().Format(time.RFC3339)
id := strings.TrimSpace(input.ID)
if id == "" {
id = uuid.NewString()
}
if !isSafeScriptID(id) {
return ScriptRecord{}, fmt.Errorf("script id is invalid")
}
entryFile := normalizeScriptEntryFile(input.EntryFile)
recordType := normalizeScriptType(input.Type)
recordStatus := normalizeScriptStatus(input.Status)
packageFormat := normalizeScriptPackageFormat(firstNonEmpty(strings.TrimSpace(input.PackageFormat), strings.TrimSpace(existing.PackageFormat)))
manifestVersion := normalizeScriptManifestVersion(input.ManifestVersion, existing.ManifestVersion)
createdAt := firstNonEmpty(strings.TrimSpace(existing.CreatedAt), strings.TrimSpace(input.CreatedAt), now)
updatedAt := firstNonEmpty(strings.TrimSpace(input.UpdatedAt), now)
if strings.TrimSpace(input.Name) == "" {
return ScriptRecord{}, fmt.Errorf("script name is required")
}
return ScriptRecord{
PackageFormat: packageFormat,
ManifestVersion: manifestVersion,
ID: id,
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
Type: recordType,
Status: recordStatus,
EntryFile: entryFile,
Tags: normalizeScriptTags(input.Tags),
SelectorText: normalizeScriptJSONText(input.SelectorText),
ParamsText: normalizeScriptJSONText(input.ParamsText),
ScriptText: normalizeScriptText(input.ScriptText),
Notes: strings.TrimSpace(input.Notes),
TargetConfig: normalizeScriptTargetConfig(input.TargetConfig),
PublicAPI: normalizeScriptPublicAPIConfig(input.PublicAPI),
Source: normalizeScriptSource(input.Source, existing.Source),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func normalizeScriptPackageFormat(value string) string {
normalized := strings.TrimSpace(value)
if normalized == "" {
return defaultScriptPackageFormat
}
return normalized
}
func normalizeScriptManifestVersion(value int, fallback int) int {
if value > 0 {
return value
}
if fallback > 0 {
return fallback
}
return defaultScriptManifestVersion
}
func normalizeScriptType(value string) string {
switch strings.TrimSpace(value) {
case "launch-api":
return "launch-api"
default:
return "playwright-cdp"
}
}
func normalizeScriptStatus(value string) string {
switch strings.TrimSpace(value) {
case "ready":
return "ready"
case "disabled":
return "disabled"
default:
return "draft"
}
}
func normalizeScriptEntryFile(value string) string {
normalized := strings.TrimSpace(value)
if normalized == "" {
return defaultScriptEntryFile
}
normalized = filepath.ToSlash(filepath.Clean(normalized))
if normalized == "." || normalized == "/" || normalized == scriptStoreConfigFileName {
return defaultScriptEntryFile
}
if strings.HasPrefix(normalized, "../") || normalized == ".." || filepath.IsAbs(normalized) {
return defaultScriptEntryFile
}
return normalized
}
func normalizeBundleFilePath(value string) (string, error) {
normalized := filepath.ToSlash(filepath.Clean(strings.TrimSpace(value)))
if normalized == "." || normalized == "/" || normalized == "" {
return "", fmt.Errorf("bundle file path is invalid")
}
if strings.HasPrefix(normalized, "../") || normalized == ".." || filepath.IsAbs(normalized) {
return "", fmt.Errorf("bundle file path is invalid")
}
return normalized, nil
}
func normalizeScriptTags(tags []string) []string {
deduped := make(map[string]struct{}, len(tags))
result := make([]string, 0, len(tags))
for _, tag := range tags {
normalized := strings.TrimSpace(tag)
if normalized == "" {
continue
}
if _, exists := deduped[normalized]; exists {
continue
}
deduped[normalized] = struct{}{}
result = append(result, normalized)
}
return result
}
func normalizeScriptJSONText(value string) string {
return strings.TrimSpace(value)
}
func normalizeScriptText(value string) string {
return strings.ReplaceAll(value, "\r\n", "\n")
}
func normalizeScriptSource(input ScriptSource, existing ScriptSource) ScriptSource {
source := ScriptSource{
Type: firstNonEmpty(strings.TrimSpace(input.Type), strings.TrimSpace(existing.Type)),
URI: firstNonEmpty(strings.TrimSpace(input.URI), strings.TrimSpace(existing.URI)),
Ref: firstNonEmpty(strings.TrimSpace(input.Ref), strings.TrimSpace(existing.Ref)),
Path: firstNonEmpty(strings.TrimSpace(input.Path), strings.TrimSpace(existing.Path)),
ImportedAt: firstNonEmpty(strings.TrimSpace(input.ImportedAt), strings.TrimSpace(existing.ImportedAt)),
}
if source.Type == "" && (source.URI != "" || source.Ref != "" || source.Path != "" || source.ImportedAt != "") {
source.Type = "manual"
}
return source
}
func normalizeScriptTargetConfig(input ScriptTargetConfig) ScriptTargetConfig {
mode := normalizeScriptTargetMode(input.Mode)
createNameTemplate := strings.TrimSpace(input.CreateNameTemplate)
if createNameTemplate == "" && mode == "create" {
createNameTemplate = defaultScriptCreateNameTemplate
}
return ScriptTargetConfig{
Mode: mode,
Selector: normalizeScriptTargetSelector(input.Selector),
TemplateSelector: normalizeScriptTargetSelector(input.TemplateSelector),
CreateNameTemplate: createNameTemplate,
}
}
func normalizeScriptTargetMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "existing":
return "existing"
case "create":
return "create"
case "rotate":
return "rotate"
default:
return "manual"
}
}
func normalizeScriptTargetSelector(input ScriptTargetSelector) ScriptTargetSelector {
return ScriptTargetSelector{
Code: strings.ToUpper(strings.TrimSpace(input.Code)),
ProfileID: strings.TrimSpace(input.ProfileID),
ProfileName: strings.TrimSpace(input.ProfileName),
GroupID: strings.TrimSpace(input.GroupID),
Keywords: normalizeScriptTags(input.Keywords),
Tags: normalizeScriptTags(input.Tags),
}
}
func normalizeScriptPublicAPIConfig(input ScriptPublicAPIConfig) ScriptPublicAPIConfig {
return ScriptPublicAPIConfig{
Enabled: input.Enabled,
Method: normalizeScriptPublicAPIMethod(input.Method),
Path: normalizeScriptPublicAPIPath(input.Path),
RequestMode: normalizeScriptPublicAPIRequestMode(input.RequestMode),
ResponseMode: normalizeScriptPublicAPIResponseMode(input.ResponseMode),
TimeoutMs: normalizeScriptPublicAPITimeout(input.TimeoutMs),
RequestBodyText: normalizeScriptJSONText(input.RequestBodyText),
ResponseBodyText: normalizeScriptJSONText(input.ResponseBodyText),
Variables: normalizeScriptPublicAPIVariables(input.Variables),
}
}
func normalizeScriptPublicAPIVariables(variables []ScriptPublicAPIVariable) []ScriptPublicAPIVariable {
seen := make(map[string]struct{}, len(variables))
result := make([]ScriptPublicAPIVariable, 0, len(variables))
for _, variable := range variables {
name := strings.TrimSpace(variable.Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
result = append(result, ScriptPublicAPIVariable{
Name: name,
DefaultValue: strings.TrimSpace(variable.DefaultValue),
Description: strings.TrimSpace(variable.Description),
Required: variable.Required,
})
}
return result
}
func normalizeScriptPublicAPIMethod(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "POST":
return "POST"
default:
return "POST"
}
}
func normalizeScriptPublicAPIRequestMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "params-only":
return "params-only"
default:
return "standard"
}
}
func normalizeScriptPublicAPIResponseMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "result-only":
return "result-only"
default:
return "envelope"
}
}
func normalizeScriptPublicAPITimeout(value int) int {
if value <= 0 {
return defaultScriptPublicAPITimeoutMs
}
if value < 1000 {
return 1000
}
if value > 30*60*1000 {
return 30 * 60 * 1000
}
return value
}
func normalizeScriptPublicAPIPath(value string) string {
normalized := strings.TrimSpace(value)
if normalized == "" {
return ""
}
normalized = strings.ReplaceAll(normalized, "\\", "/")
lower := strings.ToLower(normalized)
if strings.HasPrefix(lower, scriptPublicAPIBasePath+"/") {
normalized = normalized[len(scriptPublicAPIBasePath)+1:]
} else if strings.HasPrefix(lower, strings.TrimPrefix(scriptPublicAPIBasePath, "/")+"/") {
normalized = normalized[len(strings.TrimPrefix(scriptPublicAPIBasePath, "/"))+1:]
}
cleaned := path.Clean("/" + normalized)
cleaned = strings.Trim(cleaned, "/")
if cleaned == "" || cleaned == "." {
return ""
}
parts := strings.Split(cleaned, "/")
result := make([]string, 0, len(parts))
for _, part := range parts {
if normalizedPart := normalizeScriptPublicAPIPathSegment(part); normalizedPart != "" {
result = append(result, normalizedPart)
}
}
return strings.Join(result, "/")
}
func normalizeScriptPublicAPIPathSegment(value string) string {
var builder strings.Builder
lastDash := false
for _, ch := range strings.TrimSpace(value) {
switch {
case ch >= 'a' && ch <= 'z':
builder.WriteRune(ch)
lastDash = false
case ch >= 'A' && ch <= 'Z':
builder.WriteRune(ch + 32)
lastDash = false
case ch >= '0' && ch <= '9':
builder.WriteRune(ch)
lastDash = false
case ch == '-', ch == '_', ch == '.':
builder.WriteRune(ch)
lastDash = false
default:
if !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}
func scriptPublicAPIRoute(pathValue string) string {
pathValue = normalizeScriptPublicAPIPath(pathValue)
if pathValue == "" {
return scriptPublicAPIBasePath
}
return scriptPublicAPIBasePath + "/" + pathValue
}
func isSafeScriptID(value string) bool {
for _, ch := range value {
switch {
case ch >= 'a' && ch <= 'z':
case ch >= 'A' && ch <= 'Z':
case ch >= '0' && ch <= '9':
case ch == '-', ch == '_', ch == '.':
default:
return false
}
}
return true
}
func parseRFC3339OrZero(value string) time.Time {
ts, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil {
return time.Time{}
}
return ts
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}