package engine import ( "bytes" "encoding/json" "fmt" "html" "io" "net/http" "net/url" "os" "path" "path/filepath" "strings" "time" "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/parser" "github.com/microcosm-cc/bluemonday" "github.com/sirupsen/logrus" "github.com/xyproto/algernon/lua/convert" "github.com/xyproto/algernon/lua/run3" "github.com/xyproto/algernon/utils" lua "github.com/xyproto/gopher-lua" "github.com/xyproto/splash" ) // FutureStatus is useful when redirecting in combination with writing to a // buffer before writing to a client. May contain more fields in the future. type FutureStatus struct { code int // Buffered HTTP status code closed bool // The server has stopped writing output to this client } // LoadBasicSystemFunctions loads functions related to logging, markdown and the // current server directory into the given Lua state func (ac *Config) LoadBasicSystemFunctions(L *lua.LState) { // Return the version string L.SetGlobal("version", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString(ac.versionString)) return 1 // number of results })) // Log text with the "Info" log type L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int { buf := convert.Arguments2buffer(L, false) // Log the combined text logrus.Info(buf.String()) return 0 // number of results })) // Log text with the "Warn" log type L.SetGlobal("warn", L.NewFunction(func(L *lua.LState) int { buf := convert.Arguments2buffer(L, false) // Log the combined text logrus.Warn(buf.String()) return 0 // number of results })) // Log text with the "Error" log type L.SetGlobal("err", L.NewFunction(func(L *lua.LState) int { buf := convert.Arguments2buffer(L, false) // Log the combined text logrus.Error(buf.String()) return 0 // number of results })) // Sleep for the given number of seconds (can be a float) L.SetGlobal("sleep", L.NewFunction(func(L *lua.LState) int { // Extract the correct number of nanoseconds duration := time.Duration(float64(L.ToNumber(1)) * 1000000000.0) // Wait and block the current thread of execution. time.Sleep(duration) return 0 })) // Return the current unixtime, with an attempt at nanosecond resolution L.SetGlobal("unixnano", L.NewFunction(func(L *lua.LState) int { // Extract the correct number of nanoseconds L.Push(lua.LNumber(time.Now().UnixNano())) return 1 // number of results })) // Convert Markdown to HTML L.SetGlobal("markdown", L.NewFunction(func(L *lua.LState) int { // Retrieve all the function arguments as a bytes.Buffer buf := convert.Arguments2buffer(L, true) // Create a Markdown parser with the desired extensions mdParser := parser.NewWithExtensions(enabledMarkdownExtensions) mdContent := buf.Bytes() // Convert the buffer to markdown htmlData := markdown.ToHTML(mdContent, mdParser, nil) // Apply syntax highlighting if highlightedHTML, err := splash.Splash(htmlData, markdownCodeStyle); err == nil { // success htmlData = highlightedHTML } // Add a script for rendering MathJax, but only if at least one mathematical formula is present, // and if it hasn't been added already. if containsFormula(mdContent) { js := append([]byte(` tag } htmlString := strings.TrimSpace(string(htmlData)) L.Push(lua.LString(htmlString)) return 1 // number of results })) // Sanitize HTML L.SetGlobal("sanhtml", L.NewFunction(func(L *lua.LState) int { // Retrieve the HTML content to be sanitized htmlContent := L.ToString(1) // Sanitize the HTML content policy := bluemonday.UGCPolicy() sanitizedHTML := policy.Sanitize(htmlContent) // Return the sanitized HTML L.Push(lua.LString(sanitizedHTML)) return 1 // number of results })) // Escape HTML special characters, for including text in an HTML page L.SetGlobal("htmlescape", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString(html.EscapeString(L.ToString(1)))) return 1 // number of results })) // Get the full filename of a given file that is in the directory // where the server is running (root directory for the server). // If no filename is given, the directory where the server is // currently running is returned. L.SetGlobal("serverdir", L.NewFunction(func(L *lua.LState) int { serverdir, err := os.Getwd() if err != nil { // Could not retrieve a directory serverdir = "" } else if L.GetTop() == 1 { // Also include a separator and a filename fn := L.ToString(1) serverdir = filepath.Join(serverdir, fn) } L.Push(lua.LString(serverdir)) return 1 // number of results })) // run3 returns stdout and stderr as tables of lines, and also the exit code L.SetGlobal("run3", L.NewFunction(func(L *lua.LState) int { command := L.ToString(1) workingDir, err := os.Getwd() if err != nil { workingDir = "" } return run3.ShellHelper(L, command, workingDir) })) } // LoadBasicWeb loads functions related to handling requests, outputting data to // the browser, setting headers, pretty printing and dealing with the directory // where files are being served, into the given Lua state. func (ac *Config) LoadBasicWeb(w http.ResponseWriter, req *http.Request, L *lua.LState, filename string, flushFunc func(), httpStatus *FutureStatus) { // Always have a place to track whether output has been closed for this request if httpStatus == nil { httpStatus = &FutureStatus{} } // Print the given arguments to the web page that is being served. Add a newline. L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { return 0 // number of results } var buf bytes.Buffer top := L.GetTop() for i := 1; i <= top; i++ { buf.WriteString(L.Get(i).String()) if i != top { buf.WriteString("\t") } } // Final newline buf.WriteString("\n") // Write the combined text to the http.ResponseWriter if _, err := buf.WriteTo(w); err != nil && ac.debugMode { logrus.Error("print: could not write to buffer: " + err.Error()) } return 0 // number of results })) // Print the given arguments to the web page that is being served. Do not add a newline. L.SetGlobal("print_nonl", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { return 0 // number of results } var buf bytes.Buffer top := L.GetTop() for i := 1; i <= top; i++ { buf.WriteString(L.Get(i).String()) if i != top { buf.WriteString("\t") } } // Write the combined text to the http.ResponseWriter if _, err := buf.WriteTo(w); err != nil && ac.debugMode { logrus.Error("print_nonl: could not write to buffer: " + err.Error()) } return 0 // number of results })) // Pretty print text to the web page that is being served. Add a newline. L.SetGlobal("pprint", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { return 0 // number of results } var buf bytes.Buffer top := L.GetTop() for i := 1; i <= top; i++ { convert.PprintToWriter(&buf, L.Get(i)) if i != top { buf.WriteString("\t") } } // Final newline buf.WriteString("\n") // Write the combined text to the http.ResponseWriter if _, err := buf.WriteTo(w); err != nil && ac.debugMode { logrus.Error("pprint: could not write to buffer: " + err.Error()) } return 0 // number of results })) // Pretty print to string L.SetGlobal("ppstr", L.NewFunction(func(L *lua.LState) int { var buf bytes.Buffer top := L.GetTop() for i := 1; i <= top; i++ { convert.PprintToWriter(&buf, L.Get(i)) if i != top { buf.WriteString("\t") } } // Return the string L.Push(lua.LString(buf.String())) return 1 // number of results })) // Flush the ResponseWriter. // Needed in debug mode, where ResponseWriter is buffered. L.SetGlobal("flush", L.NewFunction(func(_ *lua.LState) int { if httpStatus.closed { L.Push(lua.LBool(false)) // not a success, the connection has been closed return 1 // number of results } if flushFunc == nil { logrus.Warn("cannot flush(): flushFunc is nil") L.Push(lua.LBool(true)) // not a failure, ignore the missing flashFunc return 1 // number of results } flushFunc() L.Push(lua.LBool(true)) // success return 1 // number of results })) // Close the communication with the client by setting a "Connection: close" header, // flushing and marking the output as closed. L.SetGlobal("close", L.NewFunction(func(_ *lua.LState) int { // Close the connection. // Works for both HTTP and HTTP/2 now, ref: https://github.com/golang/go/issues/20977 w.Header().Add("Connection", "close") // Flush, if a flush function is available if flushFunc != nil { // TODO: If flush() was called, and no further writing to w was done by now, then don't flush twice flushFunc() } // Stop Lua functions from writing more to this client httpStatus.closed = true // TODO: Set up the HTTP/QUIC/HTTP/2 Server structs with a ConnContext // field and then fetch the connection from the req.Context() // and use it here for closing the connection. // Hijack the connection (does not work with fasthttp!) //if hijacker, ok := w.(http.Hijacker); ok { // success // if conn, buf, err := hijacker.Hijack(); err == nil { // success // buf.Flush() // conn.Close() // Close the hijacked connection // } //} return 0 // number of results })) // Set the Content-Type for the page L.SetGlobal("content", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { return 0 // number of results } lv := L.ToString(1) w.Header().Add(contentType, lv) return 0 // number of results })) // Return the current URL Path L.SetGlobal("urlpath", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString(req.URL.Path)) return 1 // number of results })) // Return the current HTTP method (GET, POST etc) L.SetGlobal("method", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString(req.Method)) return 1 // number of results })) // Return the remote address ("host:port") of the connected client, see issue #114 L.SetGlobal("remoteaddr", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString(req.RemoteAddr)) return 1 // number of results })) // Return the HTTP headers as a table L.SetGlobal("headers", L.NewFunction(func(L *lua.LState) int { luaTable := L.NewTable() for key := range req.Header { L.RawSet(luaTable, lua.LString(key), lua.LString(req.Header.Get(key))) } if req.Host != "" { L.RawSet(luaTable, lua.LString("Host"), lua.LString(req.Host)) } L.Push(luaTable) return 1 // number of results })) // Return the HTTP header in the request, for a given key/string L.SetGlobal("header", L.NewFunction(func(L *lua.LState) int { key := L.ToString(1) value := req.Header.Get(key) L.Push(lua.LString(value)) return 1 // number of results })) // Set the HTTP header in the request, for a given key and value L.SetGlobal("setheader", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { return 0 // number of results } key := L.ToString(1) value := L.ToString(2) w.Header().Set(key, value) return 0 // number of results })) // Return the HTTP body in the request L.SetGlobal("body", L.NewFunction(func(L *lua.LState) int { body, err := io.ReadAll(req.Body) var result lua.LString if err != nil { result = lua.LString("") } else { result = lua.LString(string(body)) } L.Push(result) return 1 // number of results })) // Set the HTTP status code (must come before print) L.SetGlobal("status", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { return 0 // number of results } code := int(L.ToNumber(1)) if httpStatus != nil { httpStatus.code = code } w.WriteHeader(code) return 0 // number of results })) // Throw an error/exception in Lua L.SetGlobal("throw", L.GetGlobal("error")) // Set a HTTP status code and print a message (optional) L.SetGlobal("error", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { return 0 // number of results } code := int(L.ToNumber(1)) if httpStatus != nil { httpStatus.code = code } w.WriteHeader(code) if L.GetTop() == 2 { message := L.ToString(2) fmt.Fprint(w, message) } return 0 // number of results })) // Get the full filename of a given file that is in the directory // of the script that is about to be run. If no filename is given, // the directory of the script is returned. L.SetGlobal("scriptdir", L.NewFunction(func(L *lua.LState) int { scriptpath, err := filepath.Abs(filename) if err != nil { scriptpath = filename } scriptdir := filepath.Dir(scriptpath) scriptpath = scriptdir top := L.GetTop() if top == 1 { // Also include a separator and a filename fn := L.ToString(1) scriptpath = filepath.Join(scriptdir, fn) } // Now have the correct absolute scriptpath L.Push(lua.LString(scriptpath)) return 1 // number of results })) // Given a glob, like "md/*.md", read the files with the scriptdir() as the starting point. // Then return all the contents of the files as a table. L.SetGlobal("readglob", L.NewFunction(func(L *lua.LState) int { pattern := L.ToString(1) var basepath string if L.GetTop() == 2 { basepath = L.ToString(2) } else { scriptpath, err := filepath.Abs(filename) if err != nil { scriptpath = filename } basepath = filepath.Dir(scriptpath) } matches, err := filepath.Glob(filepath.Join(basepath, pattern)) if err != nil { L.Push(lua.LNil) return 1 } results := L.NewTable() for _, match := range matches { content, err := os.ReadFile(match) if err != nil { L.Push(lua.LNil) return 1 } results.Append(lua.LString(content)) } L.Push(results) return 1 })) // Given a filename, return the URL path L.SetGlobal("file2url", L.NewFunction(func(L *lua.LState) int { fn := L.ToString(1) targetpath := strings.TrimPrefix(filepath.Join(filepath.Dir(filename), fn), ac.serverDirOrFilename) if utils.Pathsep != "/" { // For operating systems that use another path separator for files than for URLs targetpath = strings.ReplaceAll(targetpath, utils.Pathsep, "/") } withSlashPrefix := path.Join("/", targetpath) L.Push(lua.LString(withSlashPrefix)) return 1 // number of results })) // Retrieve a table with keys and values from the form in the request L.SetGlobal("formdata", L.NewFunction(func(L *lua.LState) int { // Place the form data in a map m := make(map[string]string) req.ParseForm() for key, values := range req.Form { m[key] = values[0] } // Convert the map to a table and return it L.Push(convert.Map2table(L, m)) return 1 // number of results })) // Read a JSON request body and return a table with the decoded fields L.SetGlobal("formjson", L.NewFunction(func(L *lua.LState) int { body, err := io.ReadAll(req.Body) if err != nil { L.Push(L.NewTable()) return 1 // number of results } m := make(map[string]any) if err := json.Unmarshal(body, &m); err != nil { L.Push(L.NewTable()) return 1 // number of results } table := L.NewTable() for key, value := range m { L.RawSet(table, lua.LString(key), lua.LString(fmt.Sprintf("%v", value))) } L.Push(table) return 1 // number of results })) // Retrieve a table with keys and values from the URL in the request L.SetGlobal("urldata", L.NewFunction(func(L *lua.LState) int { var ( valueMap url.Values err error ) if L.GetTop() == 1 { // If given an argument rawurl := L.ToString(1) valueMap, err = url.ParseQuery(rawurl) // Log error as warning if there are issues. // An empty Value map will then be used. if err != nil { logrus.Error(err) // return 0 } } else { // If not given an argument valueMap = req.URL.Query() // map[string][]string } // Place the Value data in a map, using the first values // if there are many values for a given key. m := make(map[string]string) for key, values := range valueMap { m[key] = values[0] } // Convert the map to a table and return it L.Push(convert.Map2table(L, m)) return 1 // number of results })) // Redirect a request (as found, by default) L.SetGlobal("redirect", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { L.Push(lua.LBool(false)) // can not redirect, since the connection is closed return 1 // number of results } newurl := L.ToString(1) httpStatusCode := http.StatusFound if L.GetTop() == 2 { httpStatusCode = int(L.ToNumber(2)) } if httpStatus != nil { httpStatus.code = httpStatusCode } http.Redirect(w, req, newurl, httpStatusCode) L.Push(lua.LBool(true)) // success return 1 // number of results })) // Permanently redirect a request, which is the same as redirect(url, 301) L.SetGlobal("permanent_redirect", L.NewFunction(func(L *lua.LState) int { if httpStatus.closed { L.Push(lua.LBool(false)) // not a success, the connection has been closed return 1 // number of results } newurl := L.ToString(1) httpStatusCode := http.StatusMovedPermanently if httpStatus != nil { httpStatus.code = httpStatusCode } http.Redirect(w, req, newurl, httpStatusCode) L.Push(lua.LBool(true)) // success return 1 // number of results })) // Run the given Lua file (replacement for the built-in dofile, to look in the right directory) // Returns whatever the Lua file returns when it is being run. L.SetGlobal("dofile", L.NewFunction(func(L *lua.LState) int { givenFilename := L.ToString(1) luaFilename := filepath.Join(filepath.Dir(filename), givenFilename) if !ac.fs.Exists(luaFilename) { logrus.Error("Could not find:", luaFilename) return 0 // number of results } if err := L.DoFile(luaFilename); err != nil { logrus.Errorf("Error running %s: %s\n", luaFilename, err) return 0 // number of results } // Retrieve the returned value from the script retval := L.Get(-1) L.Pop(1) // Return the value returned from the script L.Push(retval) return 1 // number of results })) // run3 returns stdout and stderr as tables of lines, and also the exit code L.SetGlobal("run3", L.NewFunction(func(L *lua.LState) int { command := L.ToString(1) workingDir := filepath.Dir(filename) return run3.ShellHelper(L, command, workingDir) })) // Truncate a string to at most n bytes L.SetGlobal("maxlen", L.NewFunction(func(L *lua.LState) int { s := L.ToString(1) n := max(int(L.ToNumber(2)), 0) if len(s) > n { s = s[:n] } L.Push(lua.LString(s)) return 1 // number of results })) // Return true if the request method is POST, set JSON content type, // and send 405 Method Not Allowed for methods other than GET and POST. L.SetGlobal("methodPOST", L.NewFunction(func(L *lua.LState) int { switch req.Method { case "POST": w.Header().Set(contentType, "application/json; charset=utf-8") L.Push(lua.LBool(true)) case "GET", "HEAD": L.Push(lua.LBool(false)) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) L.Push(lua.LBool(false)) } return 1 // number of results })) // Embed a raw JSON value within a JSON object. // jsonembed(key, rawjson) returns {"key":rawjson} L.SetGlobal("jsonembed", L.NewFunction(func(L *lua.LState) int { key := L.ToString(1) raw := L.ToString(2) keyJSON, err := json.Marshal(key) if err != nil { L.Push(lua.LString("{}")) return 1 // number of results } L.Push(lua.LString("{" + string(keyJSON) + ":" + raw + "}")) return 1 // number of results })) }