package main import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" lua "github.com/yuin/gopher-lua" ) // LuaResult holds the result of executing a Lua script type LuaResult struct { StatusCode int Body string ContentType string Headers map[string]string } func runLuaScript(scriptPath string, r *http.Request, projectDir string) (*LuaResult, error) { L := lua.NewState() defer L.Close() // Setup sandbox setupLuaSandbox(L, projectDir) // Load and execute the script if err := L.DoFile(scriptPath); err != nil { return nil, fmt.Errorf("loading script %s: %w", scriptPath, err) } // Get the HTTP method method := strings.ToUpper(r.Method) // Check if the script defines a handler for this method handler := L.GetGlobal(method) if handler.Type() == lua.LTNil { // Default to GET if no method-specific handler handler = L.GetGlobal("GET") if handler.Type() == lua.LTNil { return nil, fmt.Errorf("no %s handler defined in script", method) } } // Read request body once (body can only be read once) body, err := io.ReadAll(r.Body) if err != nil { return nil, fmt.Errorf("reading request body: %w", err) } bodyStr := string(body) // Build request table for Lua with methods reqTable := L.NewTable() reqTable.RawSetString("path", lua.LString(r.URL.Path)) reqTable.RawSetString("method", lua.LString(r.Method)) reqTable.RawSetString("query", buildLuaQuery(L, r.URL.Query())) reqTable.RawSetString("headers", buildLuaHeaders(L, r.Header)) reqTable.RawSetString("body", lua.LString(bodyStr)) // req:json() - parse body as JSON, returns (table, error) reqTable.RawSetString("json", L.NewFunction(func(ls *lua.LState) int { result, err := unmarshalJSONToLua(ls, []byte(bodyStr)) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString(err.Error())) return 2 } ls.Push(result) ls.Push(lua.LNil) return 2 })) // req:form() - parse body as application/x-www-form-urlencoded, returns (table, error) reqTable.RawSetString("form", L.NewFunction(func(ls *lua.LState) int { pairs, err := parseFormString(bodyStr) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString(err.Error())) return 2 } table := ls.NewTable() for k, v := range pairs { table.RawSetString(k, lua.LString(v)) } ls.Push(table) ls.Push(lua.LNil) return 2 })) // Call the handler err = L.CallByParam(lua.P{ Fn: handler, NRet: 3, }, reqTable) if err != nil { return nil, fmt.Errorf("executing handler: %w", err) } // Get return values status := L.ToInt(1) if status <= 0 { status = 200 } bodyVal := L.Get(2) resultBody := formatLuaReturn(bodyVal) // Check for optional headers table returnHeaders := L.Get(3) headers := map[string]string{} if t, ok := returnHeaders.(*lua.LTable); ok { t.ForEach(func(k, v lua.LValue) { headers[k.String()] = v.String() }) } contentType := headers["Content-Type"] delete(headers, "Content-Type") return &LuaResult{ StatusCode: int(status), Body: resultBody, ContentType: contentType, Headers: headers, }, nil } // setupLuaSandbox configures a sandboxed Lua environment func setupLuaSandbox(L *lua.LState, projectDir string) { // Set up safe os functions osMod := L.NewTable() L.SetField(osMod, "date", L.NewFunction(func(ls *lua.LState) int { format := ls.ToString(1) if format == "" { format = "%Y-%m-%d %H:%M:%S" } ls.Push(lua.LString(time.Now().Format(luaTimeToGoFormat(format)))) return 1 })) L.SetField(osMod, "time", L.NewFunction(func(ls *lua.LState) int { ls.Push(lua.LNumber(float64(time.Now().Unix()))) return 1 })) L.SetGlobal("os", osMod) // Remove dangerous globals L.SetGlobal("io", L.NewTable()) L.SetGlobal("debug", L.NewTable()) // Set up package.loaded cache and require() for module loading packageMod := L.NewTable() loadedCache := L.NewTable() L.SetField(packageMod, "loaded", loadedCache) L.SetField(packageMod, "preload", L.NewTable()) L.SetGlobal("package", packageMod) // requireFn - load a Lua module from the web directory // e.g., require("contact/lib") loads web/contact/lib.lua // Modules should end with: _M = moduleTable (the _M global is captured by require) requireFn := L.NewFunction(func(ls *lua.LState) int { moduleName := ls.ToString(1) if moduleName == "" { ls.Push(lua.LNil) ls.Push(lua.LString("require: missing module name")) return 2 } // Check cache first cached := loadedCache.RawGetString(moduleName) if cached.Type() != lua.LTNil { ls.Push(cached) return 1 } // Resolve path modulePath := strings.ReplaceAll(moduleName, ".", string(os.PathSeparator)) fullPath := filepath.Join(projectDir, modulePath+".lua") if !fileExists(fullPath) { ls.Push(lua.LNil) ls.Push(lua.LString("module " + moduleName + " not found")) return 2 } // Clear the _M capture variable ls.SetGlobal("_M", lua.LNil) // Execute the module - it should set _M to its table err := ls.DoFile(fullPath) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString("loading module " + moduleName + ": " + err.Error())) return 2 } modVal := ls.GetGlobal("_M") ls.SetGlobal("_M", lua.LNil) // Cache it loadedCache.RawSetString(moduleName, modVal) ls.Push(modVal) return 1 }) // Override the global require with our custom one L.SetGlobal("require", requireFn) // Set up safe file operations restricted to projectDir fs := L.NewTable() L.SetField(fs, "read", L.NewFunction(func(ls *lua.LState) int { path := ls.ToString(1) safePath := sanitizePath(path, projectDir) if safePath == "" { ls.Push(lua.LString("")) ls.Push(lua.LString("access denied")) return 2 } content, err := os.ReadFile(safePath) if err != nil { ls.Push(lua.LString("")) ls.Push(lua.LString(err.Error())) return 2 } ls.Push(lua.LString(string(content))) ls.Push(lua.LNil) return 2 })) L.SetField(fs, "write", L.NewFunction(func(ls *lua.LState) int { path := ls.ToString(1) content := ls.ToString(2) safePath := sanitizePath(path, projectDir) if safePath == "" { ls.Push(lua.LFalse) ls.Push(lua.LString("access denied")) return 2 } err := os.WriteFile(safePath, []byte(content), 0644) if err != nil { ls.Push(lua.LFalse) ls.Push(lua.LString(err.Error())) return 2 } ls.Push(lua.LTrue) ls.Push(lua.LNil) return 2 })) L.SetField(fs, "list", L.NewFunction(func(ls *lua.LState) int { path := ls.ToString(1) safePath := sanitizePath(path, projectDir) if safePath == "" { ls.Push(lua.LNil) ls.Push(lua.LString("access denied")) return 2 } entries, err := os.ReadDir(safePath) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString(err.Error())) return 2 } result := ls.NewTable() for _, entry := range entries { result.Append(lua.LString(entry.Name())) } ls.Push(result) ls.Push(lua.LNil) return 2 })) L.SetField(fs, "exists", L.NewFunction(func(ls *lua.LState) int { path := ls.ToString(1) safePath := sanitizePath(path, projectDir) if safePath == "" { ls.Push(lua.LFalse) return 1 } _, err := os.Stat(safePath) if err == nil { ls.Push(lua.LTrue) } else { ls.Push(lua.LFalse) } return 1 })) L.SetGlobal("fs", fs) // Set up HTTP client for API calls httpClient := L.NewTable() L.SetField(httpClient, "get", L.NewFunction(func(ls *lua.LState) int { url := ls.ToString(1) resp, err := http.Get(url) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString(err.Error())) return 2 } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) ls.Push(lua.LNumber(resp.StatusCode)) ls.Push(lua.LString(string(body))) return 2 })) L.SetField(httpClient, "post", L.NewFunction(func(ls *lua.LState) int { url := ls.ToString(1) body := ls.ToString(2) contentType := ls.ToString(3) if contentType == "" { contentType = "application/json" } resp, err := http.Post(url, contentType, strings.NewReader(body)) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString(err.Error())) return 2 } defer resp.Body.Close() resultBody, _ := io.ReadAll(resp.Body) ls.Push(lua.LNumber(resp.StatusCode)) ls.Push(lua.LString(string(resultBody))) return 2 })) L.SetGlobal("http", httpClient) // JSON helpers jsonMod := L.NewTable() L.SetField(jsonMod, "encode", L.NewFunction(func(ls *lua.LState) int { val := ls.Get(1) data, err := marshalLuaValue(val) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString(err.Error())) return 2 } ls.Push(lua.LString(string(data))) ls.Push(lua.LNil) return 2 })) L.SetField(jsonMod, "decode", L.NewFunction(func(ls *lua.LState) int { str := ls.ToString(1) result, err := unmarshalJSONToLua(ls, []byte(str)) if err != nil { ls.Push(lua.LNil) ls.Push(lua.LString(err.Error())) return 2 } ls.Push(result) ls.Push(lua.LNil) return 2 })) L.SetGlobal("json", jsonMod) // Set up SQLite3 database module setupDBModule(L, projectDir) } // luaTimeToGoFormat converts Lua os.date format specifiers to Go time format func luaTimeToGoFormat(format string) string { replacements := map[string]string{ "%Y": "2006", "%y": "06", "%m": "01", "%d": "02", "%H": "15", "%I": "03", "%M": "04", "%S": "05", "%p": "PM", "%A": "Monday", "%a": "Mon", "%B": "January", "%b": "Jan", "%T": "15:04:05", "%c": "Mon Jan 2 15:04:05 2006", "%x": "01/02/2006", "%X": "15:04:05", } for luaFmt, goFmt := range replacements { format = strings.ReplaceAll(format, luaFmt, goFmt) } return format } // sanitizePath ensures the path is within the project directory func sanitizePath(path, projectDir string) string { if !filepath.IsAbs(path) { path = filepath.Join(projectDir, path) } absPath, err := filepath.Abs(path) if err != nil { return "" } projectDirAbs, err := filepath.Abs(projectDir) if err != nil { return "" } if !strings.HasPrefix(absPath, projectDirAbs+string(os.PathSeparator)) && absPath != projectDirAbs { return "" } return absPath } // parseFormString parses an application/x-www-form-urlencoded string func parseFormString(body string) (map[string]string, error) { values, err := url.ParseQuery(body) if err != nil { return nil, err } result := make(map[string]string) for k, v := range values { result[k] = strings.Join(v, ",") } return result, nil } // buildLuaQuery builds a Lua table from query parameters func buildLuaQuery(L *lua.LState, query map[string][]string) *lua.LTable { table := L.NewTable() for key, values := range query { if len(values) == 1 { table.RawSetString(key, lua.LString(values[0])) } else { arr := L.NewTable() for _, v := range values { arr.Append(lua.LString(v)) } table.RawSetString(key, arr) } } return table } // buildLuaHeaders builds a Lua table from HTTP headers func buildLuaHeaders(L *lua.LState, header http.Header) *lua.LTable { table := L.NewTable() for key, values := range header { table.RawSetString(strings.ToLower(key), lua.LString(values[0])) } return table } // formatLuaReturn formats a Lua value for HTTP response func formatLuaReturn(val lua.LValue) string { switch v := val.(type) { case *lua.LString: return string(*v) case *lua.LTable: data := convertLuaTableToGo(v) jsonData, err := json.Marshal(data) if err != nil { return "{}" } return string(jsonData) default: if v.Type() == lua.LTNil { return "" } return val.String() } } // convertLuaTableToGo converts a Lua table to a Go interface{} func convertLuaTableToGo(t *lua.LTable) interface{} { result := make(map[string]interface{}) hasNumeric := false hasString := false t.ForEach(func(k, v lua.LValue) { if k.Type() == lua.LTNumber { hasNumeric = true } else if k.Type() == lua.LTString { hasString = true } }) if hasNumeric && !hasString { arr := make([]interface{}, 0) i := 1 for { val := t.RawGetInt(i) if val.Type() == lua.LTNil { break } arr = append(arr, convertLuaValue(val)) i++ } return arr } t.ForEach(func(k, v lua.LValue) { key := k.String() result[key] = convertLuaValue(v) }) return result } // convertLuaValue converts a Lua value to a Go value func convertLuaValue(val lua.LValue) interface{} { switch v := val.(type) { case *lua.LString: return string(*v) case lua.LNumber: return float64(v) case lua.LBool: return bool(v) case *lua.LTable: return convertLuaTableToGo(v) default: if val.Type() == lua.LTNil { return nil } return val.String() } } // marshalLuaValue marshals a Lua value to JSON func marshalLuaValue(val lua.LValue) ([]byte, error) { goVal := convertLuaValue(val) return json.Marshal(goVal) } // unmarshalJSONToLua unmarshals JSON to a Lua value func unmarshalJSONToLua(L *lua.LState, data []byte) (lua.LValue, error) { var goVal interface{} if err := json.Unmarshal(data, &goVal); err != nil { return nil, err } return convertGoToLua(L, goVal), nil } // convertGoToLua converts a Go value to a Lua value func convertGoToLua(L *lua.LState, val interface{}) lua.LValue { switch v := val.(type) { case string: return lua.LString(v) case float64: return lua.LNumber(v) case bool: return lua.LBool(v) case nil: return lua.LNil case map[string]interface{}: table := L.NewTable() for k, vv := range v { table.RawSetString(k, convertGoToLua(L, vv)) } return table case []interface{}: table := L.NewTable() for _, vv := range v { table.Append(convertGoToLua(L, vv)) } return table default: return lua.LString(fmt.Sprintf("%v", v)) } }