diff --git a/go.mod b/go.mod index 578f898..0c99a60 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module Moonshark go 1.24.1 require ( - git.sharkk.net/Sky/LuaJIT-to-Go v0.4.0 + git.sharkk.net/Sky/LuaJIT-to-Go v0.4.1 github.com/VictoriaMetrics/fastcache v1.12.4 github.com/alexedwards/argon2id v1.0.0 github.com/deneonet/benc v1.1.8 @@ -11,7 +11,7 @@ require ( github.com/matoous/go-nanoid/v2 v2.1.0 github.com/valyala/bytebufferpool v1.0.0 github.com/valyala/fasthttp v1.62.0 - zombiezen.com/go/sqlite v1.4.0 + zombiezen.com/go/sqlite v1.4.2 ) require ( @@ -27,7 +27,7 @@ require ( golang.org/x/crypto v0.38.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/sys v0.33.0 // indirect - modernc.org/libc v1.65.7 // indirect + modernc.org/libc v1.65.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.37.1 // indirect diff --git a/go.sum b/go.sum index 88ce642..bb6fb14 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.sharkk.net/Sky/LuaJIT-to-Go v0.4.0 h1:1aYHZnVBQSYsvSOK34FEQO0D7iTMgHREavVYVhlDXUM= -git.sharkk.net/Sky/LuaJIT-to-Go v0.4.0/go.mod h1:HQz+D7AFxOfNbTIogjxP+shEBtz1KKrLlLucU+w07c8= +git.sharkk.net/Sky/LuaJIT-to-Go v0.4.1 h1:CAYt+C6Vgo4JxK876j0ApQ2GDFFvy9FKO0OoZBVD18k= +git.sharkk.net/Sky/LuaJIT-to-Go v0.4.1/go.mod h1:HQz+D7AFxOfNbTIogjxP+shEBtz1KKrLlLucU+w07c8= github.com/VictoriaMetrics/fastcache v1.12.4 h1:2xvmwZBW+9QtHsXggfzAZRs1FZWCsBs8QDg22bMidf0= github.com/VictoriaMetrics/fastcache v1.12.4/go.mod h1:K+JGPBn0sueFlLjZ8rcVM0cKkWKNElKyQXmw57QOoYI= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= @@ -109,6 +109,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q= +modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -125,3 +127,5 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= +zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= +zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= diff --git a/runner/embed.go b/runner/embed.go index 6824d62..8482615 100644 --- a/runner/embed.go +++ b/runner/embed.go @@ -40,6 +40,9 @@ var timeLuaCode string //go:embed lua/math.lua var mathLuaCode string +//go:embed lua/templates.lua +var templateLuaCode string + // ModuleInfo holds information about an embeddable Lua module type ModuleInfo struct { Name string // Module name @@ -60,6 +63,7 @@ var ( {Name: "crypto", Code: cryptoLuaCode}, {Name: "time", Code: timeLuaCode}, {Name: "math", Code: mathLuaCode}, + {Name: "tmpl", Code: templateLuaCode}, } ) diff --git a/runner/lua/templates.lua b/runner/lua/templates.lua new file mode 100644 index 0000000..2205e32 --- /dev/null +++ b/runner/lua/templates.lua @@ -0,0 +1,31 @@ +local template = {} + +function template.render(path, data) + local result = __template_render(path, data) + if type(result) == "string" and result:find("^template%.") then + error(result, 2) + end + return result +end + +function template.include(path, data) + local result = __template_include(path, data) + if type(result) == "string" and result:find("^template%.") then + error(result, 2) + end + return result +end + +function template.exists(path) + return __template_exists(path) +end + +function template.clear_cache(path) + return __template_clear_cache(path) +end + +function template.cache_size() + return __template_cache_size() +end + +return template diff --git a/runner/runner.go b/runner/runner.go index 358c228..425afe7 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -288,6 +288,7 @@ cleanup: CleanupFS() CleanupSQLite() + CleanupTemplate() logger.Debug("Runner closed") return nil diff --git a/runner/sandbox.go b/runner/sandbox.go index f8f69e2..3199250 100644 --- a/runner/sandbox.go +++ b/runner/sandbox.go @@ -121,6 +121,10 @@ func (s *Sandbox) registerCoreFunctions(state *luajit.State) error { return err } + if err := RegisterTemplateFunctions(state); err != nil { + return err + } + return nil } diff --git a/runner/templates.go b/runner/templates.go new file mode 100644 index 0000000..d431bcc --- /dev/null +++ b/runner/templates.go @@ -0,0 +1,440 @@ +package runner + +import ( + "fmt" + "html" + "os" + "regexp" + "strings" + "sync" + "time" + + "maps" + + luajit "git.sharkk.net/Sky/LuaJIT-to-Go" +) + +// CachedTemplate holds compiled Lua code with metadata +type CachedTemplate struct { + CompiledLua []byte // Compiled Lua bytecode + ModTime time.Time + Path string +} + +// TemplateCache manages template caching with fast lookups +type TemplateCache struct { + templates sync.Map // map[string]*CachedTemplate +} + +var ( + templateCache = &TemplateCache{} + simpleVarRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) +) + +// htmlEscape escapes HTML special characters +func htmlEscape(s string) string { + return html.EscapeString(s) +} + +// compileTemplate converts template string to Lua code +func compileTemplate(templateStr string) (string, error) { + pos := 1 + chunks := []any{} + templateLen := len(templateStr) + + for pos <= templateLen { + // Find next template tag + unescapedStart := strings.Index(templateStr[pos-1:], "{{{") + escapedStart := strings.Index(templateStr[pos-1:], "{{") + + // Adjust positions to be absolute + if unescapedStart != -1 { + unescapedStart += pos - 1 + } + if escapedStart != -1 { + escapedStart += pos - 1 + } + + var start, openLen int + var tagType string + + // Determine which tag comes first + if unescapedStart != -1 && (escapedStart == -1 || unescapedStart <= escapedStart) { + start, tagType, openLen = unescapedStart, "-", 3 + } else if escapedStart != -1 { + start, tagType, openLen = escapedStart, "=", 2 + } else { + // No more tags, add remaining text + if pos <= templateLen { + chunks = append(chunks, templateStr[pos-1:]) + } + break + } + + // Add text before tag + if start > pos-1 { + chunks = append(chunks, templateStr[pos-1:start]) + } + + // Find closing tag + pos = start + openLen + 1 + var closeTag string + if tagType == "-" { + closeTag = "}}}" + } else { + closeTag = "}}" + } + + closeStart := strings.Index(templateStr[pos-1:], closeTag) + if closeStart == -1 { + return "", fmt.Errorf("failed to find closing tag at position %d", pos) + } + closeStart += pos - 1 + + // Extract and trim code + code := strings.TrimSpace(templateStr[pos-1 : closeStart]) + + // Check if it's a simple variable for escaped output + isSimpleVar := tagType == "=" && simpleVarRe.MatchString(code) + + chunks = append(chunks, []any{tagType, code, pos, isSimpleVar}) + + // Move past closing tag + pos = closeStart + len(closeTag) + 1 + } + + // Generate Lua code + buffer := []string{"local _tostring, _escape, _b, _b_i = ...\n"} + + for _, chunk := range chunks { + switch v := chunk.(type) { + case string: + // Literal text + buffer = append(buffer, "_b_i = _b_i + 1\n") + buffer = append(buffer, fmt.Sprintf("_b[_b_i] = %q\n", v)) + case []any: + tagType := v[0].(string) + code := v[1].(string) + pos := v[2].(int) + isSimpleVar := v[3].(bool) + + switch tagType { + case "=": + if isSimpleVar { + buffer = append(buffer, "_b_i = _b_i + 1\n") + buffer = append(buffer, fmt.Sprintf("--[[%d]] _b[_b_i] = _escape(_tostring(%s))\n", pos, code)) + } else { + buffer = append(buffer, fmt.Sprintf("--[[%d]] %s\n", pos, code)) + } + case "-": + buffer = append(buffer, "_b_i = _b_i + 1\n") + buffer = append(buffer, fmt.Sprintf("--[[%d]] _b[_b_i] = _tostring(%s)\n", pos, code)) + } + } + } + + buffer = append(buffer, "return _b") + return strings.Join(buffer, ""), nil +} + +// getTemplate loads or retrieves a cached template +func getTemplate(templatePath string, state *luajit.State) ([]byte, error) { + // Resolve the path using the fs system + fullPath, err := ResolvePath(templatePath) + if err != nil { + return nil, fmt.Errorf("template path resolution failed: %w", err) + } + + // Check if file exists and get mod time + info, err := os.Stat(fullPath) + if err != nil { + return nil, fmt.Errorf("template file not found: %w", err) + } + + // Fast path: check cache first + if cached, ok := templateCache.templates.Load(templatePath); ok { + cachedTpl := cached.(*CachedTemplate) + // Compare mod times for cache validity + if cachedTpl.ModTime.Equal(info.ModTime()) { + return cachedTpl.CompiledLua, nil + } + } + + // Cache miss or file changed - load and compile template + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to read template: %w", err) + } + + // Compile template to Lua code + luaCode, err := compileTemplate(string(content)) + if err != nil { + return nil, fmt.Errorf("template compile error: %w", err) + } + + // Compile Lua code to bytecode + bytecode, err := state.CompileBytecode(luaCode, templatePath) + if err != nil { + return nil, fmt.Errorf("lua compile error: %w", err) + } + + // Store in cache + cachedTpl := &CachedTemplate{ + CompiledLua: bytecode, + ModTime: info.ModTime(), + Path: fullPath, + } + templateCache.templates.Store(templatePath, cachedTpl) + + return bytecode, nil +} + +// goHtmlEscape provides HTML escaping from Go +func goHtmlEscape(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("") + return 1 + } + + input := state.ToString(1) + escaped := htmlEscape(input) + state.PushString(escaped) + return 1 +} + +// templateInclude renders a template with auto-merged data +func templateInclude(state *luajit.State) int { + // Get template path + if !state.IsString(1) { + state.PushString("template.include: path must be a string") + return 1 + } + templatePath := state.ToString(1) + + // Get current template data + state.GetGlobal("__template_data") + currentData, _ := state.ToTable(-1) + state.Pop(1) + + // Get new data (optional) + var newData map[string]any + if state.GetTop() >= 2 && !state.IsNil(2) { + if envValue, err := state.ToValue(2); err == nil { + if envMap, ok := envValue.(map[string]any); ok { + newData = envMap + } + } + } + + // Merge data + mergedData := make(map[string]any) + if currentData != nil { + maps.Copy(mergedData, currentData) + } + if newData != nil { + maps.Copy(mergedData, newData) + } + + // Call templateRender with merged data + state.PushString(templatePath) + state.PushTable(mergedData) + return templateRender(state) +} +func templateRender(state *luajit.State) int { + // Get template path (required) + if !state.IsString(1) { + state.PushString("template.render: template path must be a string") + return 1 + } + templatePath := state.ToString(1) + + // Get data (optional) + var env map[string]any + if state.GetTop() >= 2 && !state.IsNil(2) { + var err error + envValue, err := state.ToValue(2) + if err != nil { + state.PushString("template.render: invalid data: " + err.Error()) + return 1 + } + if envMap, ok := envValue.(map[string]any); ok { + env = envMap + } + } + + // Load compiled template from cache + bytecode, err := getTemplate(templatePath, state) + if err != nil { + state.PushString("template.render: " + err.Error()) + return 1 + } + + // Load bytecode + if err := state.LoadBytecode(bytecode, templatePath); err != nil { + state.PushString("template.render: load error: " + err.Error()) + return 1 + } + + // Create runtime environment + runtimeEnv := make(map[string]any) + if env != nil { + maps.Copy(runtimeEnv, env) + } + + // Add current template data for nested calls + runtimeEnv["__template_data"] = env + + // Get current global environment + state.GetGlobal("_G") + globalEnv, err := state.ToTable(-1) + if err == nil { + for k, v := range globalEnv { + if _, exists := runtimeEnv[k]; !exists { + runtimeEnv[k] = v + } + } + } + state.Pop(1) + + // Set up runtime environment + if err := state.PushTable(runtimeEnv); err != nil { + state.Pop(1) // Pop bytecode + state.PushString("template.render: env error: " + err.Error()) + return 1 + } + + // Create metatable for environment + state.NewTable() + state.GetGlobal("_G") + state.SetField(-2, "__index") + state.SetMetatable(-2) + + // Set environment using setfenv + state.GetGlobal("setfenv") + state.PushCopy(-3) // Template function + state.PushCopy(-3) // Environment + state.Call(2, 1) // setfenv(fn, env) returns the function + + // Prepare arguments for template execution + state.GetGlobal("tostring") // tostring function + state.PushGoFunction(goHtmlEscape) // HTML escape function + state.NewTable() // output buffer + state.PushNumber(0) // buffer index + + // Execute template (4 args, 1 result) + if err := state.Call(4, 1); err != nil { + state.Pop(1) // Pop environment + state.PushString("template.render: execution error: " + err.Error()) + return 1 + } + + // Get result buffer + buffer, err := state.ToTable(-1) + if err != nil { + state.Pop(2) // Pop buffer and environment + state.PushString("template.render: result error: " + err.Error()) + return 1 + } + + // Convert buffer to string + var result strings.Builder + i := 1 + for { + if val, exists := buffer[fmt.Sprintf("%d", i)]; exists { + result.WriteString(fmt.Sprintf("%v", val)) + i++ + } else { + break + } + } + + state.Pop(2) // Pop buffer and environment + state.PushString(result.String()) + return 1 +} + +// templateExists checks if a template file exists +func templateExists(state *luajit.State) int { + if !state.IsString(1) { + state.PushBoolean(false) + return 1 + } + templatePath := state.ToString(1) + + // Resolve path + fullPath, err := ResolvePath(templatePath) + if err != nil { + state.PushBoolean(false) + return 1 + } + + // Check if file exists + _, err = os.Stat(fullPath) + state.PushBoolean(err == nil) + return 1 +} + +// templateClearCache clears the template cache +func templateClearCache(state *luajit.State) int { + // Optional: clear specific template + if state.GetTop() >= 1 && state.IsString(1) { + templatePath := state.ToString(1) + templateCache.templates.Delete(templatePath) + state.PushBoolean(true) + return 1 + } + + // Clear entire cache + templateCache.templates.Range(func(key, value any) bool { + templateCache.templates.Delete(key) + return true + }) + + state.PushBoolean(true) + return 1 +} + +// templateCacheSize returns the number of templates in cache +func templateCacheSize(state *luajit.State) int { + count := 0 + templateCache.templates.Range(func(key, value any) bool { + count++ + return true + }) + + state.PushNumber(float64(count)) + return 1 +} + +// RegisterTemplateFunctions registers template functions with the Lua state +func RegisterTemplateFunctions(state *luajit.State) error { + if err := state.RegisterGoFunction("__template_render", templateRender); err != nil { + return err + } + if err := state.RegisterGoFunction("__template_include", templateInclude); err != nil { + return err + } + if err := state.RegisterGoFunction("__template_exists", templateExists); err != nil { + return err + } + if err := state.RegisterGoFunction("__template_clear_cache", templateClearCache); err != nil { + return err + } + if err := state.RegisterGoFunction("__template_cache_size", templateCacheSize); err != nil { + return err + } + return nil +} + +// CleanupTemplate clears the template cache +func CleanupTemplate() { + templateCache.templates.Range(func(key, value any) bool { + templateCache.templates.Delete(key) + return true + }) +} + +// InvalidateTemplate removes a specific template from cache +func InvalidateTemplate(templatePath string) { + templateCache.templates.Delete(templatePath) +}