mirror of
https://github.com/aykhans/sarin.git
synced 2026-02-28 06:49:13 +00:00
add scripting js/lua
This commit is contained in:
185
internal/script/chain.go
Normal file
185
internal/script/chain.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// Chain holds the loaded script sources and can create engine instances.
|
||||
// The sources are loaded once, but engines are created per-worker since they're not thread-safe.
|
||||
type Chain struct {
|
||||
luaSources []*Source
|
||||
jsSources []*Source
|
||||
}
|
||||
|
||||
// NewChain creates a new script chain from loaded sources.
|
||||
// Lua scripts run first, then JavaScript scripts, in the order provided.
|
||||
func NewChain(luaSources, jsSources []*Source) *Chain {
|
||||
return &Chain{
|
||||
luaSources: luaSources,
|
||||
jsSources: jsSources,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEmpty returns true if there are no scripts to execute.
|
||||
func (c *Chain) IsEmpty() bool {
|
||||
return len(c.luaSources) == 0 && len(c.jsSources) == 0
|
||||
}
|
||||
|
||||
// Transformer holds instantiated script engines for a single worker.
|
||||
// It is NOT safe for concurrent use.
|
||||
type Transformer struct {
|
||||
luaEngines []*LuaEngine
|
||||
jsEngines []*JsEngine
|
||||
}
|
||||
|
||||
// NewTransformer creates engine instances from the chain's sources.
|
||||
// Call this once per worker goroutine.
|
||||
func (c *Chain) NewTransformer() (*Transformer, error) {
|
||||
if c.IsEmpty() {
|
||||
return &Transformer{}, nil
|
||||
}
|
||||
|
||||
t := &Transformer{
|
||||
luaEngines: make([]*LuaEngine, 0, len(c.luaSources)),
|
||||
jsEngines: make([]*JsEngine, 0, len(c.jsSources)),
|
||||
}
|
||||
|
||||
// Create Lua engines
|
||||
for i, src := range c.luaSources {
|
||||
engine, err := NewLuaEngine(src.Content)
|
||||
if err != nil {
|
||||
t.Close() // Clean up already created engines
|
||||
return nil, fmt.Errorf("lua script[%d]: %w", i, err)
|
||||
}
|
||||
t.luaEngines = append(t.luaEngines, engine)
|
||||
}
|
||||
|
||||
// Create JS engines
|
||||
for i, src := range c.jsSources {
|
||||
engine, err := NewJsEngine(src.Content)
|
||||
if err != nil {
|
||||
t.Close() // Clean up already created engines
|
||||
return nil, fmt.Errorf("js script[%d]: %w", i, err)
|
||||
}
|
||||
t.jsEngines = append(t.jsEngines, engine)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Transform applies all scripts to the request data.
|
||||
// Lua scripts run first, then JavaScript scripts.
|
||||
func (t *Transformer) Transform(req *RequestData) error {
|
||||
// Run Lua scripts
|
||||
for i, engine := range t.luaEngines {
|
||||
if err := engine.Transform(req); err != nil {
|
||||
return fmt.Errorf("lua script[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run JS scripts
|
||||
for i, engine := range t.jsEngines {
|
||||
if err := engine.Transform(req); err != nil {
|
||||
return fmt.Errorf("js script[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases all engine resources.
|
||||
func (t *Transformer) Close() {
|
||||
for _, engine := range t.luaEngines {
|
||||
engine.Close()
|
||||
}
|
||||
for _, engine := range t.jsEngines {
|
||||
engine.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// IsEmpty returns true if there are no engines.
|
||||
func (t *Transformer) IsEmpty() bool {
|
||||
return len(t.luaEngines) == 0 && len(t.jsEngines) == 0
|
||||
}
|
||||
|
||||
// RequestDataFromFastHTTP extracts RequestData from a fasthttp.Request.
|
||||
func RequestDataFromFastHTTP(req *fasthttp.Request) *RequestData {
|
||||
data := &RequestData{
|
||||
Method: string(req.Header.Method()),
|
||||
URL: string(req.URI().FullURI()),
|
||||
Path: string(req.URI().Path()),
|
||||
Body: string(req.Body()),
|
||||
Headers: make(map[string][]string),
|
||||
Params: make(map[string][]string),
|
||||
Cookies: make(map[string][]string),
|
||||
}
|
||||
|
||||
// Extract headers (supports multiple values per key)
|
||||
req.Header.All()(func(key, value []byte) bool {
|
||||
k := string(key)
|
||||
data.Headers[k] = append(data.Headers[k], string(value))
|
||||
return true
|
||||
})
|
||||
|
||||
// Extract query params (supports multiple values per key)
|
||||
req.URI().QueryArgs().All()(func(key, value []byte) bool {
|
||||
k := string(key)
|
||||
data.Params[k] = append(data.Params[k], string(value))
|
||||
return true
|
||||
})
|
||||
|
||||
// Extract cookies (supports multiple values per key)
|
||||
req.Header.Cookies()(func(key, value []byte) bool {
|
||||
k := string(key)
|
||||
data.Cookies[k] = append(data.Cookies[k], string(value))
|
||||
return true
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ApplyToFastHTTP applies the modified RequestData back to a fasthttp.Request.
|
||||
func ApplyToFastHTTP(data *RequestData, req *fasthttp.Request) {
|
||||
// Method
|
||||
req.Header.SetMethod(data.Method)
|
||||
|
||||
// Path (preserve scheme and host)
|
||||
req.URI().SetPath(data.Path)
|
||||
|
||||
// Body
|
||||
req.SetBody([]byte(data.Body))
|
||||
|
||||
// Clear and set headers (supports multiple values per key)
|
||||
req.Header.All()(func(key, _ []byte) bool {
|
||||
keyStr := string(key)
|
||||
if keyStr != "Host" {
|
||||
req.Header.Del(keyStr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
for k, values := range data.Headers {
|
||||
if k != "Host" { // Don't overwrite Host
|
||||
for _, v := range values {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear and set query params (supports multiple values per key)
|
||||
req.URI().QueryArgs().Reset()
|
||||
for k, values := range data.Params {
|
||||
for _, v := range values {
|
||||
req.URI().QueryArgs().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear and set cookies (supports multiple values per key)
|
||||
req.Header.DelAllCookies()
|
||||
for k, values := range data.Cookies {
|
||||
for _, v := range values {
|
||||
req.Header.SetCookie(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
198
internal/script/js.go
Normal file
198
internal/script/js.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// JsEngine implements the Engine interface using goja (JavaScript).
|
||||
type JsEngine struct {
|
||||
runtime *goja.Runtime
|
||||
transform goja.Callable
|
||||
}
|
||||
|
||||
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
||||
// The script must define a global `transform` function that takes a request object
|
||||
// and returns the modified request object.
|
||||
//
|
||||
// Example JavaScript script:
|
||||
//
|
||||
// function transform(req) {
|
||||
// req.headers["X-Custom"] = "value";
|
||||
// return req;
|
||||
// }
|
||||
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||
vm := goja.New()
|
||||
|
||||
// Execute the script to define the transform function
|
||||
_, err := vm.RunString(scriptContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute JavaScript script: %w", err)
|
||||
}
|
||||
|
||||
// Get the transform function
|
||||
transformVal := vm.Get("transform")
|
||||
if transformVal == nil || goja.IsUndefined(transformVal) || goja.IsNull(transformVal) {
|
||||
return nil, errors.New("script must define a global 'transform' function")
|
||||
}
|
||||
|
||||
transform, ok := goja.AssertFunction(transformVal)
|
||||
if !ok {
|
||||
return nil, errors.New("'transform' must be a function")
|
||||
}
|
||||
|
||||
return &JsEngine{
|
||||
runtime: vm,
|
||||
transform: transform,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Transform executes the JavaScript transform function with the given request data.
|
||||
func (e *JsEngine) Transform(req *RequestData) error {
|
||||
// Convert RequestData to JavaScript object
|
||||
reqObj := e.requestDataToObject(req)
|
||||
|
||||
// Call transform(req)
|
||||
result, err := e.transform(goja.Undefined(), reqObj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JavaScript transform error: %w", err)
|
||||
}
|
||||
|
||||
// Update RequestData from the returned object
|
||||
if err := e.objectToRequestData(result, req); err != nil {
|
||||
return fmt.Errorf("failed to parse transform result: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the JavaScript runtime resources.
|
||||
func (e *JsEngine) Close() {
|
||||
// goja doesn't have an explicit close method, but we can help GC
|
||||
e.runtime = nil
|
||||
e.transform = nil
|
||||
}
|
||||
|
||||
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
||||
func (e *JsEngine) requestDataToObject(req *RequestData) goja.Value {
|
||||
obj := e.runtime.NewObject()
|
||||
|
||||
_ = obj.Set("method", req.Method)
|
||||
_ = obj.Set("url", req.URL)
|
||||
_ = obj.Set("path", req.Path)
|
||||
_ = obj.Set("body", req.Body)
|
||||
|
||||
// Headers (map[string][]string -> object of arrays)
|
||||
headers := e.runtime.NewObject()
|
||||
for k, values := range req.Headers {
|
||||
_ = headers.Set(k, e.stringSliceToArray(values))
|
||||
}
|
||||
_ = obj.Set("headers", headers)
|
||||
|
||||
// Params (map[string][]string -> object of arrays)
|
||||
params := e.runtime.NewObject()
|
||||
for k, values := range req.Params {
|
||||
_ = params.Set(k, e.stringSliceToArray(values))
|
||||
}
|
||||
_ = obj.Set("params", params)
|
||||
|
||||
// Cookies (map[string][]string -> object of arrays)
|
||||
cookies := e.runtime.NewObject()
|
||||
for k, values := range req.Cookies {
|
||||
_ = cookies.Set(k, e.stringSliceToArray(values))
|
||||
}
|
||||
_ = obj.Set("cookies", cookies)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// objectToRequestData updates RequestData from a JavaScript object.
|
||||
func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
|
||||
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
|
||||
return errors.New("transform function must return an object")
|
||||
}
|
||||
|
||||
obj := val.ToObject(e.runtime)
|
||||
if obj == nil {
|
||||
return errors.New("transform function must return an object")
|
||||
}
|
||||
|
||||
// Method
|
||||
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
|
||||
req.Method = v.String()
|
||||
}
|
||||
|
||||
// URL
|
||||
if v := obj.Get("url"); v != nil && !goja.IsUndefined(v) {
|
||||
req.URL = v.String()
|
||||
}
|
||||
|
||||
// Path
|
||||
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
|
||||
req.Path = v.String()
|
||||
}
|
||||
|
||||
// Body
|
||||
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
|
||||
req.Body = v.String()
|
||||
}
|
||||
|
||||
// Headers
|
||||
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||
}
|
||||
|
||||
// Params
|
||||
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||
}
|
||||
|
||||
// Cookies
|
||||
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stringSliceToArray converts a Go []string to a JavaScript array.
|
||||
func (e *JsEngine) stringSliceToArray(values []string) *goja.Object {
|
||||
ifaces := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
ifaces[i] = v
|
||||
}
|
||||
return e.runtime.NewArray(ifaces...)
|
||||
}
|
||||
|
||||
// objectToStringSliceMap converts a JavaScript object to a Go map[string][]string.
|
||||
// Supports both single string values and array values.
|
||||
func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string {
|
||||
if obj == nil {
|
||||
return make(map[string][]string)
|
||||
}
|
||||
|
||||
result := make(map[string][]string)
|
||||
for _, key := range obj.Keys() {
|
||||
v := obj.Get(key)
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's an array
|
||||
if arr, ok := v.Export().([]interface{}); ok {
|
||||
var values []string
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
values = append(values, s)
|
||||
}
|
||||
}
|
||||
result[key] = values
|
||||
} else {
|
||||
// Single value - wrap in slice
|
||||
result[key] = []string{v.String()}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
191
internal/script/lua.go
Normal file
191
internal/script/lua.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// LuaEngine implements the Engine interface using gopher-lua.
|
||||
type LuaEngine struct {
|
||||
state *lua.LState
|
||||
transform *lua.LFunction
|
||||
}
|
||||
|
||||
// NewLuaEngine creates a new Lua script engine with the given script content.
|
||||
// The script must define a global `transform` function that takes a request table
|
||||
// and returns the modified request table.
|
||||
//
|
||||
// Example Lua script:
|
||||
//
|
||||
// function transform(req)
|
||||
// req.headers["X-Custom"] = "value"
|
||||
// return req
|
||||
// end
|
||||
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||
L := lua.NewState()
|
||||
|
||||
// Execute the script to define the transform function
|
||||
if err := L.DoString(scriptContent); err != nil {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("failed to execute Lua script: %w", err)
|
||||
}
|
||||
|
||||
// Get the transform function
|
||||
transform := L.GetGlobal("transform")
|
||||
if transform.Type() != lua.LTFunction {
|
||||
L.Close()
|
||||
return nil, errors.New("script must define a global 'transform' function")
|
||||
}
|
||||
|
||||
return &LuaEngine{
|
||||
state: L,
|
||||
transform: transform.(*lua.LFunction),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Transform executes the Lua transform function with the given request data.
|
||||
func (e *LuaEngine) Transform(req *RequestData) error {
|
||||
// Convert RequestData to Lua table
|
||||
reqTable := e.requestDataToTable(req)
|
||||
|
||||
// Call transform(req)
|
||||
e.state.Push(e.transform)
|
||||
e.state.Push(reqTable)
|
||||
if err := e.state.PCall(1, 1, nil); err != nil {
|
||||
return fmt.Errorf("lua transform error: %w", err)
|
||||
}
|
||||
|
||||
// Get the result
|
||||
result := e.state.Get(-1)
|
||||
e.state.Pop(1)
|
||||
|
||||
if result.Type() != lua.LTTable {
|
||||
return fmt.Errorf("transform function must return a table, got %s", result.Type())
|
||||
}
|
||||
|
||||
// Update RequestData from the returned table
|
||||
e.tableToRequestData(result.(*lua.LTable), req)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the Lua state resources.
|
||||
func (e *LuaEngine) Close() {
|
||||
if e.state != nil {
|
||||
e.state.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// requestDataToTable converts RequestData to a Lua table.
|
||||
func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
|
||||
L := e.state
|
||||
t := L.NewTable()
|
||||
|
||||
t.RawSetString("method", lua.LString(req.Method))
|
||||
t.RawSetString("url", lua.LString(req.URL))
|
||||
t.RawSetString("path", lua.LString(req.Path))
|
||||
t.RawSetString("body", lua.LString(req.Body))
|
||||
|
||||
// Headers (map[string][]string -> table of arrays)
|
||||
headers := L.NewTable()
|
||||
for k, values := range req.Headers {
|
||||
arr := L.NewTable()
|
||||
for _, v := range values {
|
||||
arr.Append(lua.LString(v))
|
||||
}
|
||||
headers.RawSetString(k, arr)
|
||||
}
|
||||
t.RawSetString("headers", headers)
|
||||
|
||||
// Params (map[string][]string -> table of arrays)
|
||||
params := L.NewTable()
|
||||
for k, values := range req.Params {
|
||||
arr := L.NewTable()
|
||||
for _, v := range values {
|
||||
arr.Append(lua.LString(v))
|
||||
}
|
||||
params.RawSetString(k, arr)
|
||||
}
|
||||
t.RawSetString("params", params)
|
||||
|
||||
// Cookies (map[string][]string -> table of arrays)
|
||||
cookies := L.NewTable()
|
||||
for k, values := range req.Cookies {
|
||||
arr := L.NewTable()
|
||||
for _, v := range values {
|
||||
arr.Append(lua.LString(v))
|
||||
}
|
||||
cookies.RawSetString(k, arr)
|
||||
}
|
||||
t.RawSetString("cookies", cookies)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// tableToRequestData updates RequestData from a Lua table.
|
||||
func (e *LuaEngine) tableToRequestData(t *lua.LTable, req *RequestData) {
|
||||
// Method
|
||||
if v := t.RawGetString("method"); v.Type() == lua.LTString {
|
||||
req.Method = string(v.(lua.LString))
|
||||
}
|
||||
|
||||
// URL
|
||||
if v := t.RawGetString("url"); v.Type() == lua.LTString {
|
||||
req.URL = string(v.(lua.LString))
|
||||
}
|
||||
|
||||
// Path
|
||||
if v := t.RawGetString("path"); v.Type() == lua.LTString {
|
||||
req.Path = string(v.(lua.LString))
|
||||
}
|
||||
|
||||
// Body
|
||||
if v := t.RawGetString("body"); v.Type() == lua.LTString {
|
||||
req.Body = string(v.(lua.LString))
|
||||
}
|
||||
|
||||
// Headers
|
||||
if v := t.RawGetString("headers"); v.Type() == lua.LTTable {
|
||||
req.Headers = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||
}
|
||||
|
||||
// Params
|
||||
if v := t.RawGetString("params"); v.Type() == lua.LTTable {
|
||||
req.Params = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||
}
|
||||
|
||||
// Cookies
|
||||
if v := t.RawGetString("cookies"); v.Type() == lua.LTTable {
|
||||
req.Cookies = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||
}
|
||||
}
|
||||
|
||||
// tableToStringSliceMap converts a Lua table to a Go map[string][]string.
|
||||
// Supports both single string values and array values.
|
||||
func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
t.ForEach(func(k, v lua.LValue) {
|
||||
if k.Type() != lua.LTString {
|
||||
return
|
||||
}
|
||||
key := string(k.(lua.LString))
|
||||
|
||||
switch v.Type() {
|
||||
case lua.LTString:
|
||||
// Single string value
|
||||
result[key] = []string{string(v.(lua.LString))}
|
||||
case lua.LTTable:
|
||||
// Array of strings
|
||||
var values []string
|
||||
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
||||
if item.Type() == lua.LTString {
|
||||
values = append(values, string(item.(lua.LString)))
|
||||
}
|
||||
})
|
||||
result[key] = values
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
190
internal/script/script.go
Normal file
190
internal/script/script.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RequestData represents the request data passed to scripts for transformation.
|
||||
// Scripts can modify any field and the changes will be applied to the actual request.
|
||||
// Headers, Params, and Cookies use []string values to support multiple values per key.
|
||||
type RequestData struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Path string `json:"path"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Params map[string][]string `json:"params"`
|
||||
Cookies map[string][]string `json:"cookies"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// Engine defines the interface for script engines (Lua, JavaScript).
|
||||
// Each engine must be able to transform request data using a user-provided script.
|
||||
type Engine interface {
|
||||
// Transform executes the script's transform function with the given request data.
|
||||
// The script should modify the RequestData and return it.
|
||||
Transform(req *RequestData) error
|
||||
|
||||
// Close releases any resources held by the engine.
|
||||
Close()
|
||||
}
|
||||
|
||||
// EngineType represents the type of script engine.
|
||||
type EngineType string
|
||||
|
||||
const (
|
||||
EngineTypeLua EngineType = "lua"
|
||||
EngineTypeJavaScript EngineType = "js"
|
||||
)
|
||||
|
||||
// Source represents a loaded script source.
|
||||
type Source struct {
|
||||
Content string
|
||||
EngineType EngineType
|
||||
}
|
||||
|
||||
// LoadSource loads a script from the given source string.
|
||||
// The source can be:
|
||||
// - Inline script: any string not starting with "@"
|
||||
// - Escaped "@": strings starting with "@@" (literal "@" at start, returns string without first @)
|
||||
// - File reference: "@/path/to/file" or "@./relative/path"
|
||||
// - URL reference: "@http://..." or "@https://..."
|
||||
func LoadSource(ctx context.Context, source string, engineType EngineType) (*Source, error) {
|
||||
if source == "" {
|
||||
return nil, errors.New("script source cannot be empty")
|
||||
}
|
||||
|
||||
var content string
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(source, "@@"):
|
||||
// Escaped @ - it's an inline script starting with literal @
|
||||
content = source[1:] // Remove first @, keep the rest
|
||||
case strings.HasPrefix(source, "@"):
|
||||
// File or URL reference
|
||||
ref := source[1:]
|
||||
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
||||
content, err = fetchURL(ctx, ref)
|
||||
} else {
|
||||
content, err = readFile(ref)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load script from %q: %w", ref, err)
|
||||
}
|
||||
default:
|
||||
// Inline script
|
||||
content = source
|
||||
}
|
||||
|
||||
return &Source{
|
||||
Content: content,
|
||||
EngineType: engineType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadSources loads multiple script sources.
|
||||
func LoadSources(ctx context.Context, sources []string, engineType EngineType) ([]*Source, error) {
|
||||
loaded := make([]*Source, 0, len(sources))
|
||||
for i, src := range sources {
|
||||
source, err := LoadSource(ctx, src, engineType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("script[%d]: %w", i, err)
|
||||
}
|
||||
loaded = append(loaded, source)
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
// ValidateScript validates a script source by loading it and checking syntax.
|
||||
// It loads the script (from file/URL/inline), parses it, and verifies
|
||||
// that a 'transform' function is defined.
|
||||
func ValidateScript(ctx context.Context, source string, engineType EngineType) error {
|
||||
// Load the script source
|
||||
src, err := LoadSource(ctx, source, engineType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to create an engine - this validates syntax and transform function
|
||||
var engine Engine
|
||||
switch engineType {
|
||||
case EngineTypeLua:
|
||||
engine, err = NewLuaEngine(src.Content)
|
||||
case EngineTypeJavaScript:
|
||||
engine, err = NewJsEngine(src.Content)
|
||||
default:
|
||||
return fmt.Errorf("unknown engine type: %s", engineType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up the engine - we only needed it for validation
|
||||
engine.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateScripts validates multiple script sources.
|
||||
func ValidateScripts(ctx context.Context, sources []string, engineType EngineType) error {
|
||||
for i, src := range sources {
|
||||
if err := ValidateScript(ctx, src, engineType); err != nil {
|
||||
return fmt.Errorf("script[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchURL downloads content from an HTTP/HTTPS URL.
|
||||
func fetchURL(ctx context.Context, url string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// readFile reads content from a local file.
|
||||
func readFile(path string) (string, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
path = filepath.Join(pwd, path)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
Reference in New Issue
Block a user