engine/handlers.go
Rama main · réplica de lectura del repositorio original.
package engine
import (
"fmt"
"html"
"html/template"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/didip/tollbooth"
"github.com/sirupsen/logrus"
"github.com/xyproto/algernon/themes"
"github.com/xyproto/algernon/utils"
"github.com/xyproto/datablock"
"github.com/xyproto/ollamaclient/v2"
"github.com/xyproto/sheepcounter"
"github.com/xyproto/simpleform"
"github.com/xyproto/unzip"
)
const (
// Gzip content over this size
gzipThreshold = 4096
// Used for deciding how long to wait before quitting when only serving a single file and starting a browser
defaultSoonDuration = time.Second * 3
contentType = "Content-Type"
htmlUTF8 = "text/html;charset=utf-8"
textUTF8 = "text/plain;charset=utf-8"
)
// ClientCanGzip checks if the client supports gzip compressed responses
func (ac *Config) ClientCanGzip(req *http.Request) bool {
// Curl does not use --compressed by default. This causes problems when
// serving gzipped contents when curl is run without --compressed!
// The wrong data, of the same size, will be downloaded. Beware!
if ac.curlSupport {
return strings.Contains(req.Header.Get("Accept-Encoding"), "gzip")
}
// Modern browsers support gzip
return true
}
// PongoHandler renders and serves a Pongo2 template
func (ac *Config) PongoHandler(w http.ResponseWriter, req *http.Request, filename, ext string) {
w.Header().Add(contentType, htmlUTF8)
pongoblock, err := ac.cache.Read(filename, ac.shouldCache(ext))
if err != nil {
if ac.debugMode {
fmt.Fprintf(w, "Unable to read %s: %s", html.EscapeString(filename), html.EscapeString(err.Error()))
} else {
logrus.Errorf("Unable to read %s: %s", filename, err)
}
return
}
// Make the functions in luaDataFilename available for the Pongo2 template
luafilename := defaultLuaDataFilename
if !ac.fs.Exists(luafilename) {
luafilename = filepath.Join(filepath.Dir(filename), defaultLuaDataFilename)
}
if ac.fs.Exists(luafilename) {
// Extract the function map from luaDataFilenname in a goroutine
errChan := make(chan error)
funcMapChan := make(chan template.FuncMap)
go ac.Lua2funcMap(w, req, filename, luafilename, ext, errChan, funcMapChan)
funcs := <-funcMapChan
err = <-errChan
if err != nil {
if ac.debugMode {
// Try reading luaDataFilename as well, if possible
luablock, luablockErr := ac.cache.Read(luafilename, ac.shouldCache(ext))
if luablockErr != nil {
// Could not find and/or read luaDataFilename
luablock = datablock.EmptyDataBlock
}
// Use the Lua filename as the title
ac.PrettyError(w, req, luafilename, luablock.Bytes(), err.Error(), "lua")
} else {
logrus.Error(err)
}
return
}
// Render the Pongo2 page, using functions from luaDataFilename, if available
ac.PongoPage(w, req, filename, pongoblock.Bytes(), funcs)
return
}
// Output a warning if something different from default has been given
// TODO: Do not only check for a suffix, check for the filename
if !strings.HasSuffix(luafilename, defaultLuaDataFilename) {
logrus.Warn("Could not read ", luafilename)
}
// Use the Pongo2 template without any Lua functions
funcs := make(template.FuncMap)
ac.PongoPage(w, req, filename, pongoblock.Bytes(), funcs)
}
// ReadAndLogErrors tries to read a file, and logs an error if it could not be read
func (ac *Config) ReadAndLogErrors(w http.ResponseWriter, filename, ext string) (*datablock.DataBlock, error) {
byteblock, err := ac.cache.Read(filename, ac.shouldCache(ext))
if err != nil {
if ac.debugMode {
fmt.Fprintf(w, "Unable to read %s: %s", html.EscapeString(filename), html.EscapeString(err.Error()))
} else {
logrus.Errorf("Unable to read %s: %s", filename, err)
}
}
return byteblock, err
}
// FilePage tries to serve a single file. The file must exist. Must be given a full filename.
func (ac *Config) FilePage(w http.ResponseWriter, req *http.Request, filename, luaDataFilename string) {
// Reject specially crafted Windows filenames that alias a different file
// than filepath.Ext sees, for example "x.lua::$DATA", "x.lua." or "x.lua ".
if base := filepath.Base(filename); strings.ContainsRune(base, ':') ||
strings.HasSuffix(base, ".") || strings.HasSuffix(base, " ") {
http.NotFound(w, req)
return
}
if ac.quitAfterFirstRequest {
go ac.quitSoon("Quit after first request", defaultSoonDuration)
}
// Use the file extension for setting the mimetype
lowercaseFilename := strings.ToLower(filename)
ext := filepath.Ext(lowercaseFilename)
// Filenames ending with .hyper.js or .hyper.jsx are special cases
if strings.HasSuffix(lowercaseFilename, ".hyper.js") {
ext = ".hyper.js"
} else if strings.HasSuffix(lowercaseFilename, ".hyper.jsx") {
ext = ".hyper.jsx"
}
// Serve the file in different ways based on the filename extension.
// Simple read+render cases are handled by the Renderer registry first.
if ac.dispatchRenderer(w, req, filename, ext) {
return
}
switch ext {
// HTML pages are handled differently, if auto-refresh has been enabled
case ".html", ".htm":
w.Header().Add(contentType, htmlUTF8)
// Read the file (possibly in compressed format, straight from the cache)
htmlblock, err := ac.ReadAndLogErrors(w, filename, ext)
if err != nil {
return
}
// If the auto-refresh feature has been enabled
if ac.autoRefresh {
// Get the bytes from the datablock
htmldata := htmlblock.Bytes()
// Insert JavaScript for refreshing the page, into the HTML
htmldata = ac.InsertAutoRefresh(req, htmldata)
// Write the data to the client
ac.DataToClient(w, req, filename, htmldata)
} else {
// Serve the file
htmlblock.ToClient(w, req, filename, ac.ClientCanGzip(req), gzipThreshold)
}
return
case ".md", ".markdown", ".gcss", ".scss", ".jsx", ".ts", ".tsx", ".happ", ".hyper", ".hyper.jsx", ".hyper.js":
// Handled by the Renderer registry; unreachable because dispatchRenderer already returned
return
case ".frm", ".form":
w.Header().Add(contentType, htmlUTF8)
formblock, err := ac.cache.Read(filename, ac.shouldCache(ext))
if err != nil {
return
}
// Render the form file as just the HTML body, not the surrounding document
// (between <body> and </body>)
html, err := simpleform.HTML(formblock.String(), false, "en")
if err != nil {
return
}
w.Write([]byte(html))
return
case ".amber", ".amb":
w.Header().Add(contentType, htmlUTF8)
amberblock, err := ac.ReadAndLogErrors(w, filename, ext)
if err != nil {
return
}
// Try reading luaDataFilename as well, if possible
luafilename := filepath.Join(filepath.Dir(filename), luaDataFilename)
luablock, err := ac.cache.Read(luafilename, ac.shouldCache(ext))
if err != nil {
// Could not find and/or read luaDataFilename
luablock = datablock.EmptyDataBlock
}
// Make functions from the given Lua data available
funcs := make(template.FuncMap)
// luablock can be empty if there was an error or if the file was empty
if luablock.HasData() {
// There was Lua code available. Now make the functions and
// variables available for the template.
funcs, err = ac.LuaFunctionMap(w, req, luablock.Bytes(), luafilename)
if err != nil {
if ac.debugMode {
// Use the Lua filename as the title
ac.PrettyError(w, req, luafilename, luablock.Bytes(), err.Error(), "lua")
} else {
logrus.Error(err)
}
return
}
if ac.debugMode && ac.verboseMode {
s := "These functions from " + luafilename
s += " are useable for " + filename + ": "
// Create a comma separated list of the available functions
for key := range funcs {
s += key + ", "
}
// Remove the final comma
s = strings.TrimSuffix(s, ", ")
// Output the message
logrus.Info(s)
}
}
// Render the Amber page, using functions from luaDataFilename, if available
ac.AmberPage(w, req, filename, amberblock.Bytes(), funcs)
return
case ".po2", ".pongo2", ".tpl", ".tmpl":
ac.PongoHandler(w, req, filename, ext)
return
case ".alg":
// Assume this to be a compressed Algernon application
webApplicationExtractionDir := "/dev/shm" // extract to memory, if possible
testfile := filepath.Join(webApplicationExtractionDir, "canary")
if f, err := os.Create(testfile); err == nil {
f.Close()
os.Remove(testfile)
} else {
// Could not create the test file
// Use the server temp dir (typically /tmp) instead of /dev/shm
webApplicationExtractionDir = ac.serverTempDir
}
if extractErr := unzip.Extract(filename, webApplicationExtractionDir); extractErr != nil {
logrus.Errorf("Failed to extract %s: %s", filename, extractErr)
return
}
firstname := path.Base(filename)
if strings.HasSuffix(filename, ".alg") {
firstname = path.Base(filename[:len(filename)-4])
}
serveDir := path.Join(webApplicationExtractionDir, firstname)
logrus.Warn(".alg web applications must be given as an argument to algernon to be served correctly")
ac.DirPage(w, req, serveDir, serveDir, ac.defaultTheme, luaDataFilename)
return
case ".lua", ".tl":
// If in debug mode, let the Lua script print to a buffer first, in
// case there are errors that should be displayed instead.
// If debug mode is enabled
if ac.debugMode {
// Use a buffered ResponseWriter for delaying the output
recorder := httptest.NewRecorder()
// Create a new struct for keeping an optional http header status
httpStatus := &FutureStatus{}
// The flush function writes the ResponseRecorder to the ResponseWriter
flushFunc := func() {
// If things went well, check if there is a status code we should write first
// (especially for the case of a redirect)
if httpStatus.code != 0 {
recorder.WriteHeader(httpStatus.code)
} else {
recorder.WriteHeader(http.StatusOK)
}
// Then write to the ResponseWriter
n, err := WriteRecorder(w, recorder) // WriteRecorder starts out by writing the status header
if n > 0 && err == nil {
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
} else if err != nil {
if !httpStatus.closed {
logrus.Warn(filename + ": " + err.Error())
// Close the connection. Works for both HTTP and HTTP/2 now, ref: https://github.com/golang/go/issues/20977
w.Header().Add("Connection", "close")
httpStatus.closed = true
}
}
}
// Run the lua script, without the possibility to flush
if err := ac.RunLua(recorder, req, filename, flushFunc, httpStatus); err != nil {
errortext := err.Error()
fileblock, err := ac.cache.Read(filename, ac.shouldCache(ext))
if err != nil {
// If the file could not be read, use the error message as the data
// Use the error as the file contents when displaying the error message
// if reading the file failed.
fileblock = datablock.NewDataBlock([]byte(err.Error()), true)
}
// If there were errors, display an error page
ac.PrettyError(w, req, filename, fileblock.Bytes(), errortext, "lua")
} else {
// If things went well, check if there is a status code we should write first
// (especially for the case of a redirect)
if httpStatus.code != 0 {
recorder.WriteHeader(httpStatus.code)
} else {
recorder.WriteHeader(http.StatusOK)
}
// Then write to the ResponseWriter
_, writeErr := WriteRecorder(w, recorder) // WriteRecorder starts out by writing the status header
// Note: no flushing here, because there was an error when running the Lua script
if writeErr != nil {
if !httpStatus.closed {
logrus.Warn(filename + ": " + writeErr.Error())
// Close the connection. Works for both HTTP and HTTP/2 now, ref: https://github.com/golang/go/issues/20977
w.Header().Add("Connection", "close")
httpStatus.closed = true
}
}
}
} else {
// The flush function just flushes the ResponseWriter
flushFunc := func() {
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
// Run the lua script, with the flush feature
if err := ac.RunLua(w, req, filename, flushFunc, nil); err != nil {
// Output the non-fatal error message to the log
if strings.HasPrefix(err.Error(), filename) {
logrus.Error("Error at " + err.Error())
} else {
logrus.Error("Error in " + filename + ": " + err.Error())
}
}
}
return
// .prompt files contains a content type and a prompt that is converted to data in a reproducible way, with a newline between them
case ".prompt":
if promptblock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil { // success
lines := strings.Split(promptblock.String(), "\n")
if (len(lines) < 4) || (strings.TrimSpace(lines[2]) != "") {
logrus.Error(filename + " must contain a content type, a model name, a blank line and then a prompt to be usable")
} else {
foundContentType := strings.TrimSpace(lines[0])
model := strings.TrimSpace(lines[1])
prompt := strings.TrimSpace(strings.Join(lines[3:], "\n"))
w.Header().Add(contentType, foundContentType)
// Per-request client avoids races on the model/timeout fields
oc := ollamaclient.New(model)
oc.HTTPTimeout = time.Duration(ac.writeTimeout) * time.Second
const errFmt = "could not convert %s to content: %s"
if err := oc.PullIfNeeded(true); err == nil { // success
if output, err := oc.GetOutput(prompt); err == nil { // success
if strings.Contains(output, "<") && strings.Contains(output, ">") {
output = betweenInclusive(output, "<", ">")
}
w.Write([]byte(output))
} else {
logrus.Errorf(errFmt, filename, err.Error())
}
} else {
logrus.Errorf(errFmt, filename, err.Error())
}
}
} else {
logrus.Error(filename + ":" + err.Error())
}
return
// --- End of special handlers that returns early ---
// Text and configuration files (most likely)
case "", ".asciidoc", ".conf", ".config", ".diz", ".example", ".gitignore", ".gitmodules", ".ini", ".log", ".lst", ".me", ".nfo", ".pem", ".readme", ".sub", ".sum", ".tml", ".toml", ".txt", ".yaml", ".yml":
// Set headers for displaying it in the browser.
w.Header().Set(contentType, textUTF8)
// Source files that may be used by web pages
case ".js":
w.Header().Add(contentType, "text/javascript;charset=utf-8")
if jsblock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil {
jsdata := jsblock.Bytes()
if needsBundling(jsdata) {
if bundled, err := ac.bundleFile(filename, jsdata, false); err == nil {
ac.DataToClient(w, req, filename, bundled)
} else {
logrus.Warnf("Could not bundle %s, serving raw: %v", filename, err)
ac.DataToClient(w, req, filename, jsdata)
}
} else {
ac.DataToClient(w, req, filename, jsdata)
}
}
return
// JSON
case ".json":
w.Header().Add(contentType, "application/json;charset=utf-8")
// Source code files for viewing
case ".S", ".ada", ".asm", ".bash", ".bat", ".c", ".c++", ".cc", ".cl", ".clj", ".cpp", ".cs", ".cxx", ".el", ".elm", ".erl", ".fish", ".go", ".h", ".h++", ".hpp", ".hs", ".java", ".kt", ".lisp", ".mk", ".ml", ".pas", ".pl", ".py", ".r", ".rb", ".rs", ".scm", ".sh":
// Set headers for displaying it in the browser.
w.Header().Set(contentType, textUTF8)
// Common binary file extensions
case ".7z", ".arj", ".bin", ".com", ".dat", ".db", ".elf", ".exe", ".gz", ".iso", ".lz", ".rar", ".tar.bz", ".tar.bz2", ".tar.gz", ".tar.xz", ".tbz", ".tbz2", ".tgz", ".txz", ".xz", ".zip":
// Set headers for downloading the file instead of displaying it in the browser.
w.Header().Set("Content-Disposition", "attachment")
default:
// If the filename starts with a ".", assume it's a plain text configuration file
if strings.HasPrefix(filepath.Base(lowercaseFilename), ".") {
w.Header().Set(contentType, textUTF8)
} else {
// Set the correct Content-Type
if ac.mimereader != nil {
ac.mimereader.SetHeader(w, ext)
} else {
logrus.Error("Uninitialized mimereader!")
}
}
}
// TODO Add support for "prettifying"/HTML-ifying some file extensions:
// movies, music, source code etc. Wrap videos in the right html tags for playback, etc.
// This should be placed in a separate Go module.
// TODO: Modify ac.fs to also cache .Size(), .Name() and .ModTime()
// Check the size of the file
f, err := os.Open(filename)
if err != nil {
logrus.Error("Could not open " + filename + "! " + err.Error())
return
}
defer f.Close()
fInfo, err := f.Stat()
if err != nil {
logrus.Error("Could not stat " + filename + "! " + err.Error())
return
}
// Check if the file is so large that it needs to be streamed directly
fileSize := uint64(fInfo.Size())
// Cache size can be set to a low number to trigger this behavior
if fileSize > ac.largeFileSize {
// logrus.Info("Streaming " + filename + " directly...")
// http.ServeContent will first seek to the end of the file, then
// serve the file. The alternative here is to use io.Copy(w, f),
// but io.Copy does not support ranges.
http.ServeContent(newDeadlineWriter(w, req), req, fInfo.Name(), fInfo.ModTime(), f)
return
}
// Read the file (possibly in compressed format, straight from the cache)
if dataBlock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil { // success
// Serve the file
dataBlock.ToClient(w, req, filename, ac.ClientCanGzip(req), gzipThreshold)
} else {
logrus.Error("Could not serve " + filename + " with datablock.ToClient: " + err.Error())
return
}
}
// ServerHeaders sets the HTTP headers that are set before anything else
func (ac *Config) ServerHeaders(w http.ResponseWriter) {
w.Header().Set("Server", ac.serverHeaderName)
if !ac.autoRefresh {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
}
if !ac.autoRefresh && ac.stricterHeaders {
w.Header().Set("Content-Security-Policy",
"connect-src 'self'; object-src 'self'; form-action 'self'")
}
// w.Header().Set("X-Powered-By", name+"/"+version)
}
// RegisterHandlers configures the given mutex and request limiter to handle
// HTTP requests
func (ac *Config) RegisterHandlers(mux *http.ServeMux, handlePath, servedir string, addDomain bool) {
theme := ac.defaultTheme
// Theme aliases. Use a map if there are more than 2 aliases in the future.
if theme == "light" {
// The "light" theme is the "gray" theme
theme = "gray"
}
// Handle all requests with this function.
// Requests are rejected by permissionMiddleware, which wraps the mux.
allRequests := func(w http.ResponseWriter, req *http.Request) {
// Local to this function
servedir := servedir
// Look for the directory that is named the same as the host
if addDomain {
domain := utils.GetDomain(req)
// Reject Host header values that could escape the document root via path traversal
if domain == "" || strings.ContainsAny(domain, "/\\") || strings.Contains(domain, "..") {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
servedir = filepath.Join(servedir, domain)
}
urlpath := req.URL.Path
//logrus.Infoln("Checking reverse proxy", urlpath, ac.reverseProxyConfig)
if ac.reverseProxyConfig != nil {
if rproxy := ac.reverseProxyConfig.FindMatchingReverseProxy(urlpath); rproxy != nil {
pr := &proxyRecorder{ResponseWriter: w}
rproxy.ServeHTTP(pr, req)
ac.LogAccess(req, pr.status, pr.written)
return
}
}
filename := utils.URL2filename(servedir, urlpath)
// Block requests for dotfiles/dot-directories if --hide-dotfiles is set
if ac.hideDotfiles {
rel := strings.TrimPrefix(urlpath, "/")
for seg := range strings.SplitSeq(rel, "/") {
if seg == "" {
continue
}
if strings.HasPrefix(seg, ".") && seg != ".well-known" {
w.WriteHeader(http.StatusNotFound)
ac.LogAccess(req, http.StatusNotFound, 0)
return
}
}
}
// Remove the trailing slash from the filename, if any
noslash := filename
if strings.HasSuffix(filename, utils.Pathsep) {
noslash = filename[:len(filename)-1]
}
if ac.strictSymlinks && !utils.ResolvesWithinDir(servedir, noslash) {
logrus.Warnf("Refusing to follow a symlink out of %s: %s", servedir, noslash)
w.WriteHeader(http.StatusNotFound)
ac.LogAccess(req, http.StatusNotFound, 0)
return
}
hasdir := ac.fs.Exists(filename) && ac.fs.IsDir(filename)
dirname := filename
hasfile := ac.fs.Exists(noslash)
// Set the server headers, if not disabled
if !ac.noHeaders {
ac.ServerHeaders(w)
}
// Share the directory or file
if hasdir {
// Prepare to count bytes written
sc := sheepcounter.New(w)
// Get the directory page
ac.DirPage(sc, req, servedir, dirname, theme, defaultLuaDataFilename)
// Log the access
ac.LogAccess(req, http.StatusOK, sc.Counter())
return
} else if !hasdir && hasfile {
// Prepare to count bytes written
sc := sheepcounter.New(w)
// Share a single file instead of a directory
ac.FilePage(sc, req, noslash, defaultLuaDataFilename)
// Log the access
ac.LogAccess(req, http.StatusOK, sc.Counter())
return
}
// Not found
w.WriteHeader(http.StatusNotFound)
data := themes.NoPage(filename, theme)
ac.LogAccess(req, http.StatusNotFound, int64(len(data)))
w.Write(data)
}
// Handle requests differently depending on rate limiting being enabled or not
if ac.disableRateLimiting {
mux.HandleFunc(handlePath, allRequests)
} else {
limiter := tollbooth.NewLimiter(float64(ac.limitRequests), nil)
limiter.SetMessage(themes.MessagePage("Rate-limit exceeded", "<div style='color:red'>You have reached the maximum request limit.</div>", theme))
limiter.SetMessageContentType(htmlUTF8)
mux.Handle(handlePath, tollbooth.LimitFuncHandler(limiter, allRequests))
}
}