瀏覽代碼

新建项目

WH01243 1 年之前
當前提交
67a381780d
共有 100 個文件被更改,包括 10966 次插入0 次删除
  1. 2 0
      .algernon
  2. 9 0
      .dockerignore
  3. 35 0
      .gitignore
  4. 1 0
      .ignore
  5. 457 0
      ChangeLog.md
  6. 11 0
      LICENSE
  7. 55 0
      Makefile
  8. 1167 0
      README.md
  9. 284 0
      TODO.md
  10. 27 0
      alg2docker/README.md
  11. 101 0
      alg2docker/alg2docker
  12. 3 0
      alg2docker/build.sh
  13. 34 0
      alg2docker/config/cert.pem
  14. 52 0
      alg2docker/config/key.pem
  15. 二進制
      alg2docker/hello.alg
  16. 42 0
      alg2docker/run.sh
  17. 143 0
      algernon.1
  18. 73 0
      api_test.go
  19. 二進制
      apps/64-bit_linux/withplugins.alg
  20. 10 0
      apps/README.md
  21. 二進制
      apps/first.alg
  22. 二進制
      apps/single.alg
  23. 14 0
      bench/stress_client.sh
  24. 8 0
      bench/stress_server.sh
  25. 59 0
      cachemode/cachemode.go
  26. 1 0
      cert.pem
  27. 7 0
      config.yaml
  28. 10 0
      config/config.go
  29. 26 0
      console/console.go
  30. 二進制
      default.pgo
  31. 11 0
      desktop/algernon.desktop
  32. 13 0
      desktop/algernon_md.desktop
  33. 二進制
      desktop/markdown.png
  34. 2 0
      desktop/mdview
  35. 13 0
      docker/README.md
  36. 3 0
      docker/build_dev.sh
  37. 3 0
      docker/build_interactive.sh
  38. 3 0
      docker/build_lua.sh
  39. 3 0
      docker/build_prod.sh
  40. 34 0
      docker/config/cert.pem
  41. 52 0
      docker/config/key.pem
  42. 56 0
      docker/dev/Dockerfile
  43. 43 0
      docker/interactive/Dockerfile
  44. 29 0
      docker/lua/Dockerfile
  45. 51 0
      docker/prod/Dockerfile
  46. 4 0
      docker/run_dev.sh
  47. 4 0
      docker/run_interactive.sh
  48. 2 0
      docker/run_lua.sh
  49. 4 0
      docker/run_prod.sh
  50. 1 0
      docker/serve/127.0.0.1
  51. 5 0
      docker/serve/localhost/index.md
  52. 98 0
      engine/access.go
  53. 480 0
      engine/basic.go
  54. 66 0
      engine/cache.go
  55. 718 0
      engine/config.go
  56. 157 0
      engine/dirhandler.go
  57. 426 0
      engine/flags.go
  58. 53 0
      engine/funcmap.go
  59. 564 0
      engine/handlers.go
  60. 628 0
      engine/help.go
  61. 17 0
      engine/hyperapp.go
  62. 268 0
      engine/jsonfile.go
  63. 397 0
      engine/lua.go
  64. 69 0
      engine/luahandler.go
  65. 10 0
      engine/mime.go
  66. 7 0
      engine/notrace.go
  67. 242 0
      engine/plugin.go
  68. 90 0
      engine/pongo_test.go
  69. 165 0
      engine/prettyerror.go
  70. 18 0
      engine/quic_disabled.go
  71. 35 0
      engine/quic_enabled.go
  72. 929 0
      engine/rendering.go
  73. 407 0
      engine/repl.go
  74. 81 0
      engine/revproxy.go
  75. 328 0
      engine/serve.go
  76. 140 0
      engine/servelua.go
  77. 429 0
      engine/serverconf.go
  78. 64 0
      engine/sse.go
  79. 141 0
      engine/static.go
  80. 13 0
      engine/testdata/data.lua
  81. 14 0
      engine/testdata/index.po2
  82. 5 0
      engine/testdata/style.gcss
  83. 84 0
      engine/trace.go
  84. 31 0
      engine/url.go
  85. 3 0
      form_example.sh
  86. 4 0
      gencert.sh
  87. 117 0
      go.mod
  88. 369 0
      go.sum
  89. 12 0
      hello.lua
  90. 二進制
      img/algernon-social.jpg
  91. 二進制
      img/algernon.gif
  92. 二進制
      img/algernon_128x128.png
  93. 二進制
      img/algernon_gopher.png
  94. 二進制
      img/algernon_large.png
  95. 二進制
      img/algernon_logo.png
  96. 二進制
      img/algernon_logo4.png
  97. 320 0
      img/algernon_logo_CC0.svg
  98. 二進制
      img/algernon_logo_dark.png
  99. 二進制
      img/algernon_logo_default_theme.png
  100. 二進制
      img/algernon_lua_error.png

+ 2 - 0
.algernon

@@ -0,0 +1,2 @@
+[main]
+title = "--=[ Algernon ]=--"

+ 9 - 0
.dockerignore

@@ -0,0 +1,9 @@
+.git
+.gitignore
+alg2docker
+apps
+editors
+img
+system
+desktop
+regtest

+ 35 - 0
.gitignore

@@ -0,0 +1,35 @@
+*.bak
+*.bak
+*.cpu
+*.log
+*.mem
+*.orig
+*.out
+*.prof
+*.rej
+*.swp
+*.tmp
+.DS_Store
+.vscode
+_vendor-*/
+alg2docker/Dockerfile
+algernon
+algernon.1.gz
+algernon.debug
+algernon.log
+apps/first
+apps/withplugins
+bolt.db
+comments.json
+dump.rdb
+examples/a
+http2.log
+include.txt
+output.b64
+plugins/cpp/main
+plugins/go/go
+regtest/issue13/large-file
+release/
+samples/getip/ip.md
+test.json
+trace.out

+ 1 - 0
.ignore

@@ -0,0 +1 @@
+vendor

+ 457 - 0
ChangeLog.md

@@ -0,0 +1,457 @@
+# Changelog
+
+Changes from 1.15.1 to 1.15.2
+=============================
+
+* Serve `.json` files a tiny bit faster.
+* Serve Algernon web applications (`.alg` files) from memory, ref #132 (thanks Dialga / @Dialga).
+* Remove a duplicate word from the `README.md` file (thanks Philipp Gillé / @philippgille).
+* Update dependencies.
+
+Changes from 1.15.0 to 1.15.1
+=============================
+
+* Switch from `blackfriday` to `gomarkdown/markdown`.
+* Add a simple example that uses the `markdown` function.
+* Update the CI configuration.
+* Update dependencies.
+* Update documentation.
+
+Changes from 1.14.0 to 1.15.0
+=============================
+
+* Compile the release binaries with Go 1.20.4.
+* Add a `close()` function, ref #124 (thanks Malcolm Ke Win / @diyism).
+* Add a shell linter to the CI configuration, ref #120 (thanks Jan Macku / @jamacku).
+* Support reverse proxies, ref #131 (thanks Mohamed Abdel Maksoud / @mohamed--abdel-maksoud).
+* Look for `handler.lua` in parent directories, ref #95, #112 and #130 (thanks Giulio Lunati / @giuliolunati).
+* Add initial support for JWT tokens.
+* Use `os` and `io`instead of the deprecated `ioutil` package.
+* Use `any` instead of `interface{}`.
+* Use the new `unix` build constraint.
+* Use `strings.ReplaceAll` and `bytes.ReplaceAll`.
+* Use `simpleredis/v2`.
+* Use `math.Round`.
+* Add an `ulimit` check to the `welcome.sh` script that also works on macOS Ventura.
+* Format/lint the code with `gofumpt`, `golint` and `staticcheck`.
+* Use the `betteralign` tool, to improve struct field alignment.
+* Make the code debug/tracing/profiling features optional at compile time, using build tags.
+* Fix a typo in one of the examples.
+* Update the CI configuration.
+* Update dependencies.
+* Update documentation.
+
+Changes from 1.13.0 to 1.14.0
+=============================
+
+* Compile the release binaries with Go 1.19.
+* Improve the documentation (thanks Matt Mc / @tooolbox ).
+* Add support for Teal together with a Teal sample (thanks Matt Mc / @tooolbox).
+* Fix an issue with how Lua tables were pretty printed in the REPL.
+* Fix an issue with conversion from Lua tables to JSON, ref #107, #108 (thanks @linkerlin).
+* Fix an issue with the generated directory listing pages, where `%2F` would appear in the URL instead of `/`, ref #117.
+* Follow the advice of these utilities: `go fmt`, `golint`, `staticcheck` and to some extent `fieldalignment`.
+* Update dependencies.
+
+Changes from 1.12.14 to 1.13.0
+==============================
+
+* Add a flag for serving domains with CertMagic and Let's Encrypt
+* Add a flag for redirecting from HTTP to HTTPS
+* Use `req.Context` since `CloseNotifier` has been deprecated
+* Switch to Go 1.18
+* Switch from the MIT license to BSD-3
+* Fix double drawn frames around syntax highlighted code in Markdown documents
+* URL encode links when listing directories
+* Use the same directory as the pongo2 template when importing macros, ref #84
+* Let plugins continue to run if an optional argument is passed in, ref #64 (otherwise close them)
+* Switch from jvatic/goja-babel to wvanw/esbuild, ref #77 (#91)
+* Improve JSX-related error messages
+* Use yuin/gopher-lua and yuin/gluamapper
+* Use a context when running Lua functions and use the background context when creating the Lua pool
+* Update the alg2docker and benchmark scripts
+* Update the `--help` output
+* Fix a typo in the "single.alg" example Algernon application
+* Update example service and Dockerfiles
+* Add a base URL flag for the directory listing (#90 ?)
+* Follow the advice of the "fieldalignment" and "staticcheck" utilies
+* Fix the `serve2` function so that the registration form example works
+* Update tests, dependencies, examples and documentation
+
+Changes from 1.12.13 to 1.12.14
+===============================
+
+* Downgrade fsnotify to v1.4.9 so that building with GOOS=freebsd works again
+
+Changes from 1.12.12 to 1.12.13
+===============================
+
+* Fix a typo in the documentation (thanks Felix Yan)
+* Add support for simple MSSQL queries, ref #57
+* Improve MSSQL support (thanks Matt Mc)
+* Improvements to table mappings in Lua, including changes to gluamapper (thanks Matt Mc)
+* Support headers in buffered responses, ref #75 (thanks Matt Mc)
+* Improvements to the file upload functionality (thanks Matt Mc)
+* Various minor fixes and improvements (thanks Matt Mc)
+* Add three new repl commands: `pwd`, `serverdir` and `serverfile`
+* Add nicer help output for built-in commands to the repl
+* Add a `ServerDir` function for the server configuration Lua script
+* Fix wasm mimetype issue, ref #82
+* Fix the Babl plugin configuration after updating the Babl dependency
+* Various improvements to the samples and to the "Welcome" page
+* Follow the advice of `go vet`, `golint` and `staticcheck`
+* Support Go 1.16 and Go 1.17 only, for now
+* Update CI configuration
+* Update dependencies
+* Update documentation
+
+Changes from 1.12.11 to 1.12.12
+===============================
+
+* Only include QUIC support on supported platforms. This should let Algernon build for the Apple M1 CPU.
+
+Changes from 1.12.10 to 1.12.11
+===============================
+
+* Remove OpenBSD support while waiting for [pkg/term](https://github.com/pkg/term) to support it.
+
+Changes from 1.12.9 to 1.12.10
+==============================
+
+* Use a specific commit of [pkg/term](https://github.com/pkg/term) so that it also compiles for FreeBSD.
+
+Changes from 1.12.8 to 1.12.9
+=============================
+
+* Improve the man page.
+* Minor improvements for the help and completion functionality in the REPL.
+* Let several `algernon --lua` instances not use the same temporary database.
+* Let `.mk`, `.ts` and `.tsx` be served as `text/plain;charset=utf-8`.
+* Initial support for rendering `.frm` and `.form` files written in SimpleForm.
+* Fix for making it possible to use `.` together with `--autorefresh`.
+* Minor fixes to the docker example files.
+* Correct a typo in a comment (thanks Felix Yan).
+* Update the Travis CI configuration (thanks Rui Chen).
+* Follow the advice of the very useful `staticcheck` utility.
+* Update documentation.
+* Update dependencies.
+
+Changes from 1.12.7 to 1.12.8
+=============================
+
+* Update documentation.
+* Improve CI config and Homebrew release process (thanks Rui Chen!).
+* Update supplied systemd configuration.
+* Remove mentions of nacl.
+* Remove the `mitchellh/colorstring` dependency.
+* Update dependencies.
+* Use `algernon_history.txt` as the REPL history filename on Windows.
+* Don't output raw color codes on Windows, use ANSI colors or disable the color.
+* Remove symlinks from the "welcome" sample.
+* Update the release script to also build with GOARM=7 for Raspberry Pi 2, 3 and 4.
+
+Changes from 1.12.6 to 1.12.7
+=============================
+
+* Issues with bolt db, simplebolt and `gccgo` are resolved. Algernon now also supports `gccgo`.
+* Now requires Go 1.11 or later.
+* Respect `TMPDIR`, for improved Termux support.
+* Fix issue #42, when `--dir` is used together with a trailing slash.
+* Don't force the use of the bolt database when in development mode.
+* Update dependencies.
+
+Changes from 1.12.5 to 1.12.6
+=============================
+
+* Now using a fork of the quic package, since there were build issues with it (could not build with `gccgo` and issue #41).
+* Updated dependencies.
+* There are still issues with compiling simplebolt with gccgo, which is why Algernon can not be compiled with gccgo in a way where simplebolt works, yet. This is related to different behavior between go and gccgo and will be worked around in simplebolt. See: golang/go#36430
+* The autorefresh feature (-a or --autorefresh) may now follow symlinks to diretories, to make the ./welcome.sh script and example more user-friendly when live editing for instance samples/greetings/index.md.
+* The file-search backend of the autofresh feature is now also concurrent.
+* Tested with the latest version of Go (1.13.5) on 64-bit Arch Linux.
+
+Changes from 1.12.4 to 1.12.5
+=============================
+
+* Tested with Go 1.13.
+* Adds support for PostgreSQL queries with the PQ function, from Lua.
+* Updated dependencies, especially with QUIC and HTTP/2 in mind.
+* Updated the JSX sample to use the latest version of React.
+* The static executable for Linux is now built with `-trimpath`.
+* New HTTP client functionality from Lua, using GET or HTTPClient.
+* `CookieSecret` and `SetCookieSecret` can now be used to get and set the secure cookie secret from Lua, or it can be set with the `--cookiesecret` flag.
+
+Changes from 1.12.3 to 1.12.4
+=============================
+
+* Fix #26, an issue with using Lua tables together with Pongo2 and the serve2 function.
+* Update dependencies.
+* Improved help function on the Lua prompt.
+* Support the `IGNOREEOF` environment variable.
+* Update documentation.
+
+Changes from 1.12.2 to 1.12.3
+=============================
+
+* Fix #25, where an attack with vegeta could make Algernon crash.
+* Update dependencies (boltdb has a new home, TLS 1.3 has further improvements).
+
+Changes from 1.12.0 to 1.12.2
+=============================
+
+* Update dependencies.
+* Better output to stdout when loading configuration files (lists the names of all loaded configuration files).
+* A timestamp is added to the command line output when starting Algernon.
+* Slightly modified console text colors.
+* Minor changes to recognized filename extensions.
+* Update documentation to mention welcome.sh (fixes issue #23).
+* Minor updates to javascript libraries used by two of the samples.
+* Improved support for streaming large files (fixes issue #13).
+* Added two new flags:
+  * `--timeout=N` for setting a timeout in seconds, when serving large files (but there is range support, so if a download times out, the client can continue where it left).
+  * `--largesize=N` for setting a threshold for when a file is too large to be read into memory (the default is 42 MiB).
+
+Changes from 1.11.0 to 1.12.0
+=============================
+
+* Favicon support when serving Markdown files.
+* More minimal `--lua` mode.
+* Using the new `strings.Builder` in Go.
+* Better Markdown keyword handling.
+* Update vendored dependencies.
+* Include transmitted bytes in the access log.
+* Detect `style.css` in addition to `style.gcss`.
+* Better Markdown checkbox support.
+* Some refactoring and linting.
+
+Changes from 1.10.1 to 1.11.0
+=============================
+
+* Using the `go mod` system that came with Go 1.11.
+* Experimental support for simple logging to a NCSA and/or a Combined access log, with two new commandline flags.
+* Minor improvements to the help text and status messages.
+* No external resources are required by Algernon, not even external fonts, ref #17.
+* Refactoring: moved the event server to the recwatch package.
+* Remove an unneeded space when setting `Content-Type`.
+* Better keyword handling in Markdown documents.
+* Set a mimetype for configuration files starting with a `.`.
+* Add a flag for clearing the default path prefixes used by the permissions subsystem.
+* Update test script.
+* Minor changes to documentation and samples.
+
+Changes from 1.10 to 1.10.1
+===========================
+
+* Workaround for a problem with the MINGW64 terminal + readline, on Windows.
+* Let Lua handlers also configure the server.
+* Release the Windows executable together with the samples.
+
+Changes from 1.9 to 1.10
+========================
+
+* Syntax highlighting by using [chroma](https://github.com/alecthomas/chroma) instead of [highlight.js](https://highlightjs.org/).
+* No external dependencies, ref issue #17.
+* Add a mode for only using the Lua REPL with `-l` or `--lua`.
+* New logo for the webpage, and new ANSI banner on the command line.
+* Minor fix for closing `</head>` tags.
+* Update vendored dependencies.
+
+Changes from 1.8 to 1.9
+=======================
+
+* Improve error messages.
+* Better support for giving a single Lua fila with handlers as an argument.
+* Update documentation and samples.
+* Add list:json() to make it easier to return JSON from a List. See `samples/react_db`.
+* Better handling of opening documents in the browser if no certs are given.
+* Update the default handling of files (view/download) based on mime type or extension.
+* Add support for "go tool trace".
+* Update vendored dependencies.
+
+Changes from 1.7 to 1.8
+=======================
+
+* Fix an issue with `curl` + `algernon` that was not present with `wget` + `algernon`, related to HTTP headers and compression.
+* Some refactoring and linting of the code.
+* Less strict HTTP headers by default.
+* Update vendored dependencies.
+
+Changes from 1.6 to 1.7
+=======================
+
+* Experimental support for the QUIC protocol (HTTP over UDP, faster than HTTP/2).
+* Improvements toward compiling Algernon with GCC (`gcc-go`).
+* Update HyperApp support and samples to work with the latest version (0.15.1).
+* Update dockerfiles and scripts.
+* Add "material" and "neon" themes.
+* Updated the documentation.
+* Add support for `.algernon` files for configuring directory listings (set a theme and title).
+* Support for having a port number as the only argument.
+* Add a `--nodb` flag, for not using any database backend (same as `--boltdb=/dev/null`).
+* Some refactoring.
+* Update vendored dependencies.
+
+Changes from 1.5.1 to 1.6
+=========================
+
+* Fix for excessive memory usage when serving and caching large files. Needs more testing.
+* Should now be possible to compile with gccgo.
+* Revert the refactoring to a separate "kinnian" package, for easier development and dependency handling.
+* Update vendored dependencies.
+
+Changes from 1.5 to 1.5.1
+=========================
+
+* Add the `.hyper.js` and `.hyper.jsx` extensions for HyperApp applications
+* Style HyperApp applications if no style/theme/`style.gcss` is provided
+* Also support HyperApp applications when using the `--theme=...` flag
+* Add the `hprint` Lua function, for combining HyperApp and Lua
+
+Changes from 1.4.5 to 1.5
+=========================
+
+* Switch JSX rendering engine to one that uses [goja](https://github.com/dop251/goja)
+* Add support for HyperApp JSX apps with the `.happ` or `.hyper` extension
+
+Changes from 1.4.4 to 1.4.5
+===========================
+
+* Performance improvements when rendering Markdown and directory listings
+* Refactoring out code to the `kinnian` package
+* Update samples.
+* Update vendored dependencies.
+
+Changes from 1.4.3 to 1.4.4
+===========================
+
+* Refactor code into packages.
+* Update tests and documentation.
+
+Changes from 1.4.2 to 1.4.3
+===========================
+
+* Update dependencies and the dependency configuration.
+
+Changes from 1.4.1 to 1.4.2
+===========================
+
+* Minor improvements to the code.
+* Minor improvements to the documentation.
+* Update dependencies.
+
+Changes from 1.4 to 1.4.1
+=========================
+
+* Update the Markdown styling: tables, colors and &lt;code&gt; tags
+* Split out file caching to a separate package: [datablock](https://github.com/xyproto/datablock)
+* Add an [example](https://github.com/xyproto/algernon/tree/main/samples/structure) for structuring a web site.
+* Add a Lua `preload()` function, for caching files before they are needed.
+* Let the Lua `render()` and ` serve()` functions take an optional filename.
+* Fallback for the log filename.
+* Add `-V` flag for "verbose".
+* Add `--ctrld` flag for having to press `ctrl-d` twice to exit the REPL.
+* Use BoltDB by default instead of Redis.
+* Add script for testing functionality (HTTP server + curl) that is ran by the CI system.
+* Fix issue when running some `.alg` files.
+* Refactor.
+
+Changes from 1.3.2 to 1.4
+=========================
+
+* Improve autocomplete in the REPL.
+* Only add syntax highlighting to rendered HTML when needed.
+* Some refactoring: made the code simpler.
+* Move error checks before defer statements whenever possible.
+* Set headers so that browsers will download the most common binary formats instead of displaying them.
+* Update vendored dependencies.
+
+Changes from 1.3.1 to 1.3.2
+===========================
+
+* Remove the dependency on readline. No external C dependencies left.
+* The beginnings of better completion in the REPL.
+* Update dependencies using Glide.
+
+Changes from 1.3 to 1.3.1
+=========================
+
+* Less strict headers when using the auto-refresh feature.
+
+Changes from 1.2.1 to 1.3
+=========================
+
+* Support for streaming large files (HTTP range).
+* Minor improvements to the samples.
+
+Changes from 1.2 to 1.2.1
+=========================
+
+* Improve the REPL and the pprint function.
+* Fix a race issue when setting up a handle function from Lua.
+* Add a "Host" header to the header table.
+
+Changes from 1.1 to 1.2
+=======================
+
+* Add support for SCSS (Sass).
+* Fix the Pongo2 + Lua data race issue with a Mutex.
+* Vendor all dependencies and add a Glide YAML file.
+* Render `* [ ]`, `* [x]` and `* [X]` in Markdown as checkboxes.
+* Add support for different images per Markdown theme, using the `replace_with_theme` keyword.
+* Add support for custom CSS from Markdown, using the `css` keyword.
+* Add another built-in Markdown theme: redbox.
+* Remove unused variables.
+
+Changes from 1.0 to 1.1
+=======================
+
+General
+-------
+
+* Tested with Go 1.7
+* Added PostgreSQL >= 9.1 support (with the HSTORE feature).
+* Added two built-in themes for error pages, directory listings and Markdown.
+* Added a `--theme` flag for selecting a theme.
+* Added a `--nocache` flag for disabling caching.
+* Added default HTTP headers, for security.
+* Algernon servers now get A+ at https://securityheaders.io/.
+* Added a `--noheader` flag for disabling security-related HTTP headers.
+* Switched back to the official pongo2 repo after a pull request was merged.
+
+Lua
+---
+
+* Added a `pprint` function for slightly prettier printing.
+* Added a `ppstr` function for slightly prettier printing, but to a string.
+* Let `redirect` take an optional HTTP status code.
+* Added a `permanent_redirect` function which only takes an URL.
+* Let `dofile` search the directory of the Lua file that is running.
+* Fixed an issue with returning HTTP status codes from Lua in Debug mode.
+* Renamed `toJSON` to just `JSON`. Both are still present and still work.
+
+Markdown
+--------
+
+* Can now select a built-in theme with `theme:`.
+* Can now select the highlight.js theme with `code_style:`.
+
+REPL
+----
+
+* More graceful shutdown upon SIGHUP on Linux.
+
+Deployment
+----------
+
+* Minor improvements to the `alg2docker` script.
+
+Samples and documentation
+-------------------------
+
+* Fixed several minor typos.
+* Added an URL location check in the "bob" sample.
+
+1.0
+===
+
+* Release.

+ 11 - 0
LICENSE

@@ -0,0 +1,11 @@
+Copyright 2023 Alexander F. Rødseth
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 55 - 0
Makefile

@@ -0,0 +1,55 @@
+.PHONY: clean install install-doc
+
+PROJECT ?= orbiton
+
+GOBUILD := $(shell test $$(go version | tr ' ' '\n' | head -3 | tail -1 | tr '.' '\n' | head -2 | tail -1) -le 12 2>/dev/null && echo GO111MODULES=on go build -v || echo GOEXPERIMENT=loopvar go build -mod=vendor -v)
+
+# macOS and FreeBSD detection
+UNAME_S := $(shell uname -s)
+ifeq ($(UNAME_S),Darwin)
+  PREFIX ?= /usr/local
+  MAKE ?= make
+else ifeq ($(UNAME_S),FreeBSD)
+  PREFIX ?= /usr/local
+  MAKE ?= gmake
+else
+  PREFIX ?= /usr
+  MAKE ?= make
+endif
+
+MANDIR ?= $(PREFIX)/share/man/man1
+
+UNAME_R ?= $(shell uname -r)
+ifneq (,$(findstring arch,$(UNAME_R)))
+# Arch Linux
+LDFLAGS ?= -Wl,-O2,--sort-common,--as-needed,-z,relro,-z,now
+BUILDFLAGS ?= -mod=vendor -buildmode=pie -trimpath -ldflags "-s -w -linkmode=external -extldflags $(LDFLAGS)"
+else
+# Default settings
+BUILDFLAGS ?= -mod=vendor -trimpath
+endif
+
+algernon:
+	$(GOBUILD) $(BUILDFLAGS)
+
+algernon.1.gz: algernon.1
+	gzip -f -k -v algernon.1
+
+install: algernon desktop/mdview
+	mkdir -p "$(DESTDIR)$(PREFIX)/bin"
+	install -m755 algernon "$(DESTDIR)$(PREFIX)/bin/algernon"
+	install -m755 desktop/mdview "$(DESTDIR)$(PREFIX)/bin/mdview"
+
+install-doc: algernon.1.gz welcome.sh samples README.md
+	mkdir -p "$(DESTDIR)$(MANDIR)"
+	install -m644 algernon.1.gz "$(DESTDIR)$(MANDIR)/algernon.1.gz"
+	mkdir -p "$(DESTDIR)$(PREFIX)/usr/share/algernon"
+	cp -r samples "$(DESTDIR)$(PREFIX)/usr/share/algernon"
+	sed 's/\.\/algernon/algernon/g' welcome.sh > welcome_install.sh
+	install -m755 welcome_install.sh "$(DESTDIR)$(PREFIX)/usr/share/algernon/welcome.sh"
+	rm -f welcome_install.sh
+	mkdir -p "$(DESTDIR)$(PREFIX)/usr/share/doc/algernon"
+	install -Dm644 README.md "$(DESTDIR)$(PREFIX)/usr/share/doc/algernon/README.md"
+
+clean:
+	rm -f algernon algernon.1.gz

+ 1167 - 0
README.md

@@ -0,0 +1,1167 @@
+<!--
+title: Algernon
+description: Web server with built-in support for Lua, Teal, Markdown, Pongo2, Amber, Sass, SCSS, GCSS, JSX, Bolt, PostgreSQL, Redis, MariaDB, MySQL, Tollbooth, Pie, Graceful, Permissions2, users and permissions
+keywords: web server, QUIC, lua, teal, markdown, pongo2, application server, http, http2, HTTP/2, go, golang, algernon, JSX, React, BoltDB, Bolt, PostgreSQL, Redis, MariaDB, MySQL, Three.js
+theme: material
+-->
+
+<!--<a href="https://github.com/xyproto/algernon"><img src="https://algernon.roboticoverlords.org/img/algernon_logo.png" style="margin-left: 2em"></a>-->
+![Algernon](img/algernon_logo.png)
+
+![Build](https://github.com/xyproto/algernon/workflows/Build/badge.svg) [![GoDoc](https://godoc.org/github.com/xyproto/algernon?status.svg)](https://godoc.org/github.com/xyproto/algernon) [![License](https://img.shields.io/badge/license-BSD-green.svg?style=flat)](https://raw.githubusercontent.com/xyproto/algernon/main/LICENSE) [![Go Report Card](https://goreportcard.com/badge/github.com/xyproto/algernon)](https://goreportcard.com/report/github.com/xyproto/algernon) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fxyproto%2Falgernon.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fxyproto%2Falgernon?ref=badge_shield) [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua)
+
+Web server with built-in support for QUIC, HTTP/2, Lua, Teal, Markdown, Pongo2, HyperApp, Amber, Sass(SCSS), GCSS, JSX, BoltDB (built-in, stores the database in a file, like SQLite), Redis, PostgreSQL, MariaDB/MySQL, rate limiting, graceful shutdown, plugins, users and permissions.
+
+All in one small self-contained executable.
+
+Distro Packages
+---------------
+
+[![Packaging status](https://repology.org/badge/vertical-allrepos/algernon.svg)](https://repology.org/project/algernon/versions)
+
+Quick installation (development version)
+----------------------------------------
+
+Requires Go 1.20 or later.
+
+    go install github.com/xyproto/algernon@latest
+
+Or manually:
+
+    git clone https://github.com/xyproto/algernon
+    cd algernon
+    go build -mod=vendor
+    ./welcome.sh
+
+Releases and pre-built images
+-----------------------------
+
+See the [release](https://github.com/xyproto/algernon/releases/latest) page for releases for a variety of platforms and architectures.
+
+The [docker image](https://hub.docker.com/r/xyproto/algernon/tags) is a total of 9MB.
+
+Technologies
+------------
+
+Written in [Go](https://golang.org). Uses [Bolt](https://github.com/coreos/bbolt) (built-in), [MySQL](https://github.com/go-sql-driver/mysql), [PostgreSQL](https://www.postgresql.org/) or [Redis](https://redis.io) (recommended) for the database backend, [permissions2](https://github.com/xyproto/permissions2) for handling users and permissions, [gopher-lua](https://github.com/yuin/gopher-lua) for interpreting and running Lua, optional [Teal](https://github.com/teal-language/tl) for type-safe Lua scripting, [http2](https://github.com/bradfitz/http2) for serving HTTP/2, [QUIC](https://github.com/xyproto/quic) for serving over QUIC, [gomarkdown/markdown](https://github.com/gomarkdown/markdown) for Markdown rendering, [amber](https://github.com/eknkc/amber) for Amber templates, [Pongo2](https://github.com/flosch/pongo2) for Pongo2 templates, [Sass](https://github.com/wellington/sass)(SCSS) and [GCSS](https://github.com/yosssi/gcss) for CSS preprocessing. [logrus](https://github.com/Sirupsen/logrus) is used for logging, [goja-babel](github.com/jvatic/goja-babel) for converting from JSX to JavaScript, [tollbooth](https://github.com/didip/tollbooth) for rate limiting, [pie](https://github.com/natefinch/pie) for plugins and [graceful](https://github.com/tylerb/graceful) for graceful shutdowns.
+
+Design decisions
+----------------
+
+* HTTP/2 over SSL/TLS (https) is used by default, if a certificate and key is given.
+  * If not, regular HTTP is used.
+* QUIC ("HTTP over UDP", HTTP/3) can be enabled with a flag.
+* /data and /repos have user permissions, /admin has admin permissions and / is public, by default. This is configurable.
+* The following filenames are special, in prioritized order:
+    * index.lua is Lua code that is interpreted as a handler function for the current directory.
+    * index.html is HTML that is outputted with the correct Content-Type.
+    * index.md is Markdown code that is rendered as HTML.
+    * index.txt is plain text that is outputted with the correct Content-Type.
+    * index.pongo2, index.po2 or index.tmpl is Pongo2 code that is rendered as HTML.
+    * index.amber is Amber code that is rendered as HTML.
+    * index.hyper.js or index.hyper.jsx is JSX+HyperApp code that is rendered as HTML
+    * index.tl is Teal code that is interpreted as a handler function for the current directory.
+    * data.lua is Lua code, where the functions and variables are made available for Pongo2, Amber and Markdown pages in the same directory.
+    * If a single Lua script is given as a command line argument, it will be used as a standalone server. It can be used for setting up handlers or serving files and directories for specific URL prefixes.
+    * style.gcss is GCSS code that is used as the style for all Pongo2, Amber and Markdown pages in the same directory.
+* The following filename extensions are handled by Algernon:
+    * Markdown: .md (rendered as HTML)
+    * Pongo2: .po2, .pongo2 or .tpl (rendered as any text, typically HTML)
+    * Amber: .amber (rendered as HTML)
+    * Sass: .scss (rendered as CSS)
+    * GCSS: .gcss (rendered as CSS)
+    * JSX: .jsx (rendered as JavaScript/ECMAScript)
+    * Lua: .lua (a script that provides its own output and content type)
+    * Teal: .tl (same as .lua but with type safety)
+    * HyperApp: .hyper.js or .hyper.jsx (rendered as HTML)
+* Other files are given a mimetype based on the extension.
+* Directories without an index file are shown as a directory listing, where the design is hard coded.
+* UTF-8 is used whenever possible.
+* The server can be configured by command line flags or with a lua script, but no configuration should be needed for getting started.
+
+Features and limitations
+------------------------
+
+* Supports HTTP/2, with or without HTTPS (browsers may require HTTPS when using HTTP/2).
+* Also supports QUIC and regular HTTP.
+* Can use Lua scripts as handlers for HTTP requests.
+* The Algernon executable is compiled to native and is reasonably fast.
+* Works on Linux, macOS and 64-bit Windows.
+* The [Lua interpreter](https://github.com/yuin/gopher-lua) is compiled into the executable.
+* The [Teal typechecker](https://github.com/teal-language/tl) is loaded into the Lua VM.
+* Live editing/preview when using the auto-refresh feature.
+* The use of Lua allows for short development cycles, where code is interpreted when the page is refreshed (or when the Lua file is modified, if using auto-refresh).
+* Self-contained Algernon applications can be zipped into an archive (ending with `.zip` or `.alg`) and be loaded at start.
+* Built-in support for [Markdown](https://github.com/gomarkdown/markdown), [Pongo2](https://github.com/flosch/pongo2), [Amber](https://github.com/eknkc/amber), [Sass](https://github.com/wellington/sass)(SCSS), [GCSS](https://github.com/yosssi/gcss) and [JSX](https://github.com/mamaar/risotto).
+* Redis is used for the database backend, by default.
+* Algernon will fall back to the built-in Bolt database if no Redis server is available.
+* The HTML title for a rendered Markdown page can be provided by the first line specifying the title, like this: `title: Title goes here`. This is a subset of MultiMarkdown.
+* No file converters needs to run in the background (like for SASS). Files are converted on the fly.
+* If `-autorefresh` is enabled, the browser will automatically refresh pages when the source files are changed. Works for Markdown, Lua error pages and Amber (including Sass, GCSS and *data.lua*). This only works on Linux and macOS, for now. If listening for changes on too many files, the OS limit for the number of open files may be reached.
+* Includes an interactive REPL.
+* If only given a Markdown filename as the first argument, it will be served on port 3000, without using any database, as regular HTTP. Handy for viewing `README.md` files locally.
+* Full multi-threading. All available CPUs will be used.
+* Supports rate limiting, by using [tollbooth](https://github.com/didip/tollbooth).
+* The `help` command is available at the Lua REPL, for a quick overview of the available Lua functions.
+* Can load plugins written in any language. Plugins must offer the `Lua.Code` and `Lua.Help` functions and talk JSON-RPC over stderr+stdin. See [pie](https://github.com/natefinch/pie) for more information. Sample plugins for Go and Python are in the `plugins` directory.
+* Thread-safe file caching is built-in, with several available cache modes (for only caching images, for example).
+* Can read from and save to JSON documents. Supports simple JSON path expressions (like a simple version of XPath, but for JSON).
+* If cache compression is enabled, files that are stored in the cache can be sent directly from the cache to the client, without decompressing.
+* Files that are sent to the client are compressed with [gzip](https://golang.org/pkg/compress/gzip/#BestSpeed), unless they are under 4096 bytes.
+* When using PostgreSQL, the HSTORE key/value type is used (available in PostgreSQL version 9.1 or later).
+* No external dependencies, only pure Go.
+* Requires Go >= 1.21 or a version of GCC/`gccgo` that supports Go 1.21.
+* The Lua implementation used in Algernon (gopherlua) does not support `package.loadlib`.
+
+Q&A
+---
+
+Q:
+
+> What is the benefit of using this? In what scenario would this excel? Thanks. -- [mtw@HN](https://news.ycombinator.com/item?id=19583144).
+
+A:
+
+> Good question. I'm not sure if it excels in any scenario. There are specialized web servers that excel at caching or at raw performance. There are dedicated backends for popular front-end toolkits like Vue or React. There are dedicated editors that excel at editing and previewing Markdown, or HTML.
+>
+> I guess the main benefit is that Algernon covers a lot of ground, with a minimum of configuration, while being powerful enough to have a plugin system and support for programming in Lua. There is an auto-refresh feature that uses Server Sent Events, when editing Markdown or web pages. There is also support for the latest in Web technologies, like HTTP/2, QUIC and TLS 1.3. The caching system is decent. And the use of Go ensures that also smaller platforms like NetBSD and systems like Raspberry Pi are covered. There are no external dependencies, so Algernon can run on any system that Go can support.
+>
+> The main benefit is that is is versatile, fresh, and covers many platforms and use cases.
+>
+> For a more specific description of a potential benefit, a more specific use case would be needed.
+
+Installation
+------------------
+
+##### macOS
+
+* Install [Homebrew](https://brew.sh), if needed.
+* `brew install algernon`
+
+##### Arch Linux
+
+* `pacman -S algernon`
+
+##### Any system where Go is available
+
+This method is using the latest commit from the main branch:
+
+`go get -u github.com/xyproto/algernon@main`
+
+If needed, first:
+
+* Set the GOPATH. For example: `export GOPATH=~/go`
+* Add $GOPATH/bin to the path. For example: `export PATH=$PATH:$GOPATH/bin`
+
+Utilities
+---------
+* Comes with the `alg2docker` utility, for creating Docker images from Algernon web applications (`.alg` files).
+* [http2check](https://github.com/xyproto/http2check) can be used for checking if a web server is offering [HTTP/2](https://tools.ietf.org/html/rfc7540).
+
+Overview
+--------
+
+Running Algernon:
+
+<img src="https://raw.github.com/xyproto/algernon/main/img/algernon_gopher.png">
+
+Screenshot of an earlier version:
+
+<img src="https://raw.github.com/xyproto/algernon/main/img/algernon_redis_054.png">
+
+---
+
+The idea is that web pages can be written in Markdown, Pongo2, Amber, HTML or JSX (+React or HyperApp), depending on the need, and styled with CSS, Sass(SCSS) or GCSS, while data can be provided by a Lua or Teal script that talks to Redis, BoltDB, PostgreSQL, MSQL or MariaDB/MySQL.
+
+Amber and GCSS is a good combination for static pages, that allows for more clarity and less repetition than HTML and CSS. It˙s also easy to use Lua for providing data for the Amber templates, which helps separate model, controller and view.
+
+Pongo2, Sass and Lua or Teal also combines well. Pongo2 is more flexible than Amber.
+
+The auto-refresh feature is supported when using Markdown, Pongo2 or Amber, and is useful to get an instant preview when developing.
+
+The JSX to JavaScript (ECMAscript) transpiler is built-in.
+
+Redis is fast, scalable and offers good [data persistence](https://redis.io/topics/persistence). This should be the preferred backend.
+
+Bolt is a [pure key/value store](https://github.com/coreos/bbolt), written in Go. It makes it easy to run Algernon without having to set up a database host first.
+MariaDB/MySQL support is included because of its widespread availability.
+
+PostgreSQL is a solid and fast database that is also supported.
+
+Screenshots
+-----------
+
+<img src="https://raw.github.com/xyproto/algernon/main/img/algernon_markdown.png">
+
+*Markdown can easily be styled with Sass or GCSS.*
+
+---
+
+<img src="https://raw.github.com/xyproto/algernon/main/img/algernon_lua_error.png">
+
+*This is how errors in Lua scripts are handled, when Debug mode is enabled.*
+
+---
+
+<img src="https://raw.github.com/xyproto/algernon/main/img/algernon_threejs.png">
+
+*One of the poems of Algernon Charles Swinburne, with three rotating tori in the background.*
+*Uses CSS3 for the Gaussian blur and [three.js](https://threejs.org) for the 3D graphics.*
+
+---
+
+<img src="https://raw.github.com/xyproto/algernon/main/img/prettify.png">
+
+*Screenshot of the <strong>prettify</strong> sample. Served from a single Lua script.*
+
+---
+
+<img src="https://raw.github.com/xyproto/algernon/main/img/algernon_react.png">
+
+*JSX transforms are built-in. Using [React](https://facebook.github.io/react/) together with Algernon is easy.*
+
+Samples
+-------
+
+The sample collection can be downloaded from the `samples` directory in this repository, or here: [samplepack.zip](https://algernon.roboticoverlords.org/samplepack.zip).
+
+
+Getting started
+---------------
+
+##### Run Algernon in "dev" mode
+
+This enables debug mode, uses the internal Bolt database, uses regular HTTP instead of HTTPS+HTTP/2 and enables caching for all files except: Pongo2, Amber, Lua, Teal, Sass, GCSS, Markdown and JSX.
+
+* `algernon -e`
+
+Then try creating an `index.lua` file with `print("Hello, World!")` and visit the served web page in a browser.
+
+##### Enable HTTP/2 in the browser (for older browsers)
+
+* Chrome: go to `chrome://flags/#enable-spdy4`, enable, save and restart the browser.
+* Firefox: go to `about:config`, set `network.http.spdy.enabled.http2draft` to `true`. You might need the nightly version of Firefox.
+
+##### Configure the required ports for local use
+
+* You may need to change the firewall settings for port 3000, if you wish to use the default port for exploring the samples.
+* For the auto-refresh feature to work, port 5553 must be available (or another host/port of your choosing, if configured otherwise).
+
+##### Prepare for running the samples
+
+    git clone https://github.com/xyproto/algernon
+    make -C algernon
+
+##### Launch the "welcome" page
+
+* Run `./welcome.sh` to start serving the "welcome" sample.
+* Visit `http://localhost:3000/`
+
+##### Create your own Algernon application, for regular HTTP
+
+* `mkdir mypage`
+* `cd mypage`
+* Create a file named `index.lua`, with the following contents:
+  `print("Hello, Algernon")`
+* Start `algernon --httponly --autorefresh`.
+* Visit `http://localhost:3000/`.
+* Edit `index.lua` and refresh the browser to see the new result.
+* If there were errors, the page will automatically refresh when `index.lua` is changed.
+* Markdown, Pongo2 and Amber pages will also refresh automatically, as long as `-autorefresh` is used.
+
+##### Create your own Algernon application, for HTTP/2 + HTTPS
+
+* `mkdir mypage`
+* `cd mypage`
+* Create a file named `index.lua`, with the following contents:
+  `print("Hello, Algernon")`
+* Create a self-signed certificate, just for testing:
+ * `openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 3000 -nodes`
+ * Press return at all the prompts, but enter `localhost` at *Common Name*.
+ * For production, store the keys in a directory with as strict permissions as possible, then specify them with the `--cert` and `--key` flags.
+* Start `algernon`.
+* Visit `https://localhost:3000/`.
+* If you have not imported the certificates into the browser, nor used certificates that are signed by trusted certificate authorities, perform the necessary clicks to confirm that you wish to visit this page.
+* Edit `index.lua` and refresh the browser to see the result (or a Lua error message, if the script had a problem).
+
+
+Basic Lua functions
+-------------------
+
+~~~c
+// Return the version string for the server.
+version() -> string
+
+// Sleep the given number of seconds (can be a float).
+sleep(number)
+
+// Log the given strings as information. Takes a variable number of strings.
+log(...)
+
+// Log the given strings as a warning. Takes a variable number of strings.
+warn(...)
+
+// Log the given strings as an error. Takes a variable number of strings.
+err(...)
+
+// Return the number of nanoseconds from 1970 ("Unix time")
+unixnano() -> number
+
+// Convert Markdown to HTML
+markdown(string) -> string
+
+// Return the directory where the REPL or script is running. If a filename (optional) is given, then the path to where the script is running, joined with a path separator and the given filename, is returned.
+scriptdir([string]) -> string
+
+// Return the directory where the server is running. If a filename (optional) is given, then the path to where the server is running, joined with a path separator and the given filename, is returned.
+serverdir([string]) -> string
+~~~
+
+
+Lua functions for handling requests
+-----------------------------------
+
+~~~c
+// Set the Content-Type for a page.
+content(string)
+
+// Return the requested HTTP method (GET, POST etc).
+method() -> string
+
+// Output text to the browser/client. Takes a variable number of strings.
+print(...)
+
+// Return the requested URL path.
+urlpath() -> string
+
+// Return the HTTP header in the request, for a given key, or an empty string.
+header(string) -> string
+
+// Set an HTTP header given a key and a value.
+setheader(string, string)
+
+// Return the HTTP headers, as a table.
+headers() -> table
+
+// Return the HTTP body in the request (will only read the body once, since it's streamed).
+body() -> string
+
+// Set a HTTP status code (like 200 or 404). Must be used before other functions that writes to the client!
+status(number)
+
+// Set a HTTP status code and output a message (optional).
+error(number[, string])
+
+// Serve a file that exists in the same directory as the script. Takes a filename.
+serve(string)
+
+// Serve a Pongo2 template file, with an optional table with template key/values.
+serve2(string[, table)
+
+// Return the rendered contents of a file that exists in the same directory as the script. Takes a filename.
+render(string) -> string
+
+// Return a table with keys and values as given in a posted form, or as given in the URL.
+formdata() -> table
+
+// Return a table with keys and values as given in the request URL, or in the given URL (`/some/page?x=7` makes the key `x` with the value `7` available).
+urldata([string]) -> table
+
+// Redirect to an absolute or relative URL. May take an HTTP status code that will be used when redirecting.
+redirect(string[, number])
+
+// Permanent redirect to an absolute or relative URL. Uses status code 302.
+permanent_redirect(string)
+
+// Send "Connection: close" as a header to the client, flush the body and also
+// stop Lua functions from writing more data to the HTTP body.
+close()
+
+// Transmit what has been outputted so far, to the client.
+flush()
+~~~
+
+
+Lua functions for formatted output
+----------------------------------
+
+~~~c
+// Output rendered Markdown to the browser/client. The given text is converted from Markdown to HTML. Takes a variable number of strings.
+mprint(...)
+
+// Output rendered Amber to the browser/client. The given text is converted from Amber to HTML. Takes a variable number of strings.
+aprint(...)
+
+// Output rendered GCSS to the browser/client. The given text is converted from GCSS to CSS. Takes a variable number of strings.
+gprint(...)
+
+// Output rendered HyperApp JSX to the browser/client. The given text is converted from JSX to JavaScript. Takes a variable number of strings.
+hprint(...)
+
+// Output rendered React JSX to the browser/client. The given text is converted from JSX to JavaScript. Takes a variable number of strings.
+jprint(...)
+
+// Output rendered HTML to the browser/client. The given text is converted from Pongo2 to HTML. The first argument is the Pongo2 template and the second argument is a table. The keys in the table can be referred to in the template.
+poprint(string[, table])
+
+// Output a simple HTML page with a message, title and theme.
+// The title and theme are optional.
+msgpage(string[, string][, string])
+~~~
+
+
+Lua functions related to JSON
+-----------------------------
+
+Tips:
+
+* Use `JFile(`*filename*`)` to use or store a JSON document in the same directory as the Lua script.
+* A JSON path is on the form `x.mapkey.listname[2].mapkey`, where `[`, `]` and `.` have special meaning. It can be used for pinpointing a specific place within a JSON document. It's a bit like a simple version of XPath, but for JSON.
+* Use `tostring(userdata)` to fetch the JSON string from the JFile object.
+
+~~~c
+// Use, or create, a JSON document/file.
+JFile(filename) -> userdata
+
+// Takes a JSON path. Returns a string value, or an empty string.
+jfile:getstring(string) -> string
+
+// Takes a JSON path. Returns a JNode or nil.
+jfile:getnode(string) -> userdata
+
+// Takes a JSON path. Returns a value or nil.
+jfile:get(string) -> value
+
+// Takes a JSON path (optional) and JSON data to be added to the list.
+// The JSON path must point to a list, if given, unless the JSON file is empty.
+// "x" is the default JSON path. Returns true on success.
+jfile:add([string, ]string) -> bool
+
+// Take a JSON path and a string value. Changes the entry. Returns true on success.
+jfile:set(string, string) -> bool
+
+// Remove a key in a map. Takes a JSON path, returns true on success.
+jfile:delkey(string) -> bool
+
+// Convert a Lua table, where keys are strings and values are strings or numbers, to JSON.
+// Takes an optional number of spaces to indent the JSON data.
+// (Note that keys in JSON maps are always strings, ref. the JSON standard).
+json(table[, number]) -> string
+
+// Create a JSON document node.
+JNode() -> userdata
+
+// Add JSON data to a node. The first argument is an optional JSON path.
+// The second argument is a JSON data string. Returns true on success.
+// "x" is the default JSON path.
+jnode:add([string, ]string) ->
+
+// Given a JSON path, retrieves a JSON node.
+jnode:get(string) -> userdata
+
+// Given a JSON path, retrieves a JSON string.
+jnode:getstring(string) -> string
+
+// Given a JSON path and a JSON string, set the value.
+jnode:set(string, string)
+
+// Given a JSON path, remove a key from a map.
+jnode:delkey(string) -> bool
+
+// Return the JSON data, nicely formatted.
+jnode:pretty() -> string
+
+// Return the JSON data, as a compact string.
+jnode:compact() -> string
+
+// Sends JSON data to the given URL. Returns the HTTP status code as a string.
+// The content type is set to "application/json; charset=utf-8".
+// The second argument is an optional authentication token that is used for the
+// Authorization header field.
+jnode:POST(string[, string]) -> string
+
+// Alias for jnode:POST
+jnode:send(string[, string]) -> string
+
+// Same as jnode:POST, but sends HTTP PUT instead.
+jnode:PUT(string[, string]) -> string
+
+// Fetches JSON over HTTP given an URL that starts with http or https.
+// The JSON data is placed in the JNode. Returns the HTTP status code as a string.
+jnode:GET(string) -> string
+
+// Alias for jnode:GET
+jnode:receive(string) -> string
+
+// Convert from a simple Lua table to a JSON string
+JSON(table) -> string
+~~~
+
+Lua functions for making HTTP requests
+--------------------------------------
+
+Quick example: `GET("http://ix.io/1FTw")`
+
+~~~c
+// Create a new HTTP Client object
+HTTPClient() -> userdata
+
+// Select Accept-Language (ie. "en-us")
+hc:SetLanguage(string)
+
+// Set the request timeout (in milliseconds)
+hc:SetTimeout(number)
+
+// Set a cookie (name and value)
+hc:SetCookie(string, string)
+
+// Set the user agent (ie. "curl")
+hc:SetUserAgent(string)
+
+// Perform a HTTP GET request. First comes the URL, then an optional table with
+// URL parameters, then an optional table with HTTP headers.
+hc:Get(string, [table], [table]) -> string
+
+// Perform a HTTP POST request. It's the same arguments as for `Get`, except
+// the fourth optional argument is the POST body.
+hc:Post(string, [table], [table], [string]) -> string
+
+// Like `Get`, except the first argument is the HTTP method (like "PUT")
+hc:Do(string, string, [table], [table]) -> string
+
+// Shorthand for HTTPClient():Get()
+GET(string, [table], [table]) -> string
+
+// Shorthand for HTTPClient():Post()
+POST(string, [table], [table], [string]) -> string
+
+// Shorthand for HTTPClient():Do()
+DO(string, string, [table], [table]) -> string
+~~~
+
+
+
+Lua functions for plugins
+-------------------------
+
+~~~c
+// Load a plugin given the path to an executable. Returns true on success. Will return the plugin help text if called on the Lua prompt.
+// Pass in true as the second argument to keep it running.
+Plugin(string, [bool])
+
+// Returns the Lua code as returned by the Lua.Code function in the plugin, given a plugin path. May return an empty string.
+// Pass in true as the second argument to keep it running.
+PluginCode(string, [bool]) -> string
+
+// Takes a plugin path, function name and arguments. Returns an empty string if the function call fails, or the results as a JSON string if successful.
+CallPlugin(string, string, ...) -> string
+~~~
+
+
+Lua functions for code libraries
+--------------------------------
+
+These functions can be used in combination with the plugin functions for storing Lua code returned by plugins when serverconf.lua is loaded, then retrieve the Lua code later, when handling requests. The code is stored in the database.
+
+~~~c
+// Create or uses a code library object. Optionally takes a data structure name as the first parameter.
+CodeLib([string]) -> userdata
+
+// Given a namespace and Lua code, add the given code to the namespace. Returns true on success.
+codelib:add(string, string) -> bool
+
+// Given a namespace and Lua code, set the given code as the only code in the namespace. Returns true on success.
+codelib:set(string, string) -> bool
+
+// Given a namespace, return Lua code, or an empty string.
+codelib:get(string) -> string
+
+// Import (eval) code from the given namespace into the current Lua state. Returns true on success.
+codelib:import(string) -> bool
+
+// Completely clear the code library. Returns true on success.
+codelib:clear() -> bool
+~~~
+
+
+Lua functions for file uploads
+------------------------------
+
+~~~c
+// Creates a file upload object. Takes a form ID (from a POST request) as the first parameter.
+// Takes an optional maximum upload size (in MiB) as the second parameter.
+// Returns nil and an error string on failure, or userdata and an empty string on success.
+UploadedFile(string[, number]) -> userdata, string
+
+// Return the uploaded filename, as specified by the client
+uploadedfile:filename() -> string
+
+// Return the size of the data that has been received
+uploadedfile:size() -> number
+
+// Return the mime type of the uploaded file, as specified by the client
+uploadedfile:mimetype() -> string
+
+// Return the full textual content of the uploaded file
+uploadedfile:content() -> string
+
+// Save the uploaded data locally. Takes an optional filename. Returns true on success.
+uploadedfile:save([string]) -> bool
+
+// Save the uploaded data as the client-provided filename, in the specified directory.
+// Takes a relative or absolute path. Returns true on success.
+uploadedfile:savein(string)  -> bool
+~~~
+
+
+Lua functions for the file cache
+--------------------------------
+
+~~~c
+// Return information about the file cache.
+CacheInfo() -> string
+
+// Clear the file cache.
+ClearCache()
+
+// Load a file into the cache, returns true on success.
+preload(string) -> bool
+~~~
+
+Lua functions for data structures
+---------------------------------
+
+##### Set
+
+~~~c
+// Get or create a database-backed Set (takes a name, returns a set object)
+Set(string) -> userdata
+
+// Add an element to the set
+set:add(string)
+
+// Remove an element from the set
+set:del(string)
+
+// Check if a set contains a value
+// Returns true only if the value exists and there were no errors.
+set:has(string) -> bool
+
+// Get all members of the set
+set:getall() -> table
+
+// Remove the set itself. Returns true on success.
+set:remove() -> bool
+
+// Clear the set
+set:clear() -> bool
+~~~
+
+##### List
+
+~~~c
+// Get or create a database-backed List (takes a name, returns a list object)
+List(string) -> userdata
+
+// Add an element to the list
+list:add(string)
+
+// Get all members of the list
+list:getall() -> table
+
+// Get the last element of the list
+// The returned value can be empty
+list:getlast() -> string
+
+// Get the N last elements of the list
+list:getlastn(number) -> table
+
+// Remove the list itself. Returns true on success.
+list:remove() -> bool
+
+// Clear the list. Returns true on success.
+list:clear() -> bool
+
+// Return all list elements (expected to be JSON strings) as a JSON list
+list:json() -> string
+~~~
+
+##### HashMap
+
+~~~c
+// Get or create a database-backed HashMap (takes a name, returns a hash map object)
+HashMap(string) -> userdata
+
+// For a given element id (for instance a user id), set a key
+// (for instance "password") and a value.
+// Returns true on success.
+hash:set(string, string, string) -> bool
+
+// For a given element id (for instance a user id), and a key
+// (for instance "password"), return a value.
+// Returns a value only if they key was found and if there were no errors.
+hash:get(string, string) -> string
+
+// For a given element id (for instance a user id), and a key
+// (for instance "password"), check if the key exists in the hash map.
+// Returns true only if it exists and there were no errors.
+hash:has(string, string) -> bool
+
+// For a given element id (for instance a user id), check if it exists.
+// Returns true only if it exists and there were no errors.
+hash:exists(string) -> bool
+
+// Get all keys of the hash map
+hash:getall() -> table
+
+// Remove a key for an entry in a hash map
+// (for instance the email field for a user)
+// Returns true on success
+hash:delkey(string, string) -> bool
+
+// Remove an element (for instance a user)
+// Returns true on success
+hash:del(string) -> bool
+
+// Remove the hash map itself. Returns true on success.
+hash:remove() -> bool
+
+// Clear the hash map. Returns true on success.
+hash:clear() -> bool
+~~~
+
+##### KeyValue
+
+~~~c
+// Get or create a database-backed KeyValue collection (takes a name, returns a key/value object)
+KeyValue(string) -> userdata
+
+// Set a key and value. Returns true on success.
+kv:set(string, string) -> bool
+
+// Takes a key, returns a value.
+// Returns an empty string if the function fails.
+kv:get(string) -> string
+
+// Takes a key, returns the value+1.
+// Creates a key/value and returns "1" if it did not already exist.
+// Returns an empty string if the function fails.
+kv:inc(string) -> string
+
+// Remove a key. Returns true on success.
+kv:del(string) -> bool
+
+// Remove the KeyValue itself. Returns true on success.
+kv:remove() -> bool
+
+// Clear the KeyValue. Returns true on success.
+kv:clear() -> bool
+~~~
+
+Lua functions for external databases
+------------------------------------
+
+~~~c
+// Query a PostgreSQL database with a SQL query and a connection string
+PQ([string], [string]) -> table
+~~~
+
+The default connection string is `host=localhost port=5432 user=postgres dbname=test sslmode=disable` and the default SQL query is `SELECT version()`. Database connections are re-used if they still answer to `.Ping()`, for the same connection string.
+
+~~~c
+// Query a MSSQL database with SQL, a connection string, and a parameter table
+MSSQL([string], [string], [table]) -> table
+~~~
+
+- The default connection string is `server=localhost;user=sa;password=Password123,port=1433` and the default SQL query is `"SELECT @@VERSION`. Database connections are re-used if they still answer to `.Ping()`, for the same connection string.
+- If the param table is numerically indexed, positional placeholders are expected: `MSSQL("SELECT * FROM users WHERE first = @p1 AND last = @p2", conn, {"John", "Smith"})`
+- If the param table is keyed with strings, named placeholders are expected: `MSSQL("SELECT * FROM users WHERE first = @first AND last = @last", conn, {first = "John", last = "Smith"})`
+
+
+Lua functions for handling users and permissions
+------------------------------------------------
+
+~~~c
+// Check if the current user has "user" rights
+UserRights() -> bool
+
+// Check if the given username exists (does not look at the list of unconfirmed users)
+HasUser(string) -> bool
+
+// Check if the given username exists in the list of unconfirmed users
+HasUnconfirmedUser(string) -> bool
+
+// Get the value from the given boolean field
+// Takes a username and field name
+BooleanField(string, string) -> bool
+
+// Save a value as a boolean field
+// Takes a username, field name and boolean value
+SetBooleanField(string, string, bool)
+
+// Check if a given username is confirmed
+IsConfirmed(string) -> bool
+
+// Check if a given username is logged in
+IsLoggedIn(string) -> bool
+
+// Check if the current user has "admin rights"
+AdminRights() -> bool
+
+// Check if a given username is an admin
+IsAdmin(string) -> bool
+
+// Get the username stored in a cookie, or an empty string
+UsernameCookie() -> string
+
+// Store the username in a cookie, returns true on success
+SetUsernameCookie(string) -> bool
+
+// Clear the login cookie
+ClearCookie()
+
+// Get a table containing all usernames
+AllUsernames() -> table
+
+// Get the email for a given username, or an empty string
+Email(string) -> string
+
+// Get the password hash for a given username, or an empty string
+PasswordHash(string) -> string
+
+// Get all unconfirmed usernames
+AllUnconfirmedUsernames() -> table
+
+// Get the existing confirmation code for a given user,
+// or an empty string. Takes a username.
+ConfirmationCode(string) -> string
+
+// Add a user to the list of unconfirmed users
+// Takes a username and a confirmation code
+// Remember to also add a user, when registering new users.
+AddUnconfirmed(string, string)
+
+// Remove a user from the list of unconfirmed users
+// Takes a username
+RemoveUnconfirmed(string)
+
+// Mark a user as confirmed
+// Takes a username
+MarkConfirmed(string)
+
+// Removes a user
+// Takes a username
+RemoveUser(string)
+
+// Make a user an admin
+// Takes a username
+SetAdminStatus(string)
+
+// Make an admin user a regular user
+// Takes a username
+RemoveAdminStatus(string)
+
+// Add a user
+// Takes a username, password and email
+AddUser(string, string, string)
+
+// Set a user as logged in on the server (not cookie)
+// Takes a username
+SetLoggedIn(string)
+
+// Set a user as logged out on the server (not cookie)
+// Takes a username
+SetLoggedOut(string)
+
+// Log in a user, both on the server and with a cookie
+// Takes a username
+Login(string)
+
+// Log out a user, on the server (which is enough)
+// Takes a username
+Logout(string)
+
+// Get the current username, from the cookie
+Username() -> string
+
+// Get the current cookie timeout
+// Takes a username
+CookieTimeout(string) -> number
+
+// Set the current cookie timeout
+// Takes a timeout number, measured in seconds
+SetCookieTimeout(number)
+
+// Get the current server-wide cookie secret. This is used when setting
+// and getting browser cookies when users log in.
+CookieSecret() -> string
+
+// Set the current server-side cookie secret. This is used when setting
+// and getting browser cookies when users log in. Using the same secret
+// makes browser cookies usable across server restarts.
+SetCookieSecret(string)
+
+// Get the current password hashing algorithm (bcrypt, bcrypt+ or sha256)
+PasswordAlgo() -> string
+
+// Set the current password hashing algorithm (bcrypt, bcrypt+ or sha256)
+// ‘bcrypt+‘ accepts bcrypt or sha256 for old passwords, but will only use
+// bcrypt for new passwords.
+SetPasswordAlgo(string)
+
+// Hash the password
+// Takes a username and password (username can be used for salting sha256)
+HashPassword(string, string) -> string
+
+// Change the password for a user, given a username and a new password
+SetPassword(string, string)
+
+// Check if a given username and password is correct
+// Takes a username and password
+CorrectPassword(string, string) -> bool
+
+// Checks if a confirmation code is already in use
+// Takes a confirmation code
+AlreadyHasConfirmationCode(string) -> bool
+
+// Find a username based on a given confirmation code,
+// or returns an empty string. Takes a confirmation code
+FindUserByConfirmationCode(string) -> string
+
+// Mark a user as confirmed
+// Takes a username
+Confirm(string)
+
+// Mark a user as confirmed, returns true on success
+// Takes a confirmation code
+ConfirmUserByConfirmationCode(string) -> bool
+
+// Set the minimum confirmation code length
+// Takes the minimum number of characters
+SetMinimumConfirmationCodeLength(number)
+
+// Generates a unique confirmation code, or an empty string
+GenerateUniqueConfirmationCode() -> string
+~~~
+
+
+Lua functions that are available for server configuration files
+---------------------------------------------------------------
+
+~~~c
+// Set the default address for the server on the form [host][:port].
+// May be useful in Algernon application bundles (.alg or .zip files).
+SetAddr(string)
+
+// Reset the URL prefixes and make everything *public*.
+ClearPermissions()
+
+// Add an URL prefix that will have *admin* rights.
+AddAdminPrefix(string)
+
+// Add a reverse proxy given a path prefix and an endpoint URL
+// For example: "/api" and "http://localhost:8080"
+AddReverseProxy(string, string)
+
+// Add an URL prefix that will have *user* rights.
+AddUserPrefix(string)
+
+// Provide a lua function that will be used as the permission denied handler.
+DenyHandler(function)
+
+// Return a string with various server information.
+ServerInfo() -> string
+
+// Direct the logging to the given filename. If the filename is an empty
+// string, direct logging to stderr. Returns true on success.
+LogTo(string) -> bool
+
+// Returns the version string for the server.
+version() -> string
+
+// Logs the given strings as INFO. Takes a variable number of strings.
+log(...)
+
+// Logs the given strings as WARN. Takes a variable number of strings.
+warn(...)
+
+// Logs the given string as ERROR. Takes a variable number of strings.
+err(...)
+
+// Provide a lua function that will be run once, when the server is ready to start serving.
+OnReady(function)
+
+// Use a Lua file for setting up HTTP handlers instead of using the directory structure.
+ServerFile(string) -> bool
+
+// Serve files from this directory.
+ServerDir(string) -> bool
+
+// Get the cookie secret from the server configuration.
+CookieSecret() -> string
+
+// Set the cookie secret that will be used when setting and getting browser cookies.
+SetCookieSecret(string)
+~~~
+
+Functions that are only available for Lua server files
+------------------------------------------------------
+
+This function is only available when a Lua script is used instead of a server directory, or from Lua files that are specified with the `ServerFile` function in the server configuration.
+
+~~~c
+// Given an URL path prefix (like "/") and a Lua function, set up an HTTP handler.
+// The given Lua function should take no arguments, but can use all the Lua functions for handling requests, like `content` and `print`.
+handle(string, function)
+
+// Given an URL prefix (like "/") and a directory, serve the files and directories.
+servedir(string, string)
+~~~
+
+Commands that are only available in the REPL
+--------------------------------------------
+
+* `help` displays a syntax highlighted overview of most functions.
+* `webhelp` displays a syntax highlighted overview of functions related to handling requests.
+* `confighelp` displays a syntax highlighted overview of functions related to server configuration.
+
+Extra Lua functions
+-------------------
+
+~~~c
+// Pretty print. Outputs the values in, or a description of, the given Lua value(s).
+pprint(...)
+
+// Takes a Python filename, executes the script with the `python` binary in the Path.
+// Returns the output as a Lua table, where each line is an entry.
+py(string) -> table
+
+// Takes one or more system commands (possibly separated by `;`) and runs them.
+// Returns the output lines as a table.
+run(string) -> table
+
+// Lists the keys and values of a Lua table. Returns a string.
+// Lists the contents of the global namespace `_G` if no arguments are given.
+dir([table]) -> string
+~~~
+
+Markdown
+--------
+
+Algernon can be used as a quick Markdown viewer with the `-m` flag.
+
+Try `algernon -m README.md` to view `README.md` in the browser, serving the file once on a port >3000.
+
+In addition to the regular Markdown syntax, Algernon supports setting the page title and syntax highlight style with a header comment like this at the top of a Markdown file:
+
+    <!--
+    title: Page title
+    theme: dark
+    code_style: lovelace
+    replace_with_theme: default_theme
+    -->
+
+Code is highlighted with [highlight.js](https://highlightjs.org/) and [several styles](https://highlightjs.org/static/demo/) are available.
+
+The string that follows `replace_with_theme` will be used for replacing the current theme string (like `dark`) with the given string. This makes it possible to use one image (like `logo_default_theme.png`) for one theme and another image (`logo_dark.png`) for the dark theme.
+
+The theme can be `light`, `dark`, `redbox`, `bw`, `github`, `wing`, `material`, `neon`, `default`, `werc` or a path to a CSS file. Or `style.gcss` can exist in the same directory.
+
+An overview of available syntax highlighting styles can be found at the [Chroma Style Gallery](https://xyproto.github.io/splash/docs/).
+
+
+HTTPS certificates with Let's Encrypt and Algernon
+--------------------------------------------------
+
+#### Method 1
+
+Follow the guide at [certbot.eff.org](https://certbot.eff.org/) for the "None of the above" web server, then start `algernon` with `--cert=/etc/letsencrypt/live/mydomain.space/cert.pem --key=/etc/letsencrypt/live/mydomain.space/privkey.pem` where `mydomain.space` is replaced with your own domain name.
+
+First make Algernon serve a directory for the domain, like `/srv/mydomain.space`, then use that as the webroot when configuring `certbot` with the `certbot certonly` command.
+
+Remember to set up a cron-job or something similar to run `certbot renew` every once in a while (every 12 hours is suggested by [certbot.eff.org](https://certbot.eff.org/)). Also remember to restart the algernon service after updating the certificates. A way to refresh the certificates without restarting Algernon will be implemented in the future.
+
+#### Method 2
+
+Use the `--letsencrypt` flag together with the `--domain` flag to automatically fetch and use certificates from Let's Encrypt.
+
+For instance, if `/srv/myhappydomain.com` exists, then `algernon --letsencrypt --domain /srv` can be used to serve `myhappydomain.com` if it points to this server, and fetch certificates from Let's Encrypt.
+
+When `--letsencrypt` is used, it will try to serve on port 443 and 80 (which redirects to 443).
+
+Releases
+--------
+
+* [Arch Linux package](https://aur.archlinux.org/packages/algernon) in the AUR.
+* [Windows executable](https://github.com/xyproto/algernon/releases/tag/v1.0-win8-64).
+* [macOS homebrew package](https://raw.githubusercontent.com/xyproto/algernon/main/system/homebrew/algernon.rb)
+* [Algernon Tray Launcher for macOS, in App Store](https://itunes.apple.com/no/app/algernon-server/id1030394926?l=nb&mt=12)
+* Source releases are tagged with a version number at release.
+
+
+Requirements
+------------
+
+* `go 1.21` or later is a requirement for building Algernon.
+* For `go 1.10`, `1.11`, `1.12`, `1.13`, `1.14`, '1.15`, `1.16` + `gcc-go <10` version `1.12.7` of Algernon is the last supported version.
+
+Access logs
+-----------
+
+Can log to a Combined Log Format access log with the `--accesslog` flag. This works nicely together with [goaccess](https://goaccess.io/).
+
+### Example usage
+
+Serve files in one directory:
+
+    algernon --accesslog=access.log -x
+
+Then visit the web page once, to create one entry in the access.log.
+
+The wonderful [goaccess](https://goaccess.io) utility can then be used to view the access log, while it is being filled:
+
+    goaccess --no-global-config --log-format=COMBINED access.log
+
+If you have goaccess setup correctly, running goaccess without any flags should work too:
+
+    goaccess access.log
+
+`.alg` files
+------------
+
+`.alg` files are just renamed `.zip` files, that can be served by Algernon. There is an example application here: [wercstyle](https://github.com/xyproto/wercstyle).
+
+Logo license
+------------
+
+Thanks to [Egon Elbre](https://twitter.com/egonelbre) for the two SVG drawings that I remixed into the current logo ([CC0](https://creativecommons.org/publicdomain/zero/1.0/) licensed).
+
+Listening to port 80 without running as root
+--------------------------------------------
+
+For Linux:
+
+    sudo setcap cap_net_bind_service=+ep /usr/bin/algernon
+
+Other resources
+---------------
+
+* [Algernon on docker hub](https://hub.docker.com/r/xyproto/algernon/)
+
+General information
+-------------------
+
+* Version: 1.15.3
+* License: BSD-3
+* Alexander F. Rødseth &lt;xyproto@archlinux.org&gt;
+
+Stargazers over time
+--------------------
+
+[![Stargazers over time](https://starchart.cc/xyproto/algernon.svg)](https://starchart.cc/xyproto/algernon)
+
+<a href="https://algernon.roboticoverlords.org"><img alt="0-0" src="img/gophereyes.png" align="right"></a>
+
+The jump in stargazers happened when Algernon reached the front page of Hacker News:
+
+* [Self-Contained Pure-Go Web Server with Lua, MD, HTTP/2, QUIC, Redis Support](https://news.ycombinator.com/item?id=19578351)

+ 284 - 0
TODO.md

@@ -0,0 +1,284 @@
+# Plans
+
+General
+-------
+
+- [ ] Add a Go function for adding a Lua function that can handle websocket requests to `/ws`.
+- [ ] Add a flag for redirecting all `http://` traffic to `https://`.
+- [ ] Add a smoother way than `CodeLib()` to define site-wide Lua values.
+- [ ] Create a video like the one at [vim-livedown](https://github.com/shime/vim-livedown), that demonstrates live editing of Markdown.
+- [ ] When `-m` is given, scan the given Markdown file for images that will also need to be served, then wait until those are served before exiting.
+- [ ] Add support for [metatar](https://github.com/xyproto/metatar) in Lua, to be able to offer a whole Arch Linux package repository from just a single `.lua` file, and a collection of `PKGBUILD` files.
+- [ ] Integrate [boltBrowser](https://github.com/ShoshinNikita/boltBrowser).
+- [ ] Make it possible to send the access log to the database.
+- [ ] Make it possible to send the error log to the database.
+- [ ] User management interface + web REPL + stats + logs + import/export data .alg launcher.
+- [ ] Make most methods in [onthefly](https://github.com/xyproto/onthefly) available to Algernon/Lua.
+- [ ] Present directories with media files with a built-in page.
+- [ ] Make the behavior per file extension or mime type configurable: "raw view", "pretty view" or "download"
+- [ ] Add a Lua function for upgrading a handler to a WebSocket handler, also using concurrency in Lua.
+- [ ] Add support for pushing from emacs "writefreely mode" to Algernon with this [API](https://developers.write.as/docs/api/).
+- [ ] Parse options with [docopt](https://github.com/docopt/docopt.go) or [cli](https://github.com/urfave/cli).
+- [ ] Use [configparser](https://github.com/alyu/configparser) for a configuration file with port, host, keys etc.
+
+Languages other than Lua
+------------------------
+
+- [ ] Add support for [zygomys](https://github.com/glycerine/zygomys) on equal footing with Lua.
+- [ ] Embed the [Fennel](https://github.com/bakpakin/Fennel) package (compiles to Lua) so that Fennel can be used on equal footing with Lua for Algernon projects.
+
+Community
+---------
+
+- [ ] Add links to various chat pages and forums in the `README.md` file.
+- [ ] Create a web page for uploading, reviewing, previewing and downloading Algernon Applications (`.alg` files).
+
+Performance and memory usage
+----------------------------
+
+- [ ] Make calling Lua scripts thread safe without using a mutex, either by modifying gopher-lua or by creating a way of calling Lua over channels.
+- [ ] Profile the startup process and make it even faster.
+- [ ] Add a flag for caching to the database backend instead of to memory.
+- [ ] Add an option for using **brotly** compression instead of **gzip**.
+- [ ] Use fasthttp (or something equally performant) when using regular HTTP: [switching to fasthttp](https://github.com/valyala/fasthttp#switching-from-nethttp-to-fasthttp).
+- [ ] When requests are handled, spawn each switch/case as a Go routine. Benchmark to see if there is a difference.
+
+Styles and themes
+-----------------
+
+- [ ] Port the layout and concept of [werc](http://werc.cat-v.org/) to Algernon. See also [gowerc](https://bitbucket.org/mischief/gowerc/src).
+- [ ] Add a flag for dumping the currently used Markdown theme to a CSS file and exit.
+- [ ] Add a Markdown style similar to this one: [style.css](https://hyperapp.glitch.me/style.css)
+- [ ] Add a Markdown style similar to this one: [setconf](http://setconf.roboticoverlords.org/)
+- [ ] Add support for [Ghost](https://ghost.org) and/or [Hugo](https://github.com/gohugoio/hugoThemes) themes.
+
+Documentation/tutorials
+-----------------------
+
+- [ ] Add sample for using Vue.js + Algernon.
+- [ ] Look into using [slate](https://github.com/lord/slate) for documentation.
+- [ ] Add sample for HyperApp + database usage.
+- [ ] Terminal recording of Lua tutorial using `algernon --lua`.
+- [ ] Terminal recording demonstrating creating a simple register+login site.
+- [ ] Update the book to be more similar to the python Flask documentation.
+- [ ] Video tutorials and screencasts.
+- [ ] Document what the "current directory" is for various Lua functions that deals with files.
+- [ ] Document better the order of output calls when modifying the header to redirect.
+- [ ] Document how to read JSON from one place and output processed data somewhere else.
+- [ ] Create a docker image that comes with all the samples.
+- [ ] Create a sample TODOMVC application.
+- [ ] Port [niltalk](https://github.com/knadh/niltalk) to Algernon, in a separate repository.
+- [ ] Create a sample chat application.
+- [ ] Document possible MultiMarkdown keywords somewhere (in a separate document).
+- [ ] Add a sample for bricklayer https://github.com/ademilter/bricklayer
+
+Various
+-------
+
+- [ ] Add a C++ plugin example.
+- [ ] Check behavior of ctrl-c/ctrl-d on macOS vs Linux vs Windows.
+- [ ] Add a theme that looks like [huytd.github.io](https://huytd.github.io).
+- [ ] Add fastcgi support, for connecting to fastcgi servers and use them for serving content?
+- [ ] Write a module for caching that can cache chunks of files and stream files that does not fit in memory directly from disk.
+- [ ] Add support for systemd reload, not just restart.
+- [ ] Render JavaScript server-side by using [Goja](https://github.com/dop251/goja)
+- [ ] Use [cfilter](https://github.com/irfansharif/cfilter) for potentially faster cache lookups.
+- [ ] Support [HAML](https://github.com/travissimon/ghaml)?
+- [ ] Introduce a separate package for dealing with Lua pools, Lua states and
+      adding custom functions to some Lua states. All without using mutexes.
+- [ ] Support for websockets (port a small multiplayer game to test).
+- [ ] Add support for Handlebars: [raymond](https://github.com/aymerick/raymond)
+- [ ] Server side support for [sw-delta](https://github.com/gmetais/sw-delta)
+- [ ] Add a flag to minify all transmitted CSS/HTML/JS/JSON/SVG/XML files
+      https://github.com/tdewolff/minify
+- [ ] Draw inspiration from https://github.com/olebedev/go-starter-kit
+- [ ] Draw inspiration from https://github.com/disintegration/bebop
+- [ ] Provide a Lua sample/command for listing files and directories with dates and sizes.
+- [ ] Find a way to redirect while preserving headers and/or use a mux package.
+- [ ] Implement a documentation server that can convert files with pandoc.
+- [ ] Make it easy to apply patches on the fly, when GET-ting the resulting file
+- [ ] Built in support for running the Lua REPL in the browser (possibly by using "gotty", either as a package or wrapped in a script).
+- [ ] Create a sample that is inspired by this design: http://codepen.io/KtorZ/pen/ZOzdqG
+- [ ] Add Markdown themes from: https://github.com/mixu/markdown-styles
+- [ ] Add a similar boilerplate as Jekyll to megaboilerplate.com
+- [ ] Describe how to set up a system a bit similar to a wiki, but more lightweight, using git + git hooks + algernon.
+- [ ] Add a flag for listing and selecting styles for Markdown and directory listings.
+- [ ] Specify if rate limiting is per user/ip/handler
+- [ ] Add a flag for serving with fasthttp: https://github.com/valyala/fasthttp
+- [ ] Create alg2systemd-nspawn and alg2runc.
+- [ ] Create a site generator for Algernon. Draw inspiration from http://nanoc.ws/doc/tutorial/
+- [ ] Draw inspiration from https://lwan.ws/
+- [ ] Check out https://github.com/peterh/liner
+- [ ] Support SASS and HAML. Maybe.
+- [ ] Port Pastecat to Algernon (https://github.com/mvdan/pastecat)
+- [ ] Argon2 hashing algorithm support (https://godoc.org/github.com/magical/argon2)
+- [ ] Add config Function for adding a directory listing title to a certain path regex (and/or a title.txt or common.md file).
+- [ ] Add a lua function for presenting an executable as a web application, like gotty does. Create a password protected example application.
+- [ ] Web application for browsing the database.
+- [ ] Document the case sensitivity or add case insensitivity support.
+- [ ] Create a tool that pretends to upload a file of size 128 bytes
+      (Content-Length), but continues to stream data. Test with Algernon.
+- [ ] Lua plugin that is not via the database
+- [ ] File upload while handling gzip
+- [ ] Cache os.Stat also when serving directory listings
+- [ ] Implement https://github.com/labstack/echo/tree/master/examples as Algernon applications
+- [ ] Look into github.com/jessevdk/go-flags/
+- [ ] pprint should output text to the browser when not running in the repl (or be disabled)
+- [ ] Graph of visitors over time
+- [ ] See if the HTTP headers from the client + country of origin + mouse
+      movement patterns can become some sort of pseudo ID. Combine with a
+      neural net. Can be used for storing non-critical data like preferred
+      themes, font sizes etc. Time of day may also be an input.
+- [ ] Add editor syntax highlight files.
+- [ ] Support for pretty URLs and/or routing in serverconf.lua (/position/x/2/y/4).
+- [ ] Command line utilities for editing users, permissions, databases and Lua functions in databases.
+- [ ] Add a lua function for running a lua function periodically.
+- [ ] Add a cache mode for caching binary files only.
+- [ ] MSI installer.
+- [ ] deb/ppa
+- [ ] Consider using https://github.com/sbinet/igo instead of readline.
+- [ ] Create a utility for creating and running new projects, ala Meteor.
+- [ ] Add Lua functions for BSON and ION?
+- [ ] Add simpleredis/simplebolt/simplemaria functions for exporting/importing data to JSON and offer these.
+
+Events
+------
+
+- [ ] Better 404 page not found page for users visiting "/".
+- [ ] Consider only listening for changes after a file has been visited, then stop watching it after a while.
+- [ ] Use a regexp or a JavaScript minification package instead of replacing strings in insertAutoRefresh.
+- [ ] In genFileChangeEvents, check for CloseNotify for more graceful timeouts.
+
+Server configuration
+--------------------
+- [ ] Prefer environment variables and flags over lua server configuration.
+
+Routing
+-------
+- [ ] Server("host:port", "/srv/http/somedirectory", "/var/log/algernon/logfile.log")
+- [ ] Redirect("host/path:port", ":port/path")
+- [ ] Rewrite("host:port", "host:port/path")
+- [ ] RewritePrefix("www.", "")
+- [ ] RewritePort("host", 443, 80)
+
+REPL
+----
+
+- [/] Make `help` work a bit like in Python.
+- [/] Make `dir` work a bit like in Python.
+
+Plugins
+-------
+
+- [ ] Unmarshal the CallPlugin reply into appropriate Lua structures instead of returning a JSON string.
+- [ ] If a plugin ends with `.go`, check if go is installed and run it with "go run" (if a binary of the same name has not been provided for the current platform).
+- [ ] Add a function for loading all plugins in a "plugins" directory.
+
+Security-related
+----------------
+
+- [ ] Consider using [secure](https://github.com/unrolled/secure).
+- [ ] HTTP Basic Auth using the permissions2 usernames and passwords, for selected URL prefixes. Use code from "scoreserver".
+- [ ] Check that HTTP reads not only times out, but has a deadline.
+- [ ] Flag for disabling directory listings entirely.
+- [ ] OAuth 1
+- [ ] OAuth 2
+- [ ] The ability to set headers and do HTTP Basic Auth manually.
+- [ ] Check if `*` or the server host should be used as parameter to the EventServer function.
+- [ ] Implement a warning when using cookies over regular HTTP.
+
+Console output
+--------------
+- [ ] Check the terminal capabilities and terminal width. Display a smaller logo if the width is smaller. Or no logo.
+
+Lua
+---
+- [ ] Add a function named "sort" for quickly sorting tables by key or by value, numerical or lexical.
+- [ ] Add a Lua function ForEach that takes a data structure and a function
+      that takes a key and a value.
+- [ ] Wrap JNode in the same way as JFile.
+- [ ] Change the "JSON" function and create some sort of JSON object that returns the string by default.
+- [ ] Add a function for sanitizing HTML, possibly with bluemonday.
+- [ ] Create an import function for importing online lua libraries.
+      (Like `require`, but over http). (possibly luarocks packages).
+- [ ] In runLuaString, check if L.Close() really is needed instead of
+      luapool.Put(L)
+- [ ] Way to load parts of a page asynchronously (with gopher-lua channels?)
+- [ ] Way to use Lua libraries for adding ie. SQLite support.
+
+Performance
+-----------
+
+- [ ] Minify CSS, JS and HTML (as enabled by default, but can be disabled)
+- [ ] Find a reliable way of measuring speed and emulating users.
+      gor? https://github.com/buger/gor
+- [ ] Cache compiled templates as well, not just the final result.
+
+Unusual features
+----------------
+
+- [ ] A function for specifying png images by using ` `, `-` and `*` for
+      pixels inside a `[[``]]` block, while specifying a main color. This can
+      be used as an alternative way to serve favicon.ico files or specify icon
+      graphics. Same thing could be used for svg, but by specifying numbered
+      vertices in a polygon. Update: Someone else has made a format for this!
+      https://github.com/cparnot/ASCIImage
+
+Maybe
+-----
+
+- [ ] Add support for both SASS and SCSS (Perhaps https://github.com/c9s/c6)
+- [ ] Add configurable log hooks for the systems logrus supports.
+      See: https://github.com/Sirupsen/logrus
+- [ ] When searching files and directories, do it in parallel, like [wallutils](https://github.com/xyproto/wallutils).
+- [ ] Add a Lua function for outputting Lua tables to the client.
+- [ ] Add a Lua function for fetching a value from a table, or a blank string.
+- [ ] Add a Lua function for checking if a file exists.
+- [ ] Mention the `jpath` package in the README.
+- [ ] Support for plugins written in BF.
+- [ ] A flag to store the Bolt database inside the given zip file?
+- [ ] Keep all configuration settings in Redis. Use an external package for
+      handling configuration.
+- [ ] Support for the [onthefly](https://github.com/xyproto/onthefly) package,
+      as a virtual DOM.
+- [ ] WebRTC? Three.js? Web components?
+- [ ] Use the goroutine functionality provided by gopher-lua to provide
+      "trigger functions" that sends 1 on a channel when the function
+      triggers, perhaps when a file is changed. Combine this with javascript
+      somehow to make it possible to change the parts of a page when an
+      event happens.
+- [ ] User functions shared by many lua pages should not be placed in
+      `app.lua`, nor in a place related to the server, but be imported where
+      they are needed. Either by importing a lua file, by importing a lua
+      file by url or by connecting to a Lua Function Server.
+- [ ] Make it possible to toggle the pretty error view on or off in
+      `serverconf.lua`, for temporary debugging.
+- [ ] Find a good way to store errors.
+- [ ] Implement a page, with admin rights, that displays the last error
+      together with the sourcecode, in a pretty way.
+- [ ] Add a flag for specifying a different default set of URL prefixes with
+      admin, user or public rights.
+- [ ] Add a flag for detailed debug information at errors, or not.
+- [ ] If a symbolic link to a directory is made, for instance /chat -> /data,
+      then algernon should also apply user permissions to the symbolic link.
+- [ ] Add a function for calling EVAL on the redis server, while sending Lua
+      code to the server for evaluation.
+- [ ] Re-run the Lua server script if changed. Restart the server if the addr
+      or port is changed.
+- [ ] Add a function tprint("file.tmpl", table) for github.com/unrolled/render.
+- [ ] Read zip files directly instead of decompressing when given as the
+      first argument (downside: some Amber functions look for files in the
+      same directory).
+- [ ] Utilities to lint and package .alg archives.
+- [ ] Whitelist and blacklist for which file extensions to cache
+- [ ] Use golang/pkg/net/rpc/#Client.Go for calling plugins asynchronously.
+      Let Lua provide a callback function.
+- [ ] Configuration function for whitelisting URL prefixes.
+- [ ] Functions for adding URL prefixes to the whitelist
+- [ ] Lua function for reading the contents of a file in the script dir,
+      but in a cached way. Timestamp, filename and data are stored in Redis,
+      if timestamp changes, data is re-read.
+- [ ] Add a Lua function for removing all cache entries without a hit.
+- [ ] Support the LuaPage format (".lp", HTML with <% %> and <%= %> for Lua code).
+- [ ] Add Lua functions for HTTP PUT without using JSON? (for etcd, but might be a bad idea in the first place).
+- [ ] Rewrite in C++23 and rename the project to "FnuFnu".

+ 27 - 0
alg2docker/README.md

@@ -0,0 +1,27 @@
+# alg2docker
+
+This utility can generate a Dockerfile, given an Algernon application (`.alg` file).
+
+### Step by step usage
+
+1. Have an `.alg` file (for instance `hello.alg`), and also a directory with a `cert.pem` and `key.pem` file that can be used when serving HTTPS.
+
+2. Generate the Dockerfile:
+
+    `./alg2docker -f hello.alg Dockerfile 'John Bob' 'john@thebobs.cx'`
+
+3. Build the Docker image:
+
+    `docker build -t hello .`
+
+The resulting Docker image will include the application itself, but not the SSL keys used for HTTPS.
+
+4. Serve the application using docker and the `cert.pem` and `key.pem` files in `$PWD/config`:
+
+    `docker run -v "$PWD/config":/etc/algernon --publish 80:80 --publish 443:443 --rm hello`
+
+### Tweaks
+
+* The cache settings can be modified after creating the `Dockerfile` by changing the command line arguments that are given to Algernon, at the bottom of the file. One might want to disable caching, enable the auto-refresh feature or enable debug mode.
+* When `--domain` is used and a directory `/srv/algernon` corresponds to a valid domain name for the server, like `example.com`, then `/srv/algernon/example.com/` will be served when users vists `example.com`, if Algernon is running on that server.
+* Using `--letsencrypt` together with the `--domain` option, on a server that responds to requests for that domain, will use Let's Encrypt for fetching keys and certificates for the HTTPS port, and use CertMagic to serve both HTTP and HTTPS.

+ 101 - 0
alg2docker/alg2docker

@@ -0,0 +1,101 @@
+#!/bin/bash
+#
+# alg2docker 0.3
+#
+# Convert an Algernon application (.alg or .zip) to a Dockerfile
+#
+
+if [[ $1 == "" || $1 == "-h" || $1 == "--help" ]]; then
+  echo 'alg2docker 0.4.0'
+  echo
+  echo 'Convert an Algernon application (.alg or .zip) to a Dockerfile'
+  echo
+  echo 'Usage:'
+  echo '    alg2docker [-f] ALGFILE [DOCKERFILE] [NAME] [EMAIL]'
+  echo
+  echo 'Example:'
+  echo "    alg2docker hello.alg Dockerfile '$name' '$email'"
+  echo
+  exit 1
+fi
+
+filename=hello.alg
+newfile=Dockerfile
+name="$(whoami)"
+email="$EMAIL"
+force="false"
+
+if [[ $1 == "-f" ]]; then
+  force="true"
+  shift
+fi
+
+if [[ $1 != "" ]]; then filename="$1"; fi
+if [[ $2 != "" ]]; then newfile="$2"; fi
+if [[ $3 != "" ]]; then name="$3"; fi
+if [[ $4 != "" ]]; then email="$4"; fi
+
+basefilename=$(basename "$filename")
+
+basedockerfilename=$(basename "$newfile")
+if [[ "$basedockerfilename" != Dockerfile ]]; then
+  echo "ERROR: expected Dockerfile, got $basedockerfilename"
+  exit 1
+fi
+
+
+if [[ -e $newfile && $force == "false" ]]; then
+  echo "ERROR: file already exists: $newfile"
+  echo "Use -f as the first argument to overwrite."
+  exit 1
+fi
+
+echo "Using this Algernon application: $filename"
+echo "Creating this Dockerfile: $newfile"
+
+cat <<EOF > "$newfile"
+FROM golang as builder
+LABEL MAINTAINER="%EMAIL%"
+
+# Install Algernon
+RUN CGO_ENABLED=0 GOOS=linux go install -trimpath -ldflags "-s" -a -v github.com/xyproto/algernon@latest
+
+# Copy in in the .alg file
+COPY %FILENAME% %FILENAME%
+
+# Start a new Dockerfile based on Alpine
+FROM alpine
+LABEL MAINTAINER="%EMAIL%"
+RUN apk add --no-cache ca-certificates
+
+# Mount the configuration, cert and keys
+#VOLUME /etc/algernon
+
+# Copy in the .alg file
+COPY %FILENAME% /srv/algernon/%BASEFILENAME%
+
+# Copy in the Algernon executable from the builder docker
+COPY --from=builder /go/bin/algernon /usr/bin/algernon
+
+# Expose port 80 (HTTP) and 443 (HTTPS)
+EXPOSE 80 443
+
+# Serve over HTTPS using the custom cert and key
+CMD ["/usr/bin/algernon", "-c", "--cachesize", "67108864", "--cert", "/etc/algernon/cert.pem", "--key", "/etc/algernon/key.pem", "-n", "--prod", "--server", "/srv/algernon/%BASEFILENAME%"]
+
+EOF
+
+# Check the OS
+if [[ "$OSTYPE" == "darwin"* ]]; then
+    # macOS
+    sed -i '' "s|%NAME%|$name|g" "$newfile"
+    sed -i '' "s|%EMAIL%|$email|g" "$newfile"
+    sed -i '' "s|%FILENAME%|$filename|g" "$newfile"
+    sed -i '' "s|%BASEFILENAME%|$basefilename|g" "$newfile"
+else
+    # Linux
+    sed "s|%NAME%|$name|g" -i "$newfile"
+    sed "s|%EMAIL%|$email|g" -i "$newfile"
+    sed "s|%FILENAME%|$filename|g" -i "$newfile"
+    sed "s|%BASEFILENAME%|$basefilename|g" -i "$newfile"
+fi

+ 3 - 0
alg2docker/build.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+./alg2docker -f hello.alg Dockerfile
+docker build -t hello .

+ 34 - 0
alg2docker/config/cert.pem

@@ -0,0 +1,34 @@
+-----BEGIN CERTIFICATE-----
+MIIF9DCCA9ygAwIBAgIJAOiR79sf+Jl1MA0GCSqGSIb3DQEBCwUAMFkxCzAJBgNV
+BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
+aWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0xNTAzMDUwOTQw
+NDBaFw00MjA3MjAwOTQwNDBaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21l
+LVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNV
+BAMTCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKGE
+dCnT8H2+jsMMmjdpY/euCvhjKNe5nV5j7GQGjiADFIaMHJVmJUrc7JRbeQcpgqS9
+j8HgXOcJ9CVgxNsT1jmLIt/VR9vnuB90BVZIBcbAIxG4LUJYZmMm1V0z7N7k3ZqP
+bpxajjk/VCt0BGbey4vylq2E9h/RRsOOa/nmxUwBB2q+6Ful0nP2RsF+RXOuNcS/
+HmkwXtUKfd9Sv20rxjdAwRArjqXTmfsOY+CWlpghqYNRcdncgPMnX/nYA4C+GfKq
+kg8KySIfrNq5JnwHQSq2n6hBY71WZkDK+d5KbK0j7cBk/9ePT7kb6aTRWn1bLS3j
+YNJ+JSi3aw9cwTs6hosyoTjk3rvN898/3I1pcw/X1AFJ4VJWh2HHd6HGWa8pgpUn
+EMYuxle4ei58sR4J5Vm9LGt7T2P04h4wDtjKqTMYI6NegzEVwQDBL0sYtyL1XykD
+VrvRAuSnNf0nw9VGcD6GHY5JzMqakkEgzJxqsQRPyQUyEzkA8QiHQIFGkL+euCKB
+Mkxz1I3jz9YVOkUR1m/yVFQRp1em0+mwMlqlHuRPINz8Fxnp9j/znSkkd44nBQPB
+a/i+ZFcCvXfdmkWwP8qM1Ka4QAMbyr7LSufBl8udwUNB2uP3wEq62V0JBK+vvklf
+yu+a2ulUDjx4Wr2BvrynJhA4G7kbJgqhYV5uYYR5AgMBAAGjgb4wgbswHQYDVR0O
+BBYEFNDt98zRxK0ZsirNLLinxb2gZGYbMIGLBgNVHSMEgYMwgYCAFNDt98zRxK0Z
+sirNLLinxb2gZGYboV2kWzBZMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1T
+dGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQD
+Ewlsb2NhbGhvc3SCCQDoke/bH/iZdTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEB
+CwUAA4ICAQBn0f/uLHhSAKSwwVHYtIIM55aE846+CrXduWE+F8JI8C4sMcrdCLaq
+2ig42u5iSeOsIT/ESVpxT7olsPA9WRUlg5Po3qf/HLmhge8iUimzNsqlSDVoCX/j
+kZeWTijsu+KYUQ9CAdel8ymlAEp9FCujrp4rbBuvlycZSASvq868cFI9Z47TeVaZ
+esqEu5Kr3r0dNtQjI304tc0kLa6fVY6/kN463xSC8XUEgsHoucJCrORrrjiti1eV
+ry2ZLBYs2aDN9x8qnOjtwuY369xFHGiZs9vcjJsLZhF/8eRbtDhaT9Y7IPG9WM7h
+ve4j7KN95mRXDCmSwPQ17W8rUhR3eid0IDjmgqpqw6cUq2muvCe2KllDd9dkEAB1
+o9CbUay927BitG9BW9kIrYHKz+9a2ivryPRF/pP2c0xg/4j079WeMn20o9X9gsjI
+wQ3vm72i5ElyoDoA7NU5f7Y3Olax6OzwnrlLrq9rgME/mU7LQ//3VLQk4W3tHBER
+tUsBaNOy1l0m6UBlp4FZBhbpqIBGKU7hBndMsdpaeWykByMtLd4bW0iJA3x4DN/M
+yXqThFsVBA7MV4kQmgbtIcAR3sfEqWJGrwLShcn4QpQiypgVeZIKyPMfw8hKn5+Q
+N87LRT3lSdUUjAzpLibAo98UmLPOP7Ec3zmbU7yFDbMfJqWzE7nDrA==
+-----END CERTIFICATE-----

+ 52 - 0
alg2docker/config/key.pem

@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQChhHQp0/B9vo7D
+DJo3aWP3rgr4YyjXuZ1eY+xkBo4gAxSGjByVZiVK3OyUW3kHKYKkvY/B4FznCfQl
+YMTbE9Y5iyLf1Ufb57gfdAVWSAXGwCMRuC1CWGZjJtVdM+ze5N2aj26cWo45P1Qr
+dARm3suL8pathPYf0UbDjmv55sVMAQdqvuhbpdJz9kbBfkVzrjXEvx5pMF7VCn3f
+Ur9tK8Y3QMEQK46l05n7DmPglpaYIamDUXHZ3IDzJ1/52AOAvhnyqpIPCskiH6za
+uSZ8B0Eqtp+oQWO9VmZAyvneSmytI+3AZP/Xj0+5G+mk0Vp9Wy0t42DSfiUot2sP
+XME7OoaLMqE45N67zfPfP9yNaXMP19QBSeFSVodhx3ehxlmvKYKVJxDGLsZXuHou
+fLEeCeVZvSxre09j9OIeMA7YyqkzGCOjXoMxFcEAwS9LGLci9V8pA1a70QLkpzX9
+J8PVRnA+hh2OSczKmpJBIMycarEET8kFMhM5APEIh0CBRpC/nrgigTJMc9SN48/W
+FTpFEdZv8lRUEadXptPpsDJapR7kTyDc/BcZ6fY/850pJHeOJwUDwWv4vmRXAr13
+3ZpFsD/KjNSmuEADG8q+y0rnwZfLncFDQdrj98BKutldCQSvr75JX8rvmtrpVA48
+eFq9gb68pyYQOBu5GyYKoWFebmGEeQIDAQABAoICAB8vofZJk8/TsWD71/MHCGRU
+WI3pJ4OvtTD6fjQ6B8sqjRYFi6dVF7JCwfNLTi0R2MXSTCWVGpsJkvh8nMXoKJ7n
+vI4Xck6FiUmZb0Zfla3wf1T2iNaclvhiESRz2DKZYihrtWG/ImLvVWMmfYsXTZnx
+9yH94D/4en9byoFwC3xHXpU/00GO3PnC/ZpytRpG8t7QQCDzU/wykGdEZO3BW/5j
+JGPo5RwjpUGSX7qHmQA6z64pVaBJMSTB34gwK0z6Z6wxPw5keL17/DYnNcUQ6YZD
+yMQGDCoMuqWcC27eU+mCXG+fkL6gTzZgq1ZFjgFST4DivFkoPiiEccl/kVfMTxnW
+3c+aYPscyiX7LrCuG0eCUDVtxSkiOWVaUEp8IOsFSM22lz8kgK7jRV8faOpt0U1n
+p7WdLnnDdSFVs3PKkyg3JOWfol7YhorHpKBZctn1+Y++/0oFHRc1Hv95T0pRT2T7
++NhokLrV5SnlpMVO5aBIdCetNjhTHnatAo15FgF0EJx1nxRLzVauIAEX/GwN/LFR
+JTkxQS9idwj8OCce5A0aFgcBSbIbeu30e+sSd2a7A/+fptv0e9AsBzQdNwm+VMWh
+cO5ctXwi/TALL9G9D2Ho2hu53awH7Pqqb4zgdGLNIMn6BTAKonNqm+3yPR/yDCdn
+kVcIKp/2GcCvbNUOSFgBAoIBAQDOoQeS89OKMBQ6JmxXv4QUyzhhZQ1vOwPsBjMN
+Ob6zIzmKouZ4pw2n8UWKYdd6Kg1MqVsdyoQkdrDhqtpAOqC5Nf7Lx374RXiGq/DF
+LoT4PT9oIO/HJaj+3w4kCsIOzpg2y6kH9l/5Ad4Rgq1R0eJArjEBBRQ2vxtTijhn
+dlUeiIy/iob//EYZ3kd5OmncgPPdob808KyW6TXR+3rPBIb+T91V5nD7cLtEg4+m
+5RLnM2MeqSM8hEwJbDrWE386BkRDIuTZltx2NjCoyx21A9h7WpJLrDeTtV7XXmJj
+H/TH7+eRHDPUxE5Wy3fsc4ztFIWmrsMZ0j7+KJXOXDZU+bmBAoIBAQDIHA+m9LWS
+MbYeA+YkHLaUEG/vBzx1oZZOkDUjTs+aghgX0UCz5n5h23GHXn4PT+3xR0bO+Kfw
+KfG4TYxl9PgLZKpEexGqyVqLNJMNRKdb8Ya1AO8Hk2y7rRBt9Zzvp/8DqQq8iMhy
+3xa7pC8x5fzVMg0X4+lIyHzulDTPVXA+rWjhQ9+dAGiS0jcXYFsuCSXcUuIX8QqO
+9RzCZO7TkhccuDbP5Efhl+Y1Rd+aP/TJiPxzNYKIt8U4sVTkqnFiTqVwXTy33HrB
+tveR5HqvsjIBv5LRfvN9ZD+dhJ9ymmrXjIdhNeoGXybiPumlc/366L0thmv1wGQM
+Ct+oyb/ZCxb5AoIBAD2xKGDAucUFa9ScyjY8sQPAVRoHIMa3+QjMJQvq1LwJZVG2
+J1eYkOQ2jguuALaxvXzJB7JSV7fHVDJcX4NTvc7uhx9ydb9Mhpq21VEZ/YtcRgUh
+6FUuMlG+aStrptri5eFeVx5QegEPhKz3tiW6o3GqUGk8ifd6gX8kwohpdSqDnHpH
+stzVBl0M14wjW54BRk5Cz6LPxMfd4zrJebY7ZM6jK7NM9SitVMw093vd4xHybsKl
+vyZ/XXBmxv5Jne/GFTVyQp+QgkwtC8QpuLnl5wmVVh6P0ZwhR6rHvFpaHSxgvWO+
+5t68qaMSZsDLEyVpvimw/gFEm5gop+mP8IzYboECggEAUThSgglAyR20NWqPtoiQ
+8nfZdsMRHw1+3FRbvQD38lZrQP+Fq38aPM28vpzzvXNLuGqdqtPyFeBwY8XV/mv3
+YWhyU2lAOnmPboDgTrpW01KswjqmygRkVahgysCt+ffN4cZyvSMneLhjmbWCElau
+0DmIZvHydcAqNQuig6TB8i6gKw5JbIgF6dG42pwZZg4Ad/SMH/uNtYh3K8+bv/PD
+vbxnQun84uusXGMH5quex036aSiG2sb8HY6cTlN/wc76BylQw6+Rg56nzWdXBIjn
+Ex6WkMFeQr61s1FpBjL2upYnPKWNXxivx5SRcL4rj/N3FUtB9nUoJlsjfHamj4mY
+wQKCAQBUNJ5kjmnBIKZtR1uay3OpuZlECcoOKjEWaOyh57zN4upA6ll9J0rfAvSf
+H6oc6/LFrANDHkt9chh4fhGr8JmGXdhwlWdDBucGqPrbex3IDKWQmUkzYwqyJibg
+q1al9SyxCHsRxxowV2dk9Rata3r0GTILDiY3i1oiKp4hSzTyGKlc1i9n1VlIktcv
+SR4kVQsG5hnABOX1L5rzhKdxLR/3jexBr3zxPh2zejEqBQ0vC1QsYxo0nFz/vnvc
+6d6joR3RVaNZmiShlP9oZylturV0/CQFUJtQm6/6fOFPNK0+HyM+7KuykQhsf3FZ
+WF2mO3wmqmf6zMcK2vqAyogEAjhV
+-----END PRIVATE KEY-----

二進制
alg2docker/hello.alg


+ 42 - 0
alg2docker/run.sh

@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# Use available command to determine if port is open
+is_port_open() {
+  local port=$1
+
+  if command -v netstat >/dev/null 2>&1; then
+    netstat -tuln | grep -q ":$port "
+    return $?
+  elif command -v lsof >/dev/null 2>&1; then
+    lsof -i :$port | grep -q LISTEN
+    return $?
+  elif command -v sockstat >/dev/null 2>&1; then
+    sockstat -4l | grep -q ":$port "
+    return $?
+  else
+    echo "Error: Could not find netstat, lsof, or sockstat. Unable to check if port is open."
+    exit 1
+  fi
+}
+
+port8080=${1:-yes}
+
+if [[ "$port8080" == "yes" ]]; then
+  if is_port_open 8080; then
+    echo "Error: Port 8080 is already in use!"
+    exit 1
+  fi
+  echo
+  echo "It's possible to visit http://localhost:8080/ after the docker image has launched"
+  echo
+  docker run --mount type=bind,source="$PWD/config",destination=/etc/algernon,readonly --publish 8080:80 --rm hello
+else
+  if is_port_open 80 || is_port_open 443; then
+    echo "Error: Either Port 80 or 443 (or both) are already in use!"
+    exit 1
+  fi
+  echo
+  echo "It's possible to visit http://localhost/ and https://localhost/ after the docker image has launched, if docker has the right permissions"
+  echo
+  docker run --mount type=bind,source="$PWD/config",destination=/etc/algernon,readonly --publish 80:80 --publish 443:443 --rm hello
+fi

+ 143 - 0
algernon.1

@@ -0,0 +1,143 @@
+.\"             -*-Nroff-*-
+.\"
+.TH "algernon" 1 "16 Oct 2023" "" ""
+.SH NAME
+algernon \- web server
+.sp
+.SH SYNOPSIS
+.B algernon
+[\fBflags\fR]
+[\fBfile or directory\fR]
+[\fBhost\fR][\fB:port\fR]
+.sp
+.SH DESCRIPTION
+Serve the given file or directory, with an optional \fB:port\fP and \fBhostname\fP.
+.sp
+.SH OPTIONS
+.sp
+.TP
+.B \-a or \-\-autorefresh
+Inject JavaScript that refreshes the served web pages when they receive
+server-sent events (SSE), when a file or directory has changed.
+.TP
+.B \-v or \-\-version
+Display the current version number.
+.TP
+.B \-h or \-\-help
+Display usage information. Includes a few flags that are not listed here.
+.TP
+.B \-\-noheaders
+Don't use the security-related HTTP headers.
+.TP
+.B \-\-stricter
+Stricter HTTP headers (same origin policy).
+.TP
+.B \-n or \-\-nobanner
+Don't display a colorful banner at start.
+.TP
+.B \-\-cert=FILENAME
+Provide a TLS certificate, for using HTTPS.
+.TP
+.B \-\-key=FILENAME
+Provide a TLS key, for using HTTPS.
+.TP
+.B \-\-boltdb=FILENAME
+Provide a Bolt database filename, instead of using \fB/tmp/algernon.db\fP.
+.TP
+.B \-t or \-\-httponly
+Only serve regular HTTP.
+.TP
+.B \-\-http2only
+Only serve HTTP/2, without HTTPS.
+.TP
+.B \-u
+Serve QUIC aka HTTP/3.
+.TP
+.B \-\-limit=N
+Limit clients to N request per second (the default is 10).
+.TP
+.B \-\-nodb
+Don't use a database backend. Some Lua functions might not work.
+.TP
+.B \-\-timeout=N
+Timeout when serving files, in seconds.
+.TP
+.B \-\-largesize=N
+Threshold for not reading static files into memory, in bytes.
+.TP
+.B \-\-lua
+Don't serve anything, just present an interactive Lua prompt (REPL).
+.TP
+.B \-\-server
+Disable debug + interactive mode. Is unrelated to if anything is served or not.
+.TP
+.B \-q or \-\-quiet
+Don't output anything to stdout or stderr.
+.TP
+.B \-\-servername=NAME
+Custom HTTP header value for the \fBServer\fP field.
+.TP
+.B \-o or \-\-open=COMMAND
+Open the served URL with \fBxdg-open\fP, or with the given application.
+.TP
+.B \-z or \-\-quit
+Quit after the first request has been served.
+.TP
+.B \-m
+View the given Markdown file in the browser.
+Quit after the file has been served once.
+This is equivalent to \fB\-q \-o \-z\fP.
+.TP
+.B \-c or \-\-statcache
+Speed up responses by caching \fBos.Stat\fP.
+Only use if served files will never be removed!
+.TP
+.B \-\-accesslog=FILENAME
+Filename for where to log requests in the Combined Log Format (CLF).
+.TP
+.B \-\-ncsa=FILENAME
+Filename for where to log requests in the Common Log Format (NCSA).
+.TP
+.B \-\-domain
+Serve files from the subdirectory with the same name as the requested domain,
+for instance \fB/srv/algernon/mydomain.com\fP and \fB/srv/algernon/otherweb.com\fP,
+depending on if HTTP requests are using \fBhttps://mydomain.com\fP or
+\fBhttps://otherweb.com\fP.
+.TP
+.B \-x or \-\-simple
+Simple mode. Serve the current directory over regular HTTP, disable debug mode,
+interactive mode, request limits and all features that require a database.
+.TP
+.PP
+.SH "ENV"
+.sp
+The \fBNO_COLOR\fP environment variable can be set to 1 to turn off all colors.
+.sp
+.SH "WHY"
+.sp
+Web development with few dependencies and a decent caching system.
+.SH "EXAMPLE USAGE"
+.sp
+.TP
+For auto-refreshing a webpage while developing:
+\fBalgernon --dev --httponly --debug --autorefresh --bolt --server . :4000\fP
+.TP
+Serve /srv/mydomain.com and /srv/otherweb.com over HTTP and HTTPS + HTTP/2:
+\fBalgernon -c --domain --server --cachesize 67108864 --prod /srv\fP
+.TP
+Serve the current dir over QUIC, port 7000, no banner:
+\fBalgernon -s -u -n . :7000\fP
+.TP
+Serve the current directory over HTTP, port 3000. No limits, cache, permissions
+or database connections:
+\fBalgernon -x\fP
+.SH "SEE ALSO"
+.BR caddy (1)
+.BR nginx (1)
+.SH BUGS
+This man page is a work in progress.
+.SH VERSION
+1.15.4
+.SH AUTHOR
+.B algernon
+was written by Alexander F. Rødseth <xyproto@archlinux.org>

+ 73 - 0
api_test.go

@@ -0,0 +1,73 @@
+// API version number check
+package main
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/xyproto/algernon/engine"
+	"github.com/xyproto/permissionbolt/v2"
+	permissions "github.com/xyproto/permissions2/v2"
+	"github.com/xyproto/pinterface"
+	"github.com/xyproto/simplebolt"
+	"github.com/xyproto/simpleredis/v2"
+	//"github.com/xyproto/permissionsql"
+	//"github.com/xyproto/pstore"
+	//"github.com/xyproto/simplehstore"
+	//"github.com/xyproto/simplemaria"
+)
+
+// VersionInfo helps to keep track of package names and versions
+type VersionInfo struct {
+	name    string
+	current float64
+	target  float64
+}
+
+// New takes the name of the go package, the current and the desired version
+func New(name string, current, target float64) *VersionInfo {
+	return &VersionInfo{name, current, target}
+}
+
+// Check compares the current and target version
+func (v *VersionInfo) Check() error {
+	if v.current != v.target {
+		return fmt.Errorf("is %.1f, needs version %.1f", v.current, v.target)
+	}
+	return nil
+}
+
+func TestAPI(t *testing.T) {
+	if err := New("simplebolt", simplebolt.Version, 5.1).Check(); err != nil {
+		t.Error(err)
+	}
+	if err := New("permissionbolt", permissionbolt.Version, 2.6).Check(); err != nil {
+		t.Error(err)
+	}
+	if err := New("simpleredis", simpleredis.Version, 2.6).Check(); err != nil {
+		t.Error(err)
+	}
+	if err := New("permissions2", permissions.Version, 2.6).Check(); err != nil {
+		t.Error(err)
+	}
+	if err := New("pinterface", pinterface.Version, 5.3).Check(); err != nil {
+		t.Error(err)
+	}
+	if err := New("engine", engine.Version, 2.0).Check(); err != nil {
+		t.Error(err)
+	}
+
+	// These adds many dependencies when testing
+	// if err := New("simplemaria", simplemaria.Version, 3.0).Check(); err != nil {
+	// 	t.Error(err)
+	// }
+	// if err := New("permissionsql", permissionsql.Version, 2.0).Check(); err != nil {
+	// 	t.Error(err)
+	// }
+	// if err := New("simplehstore", simplehstore.Version, 2.3).Check(); err != nil {
+	// 	t.Error(err)
+	// }
+	// if err := New("pstore", pstore.Version, 3.1).Check(); err != nil {
+	// 	t.Error(err)
+	// }
+}

二進制
apps/64-bit_linux/withplugins.alg


+ 10 - 0
apps/README.md

@@ -0,0 +1,10 @@
+Self-contained sample applications for Algernon
+===============================================
+
+Give an .alg file as the first argument to algernon to run it.
+
+* `first.alg` just displays a simple message.
+* `64-bit_linux/withplugins.alg` runs a Go plugin that is compiled for 64-bit Linux.
+* `single.alg` is an example of defining all handlers in a single Lua file.
+
+(`.alg` files are `.zip` files. It's the equivalent of `.war` files for Java.)

二進制
apps/first.alg


二進制
apps/single.alg


+ 14 - 0
bench/stress_client.sh

@@ -0,0 +1,14 @@
+#!/bin/sh
+time for url in \
+  'http://localhost:7531/' \
+  'http://localhost:7531/TODO.md' \
+  'http://localhost:7531/samples/sass/' \
+  'http://localhost:7531/samples/hellolua/' \
+  'http://localhost:7531/samples/greetings/' \
+  'http://localhost:7531/samples/pongo2/' \
+  ;
+do
+  echo "$url"
+  #ab -n 5000 -c 100 -s 3600 -H 'Accept-Encoding: gzip' "$url"
+  ab -n 5000 -c 100 -s 15 -H 'Accept-Encoding: gzip' "$url"
+done

+ 8 - 0
bench/stress_server.sh

@@ -0,0 +1,8 @@
+#!/bin/sh
+
+go build -mod=vendor -tags=trace && ./algernon -n -t -c --cachesize=10000000 --nolimit --cpuprofile=algernon.prof . :7531
+
+# The "-race" flag gets in the way of the CPU profiling, unfortunately
+#go build -mod=vendor -race && ./algernon -n -t -c --cachesize=10000000 --nolimit . :7531
+
+echo 'Now run: "go tool pprof algernon.prof" and try "top50"'

+ 59 - 0
cachemode/cachemode.go

@@ -0,0 +1,59 @@
+// Package cachemode provides ways to deal with different cache modes
+package cachemode
+
+// Setting represents a cache mode setting
+type Setting int
+
+// Possible cache modes
+const (
+	Unset       = iota // cache mode has not been set
+	On                 // cache everything
+	Development        // cache everything, except Amber, Lua, GCSS and Markdown
+	Production         // cache everything, except Amber and Lua
+	Images             // cache images (png, jpg, gif, svg)
+	Small              // only cache small files (<=64KB) // 64 * 1024
+	Off                // cache nothing
+	Default     = On
+)
+
+// Names is a map of cache mode setting string representations
+var Names = map[Setting]string{
+	Unset:       "unset",
+	On:          "On",
+	Development: "Development",
+	Production:  "Production",
+	Images:      "Images",
+	Small:       "Small",
+	Off:         "Off",
+}
+
+// New creates a CacheModeSetting based on a variety of string options, like "on" and "off".
+func New(mode string) Setting {
+	switch mode {
+	case "everything", "all", "on", "1", "enabled", "yes", "enable": // Cache everything.
+		return On
+	case "production", "prod": // Cache everything, except: Amber and Lua.
+		return Production
+	case "images", "image": // Cache images (png, jpg, gif, svg).
+		return Images
+	case "small", "64k", "64KB": // Cache only small files (<=64KB), but not Amber and Lua
+		return Small
+	case "off", "disabled", "0", "no", "disable": // Disable caching entirely.
+		return Off
+	case "dev", "default", "unset": // Cache everything, except: Amber, Lua, GCSS and Markdown.
+		fallthrough
+	default:
+		return Default
+	}
+}
+
+// String returns the name of the cache mode setting, if set
+func (cms Setting) String() string {
+	for k, v := range Names {
+		if k == cms {
+			return v
+		}
+	}
+	// Could not find the name
+	return Names[Unset]
+}

+ 1 - 0
cert.pem

@@ -0,0 +1 @@
+keys/cert.pem

+ 7 - 0
config.yaml

@@ -0,0 +1,7 @@
+mysql:
+  dbName: thirdparty
+  address: 192.168.3.217:4000
+  userName: root
+  passWord: =PDT49#80Z!RVv52_z
+  maxOpenConns: 5
+  maxIdleConns: 5

+ 10 - 0
config/config.go

@@ -0,0 +1,10 @@
+package config
+
+import (
+	"github.com/gogf/gf/v2/frame/g"
+	"github.com/gogf/gf/v2/os/gcfg"
+)
+
+func init() {
+	g.Cfg().GetAdapter().(*gcfg.AdapterFile).SetFileName("./config.yaml") //设置配置文件
+}

+ 26 - 0
console/console.go

@@ -0,0 +1,26 @@
+// Package console provides functions for disabling and disabling output
+package console
+
+import (
+	"os"
+)
+
+// Output is for enabling or disabling output to stdout
+type Output struct {
+	stdout  *os.File
+	enabled bool
+}
+
+// Disable output to stdout. Will close stdout and stderr.
+func (o *Output) Disable() {
+	os.Stdout.Close()
+	os.Stderr.Close()
+	o.stdout, _ = os.OpenFile(os.DevNull, os.O_WRONLY, 0o644)
+	o.enabled = false
+}
+
+// Enable output to stdout, if stdout has not been closed
+func (o *Output) Enable() {
+	o.stdout = os.Stdout
+	o.enabled = true
+}

二進制
default.pgo


+ 11 - 0
desktop/algernon.desktop

@@ -0,0 +1,11 @@
+[Desktop Entry]
+Encoding=UTF-8
+Type=Application
+Name=Algernon
+GenericName=Web Server
+Comment=QUIC Web Server with Lua, Markdown and Pongo2 support
+Exec=algernon -u -o --theme=material /usr/share/doc
+Icon=markdown
+Terminal=false
+StartupNotify=false
+Categories=Application;

+ 13 - 0
desktop/algernon_md.desktop

@@ -0,0 +1,13 @@
+[Desktop Entry]
+Encoding=UTF-8
+Type=Application
+Name=Algernon
+GenericName=Web Server
+Comment=QUIC Web Server with Lua, Markdown and Pongo2 support
+Exec=algernon -m
+Icon=markdown
+Terminal=false
+StartupNotify=false
+Categories=Application;
+MimeType=text/markdown;
+NoDisplay=true

二進制
desktop/markdown.png


+ 2 - 0
desktop/mdview

@@ -0,0 +1,2 @@
+#!/bin/sh
+algernon -m "$@"

+ 13 - 0
docker/README.md

@@ -0,0 +1,13 @@
+# Docker
+
+These files can be used for running Algernon as a docker container.
+
+build_prod.sh and run_prod.sh is for building and running the production image for Algernon. This image will serve both HTTP and HTTPS+HTTP/2.
+Please adjust the Dockerfile for your needs. In particular, the caching is too aggressive if you have a dynamic web application.
+You might want to drop the "-c" flag unless you are only serving static files.
+
+build_dev.sh and run_dev.sh is for building and running the development image. It can also take an argument which is either a directory to serve or an .alg file to serve.
+
+Make sure ports are open in your firewall if you are serving anything remotely.
+
+Adjust the configuration and scripts to your needs.

+ 3 - 0
docker/build_dev.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+cd ..
+docker build --no-cache -t algernon_dev -f docker/dev/Dockerfile .

+ 3 - 0
docker/build_interactive.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+cd ..
+docker build --no-cache -t algernon_interactive -f docker/interactive/Dockerfile .

+ 3 - 0
docker/build_lua.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+cd ..
+docker build --no-cache -t algernon_lua -f docker/lua/Dockerfile .

+ 3 - 0
docker/build_prod.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+cd ..
+docker build --no-cache -t algernon_prod -f docker/prod/Dockerfile .

+ 34 - 0
docker/config/cert.pem

@@ -0,0 +1,34 @@
+-----BEGIN CERTIFICATE-----
+MIIF9DCCA9ygAwIBAgIJAOiR79sf+Jl1MA0GCSqGSIb3DQEBCwUAMFkxCzAJBgNV
+BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
+aWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0xNTAzMDUwOTQw
+NDBaFw00MjA3MjAwOTQwNDBaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21l
+LVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNV
+BAMTCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKGE
+dCnT8H2+jsMMmjdpY/euCvhjKNe5nV5j7GQGjiADFIaMHJVmJUrc7JRbeQcpgqS9
+j8HgXOcJ9CVgxNsT1jmLIt/VR9vnuB90BVZIBcbAIxG4LUJYZmMm1V0z7N7k3ZqP
+bpxajjk/VCt0BGbey4vylq2E9h/RRsOOa/nmxUwBB2q+6Ful0nP2RsF+RXOuNcS/
+HmkwXtUKfd9Sv20rxjdAwRArjqXTmfsOY+CWlpghqYNRcdncgPMnX/nYA4C+GfKq
+kg8KySIfrNq5JnwHQSq2n6hBY71WZkDK+d5KbK0j7cBk/9ePT7kb6aTRWn1bLS3j
+YNJ+JSi3aw9cwTs6hosyoTjk3rvN898/3I1pcw/X1AFJ4VJWh2HHd6HGWa8pgpUn
+EMYuxle4ei58sR4J5Vm9LGt7T2P04h4wDtjKqTMYI6NegzEVwQDBL0sYtyL1XykD
+VrvRAuSnNf0nw9VGcD6GHY5JzMqakkEgzJxqsQRPyQUyEzkA8QiHQIFGkL+euCKB
+Mkxz1I3jz9YVOkUR1m/yVFQRp1em0+mwMlqlHuRPINz8Fxnp9j/znSkkd44nBQPB
+a/i+ZFcCvXfdmkWwP8qM1Ka4QAMbyr7LSufBl8udwUNB2uP3wEq62V0JBK+vvklf
+yu+a2ulUDjx4Wr2BvrynJhA4G7kbJgqhYV5uYYR5AgMBAAGjgb4wgbswHQYDVR0O
+BBYEFNDt98zRxK0ZsirNLLinxb2gZGYbMIGLBgNVHSMEgYMwgYCAFNDt98zRxK0Z
+sirNLLinxb2gZGYboV2kWzBZMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1T
+dGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQD
+Ewlsb2NhbGhvc3SCCQDoke/bH/iZdTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEB
+CwUAA4ICAQBn0f/uLHhSAKSwwVHYtIIM55aE846+CrXduWE+F8JI8C4sMcrdCLaq
+2ig42u5iSeOsIT/ESVpxT7olsPA9WRUlg5Po3qf/HLmhge8iUimzNsqlSDVoCX/j
+kZeWTijsu+KYUQ9CAdel8ymlAEp9FCujrp4rbBuvlycZSASvq868cFI9Z47TeVaZ
+esqEu5Kr3r0dNtQjI304tc0kLa6fVY6/kN463xSC8XUEgsHoucJCrORrrjiti1eV
+ry2ZLBYs2aDN9x8qnOjtwuY369xFHGiZs9vcjJsLZhF/8eRbtDhaT9Y7IPG9WM7h
+ve4j7KN95mRXDCmSwPQ17W8rUhR3eid0IDjmgqpqw6cUq2muvCe2KllDd9dkEAB1
+o9CbUay927BitG9BW9kIrYHKz+9a2ivryPRF/pP2c0xg/4j079WeMn20o9X9gsjI
+wQ3vm72i5ElyoDoA7NU5f7Y3Olax6OzwnrlLrq9rgME/mU7LQ//3VLQk4W3tHBER
+tUsBaNOy1l0m6UBlp4FZBhbpqIBGKU7hBndMsdpaeWykByMtLd4bW0iJA3x4DN/M
+yXqThFsVBA7MV4kQmgbtIcAR3sfEqWJGrwLShcn4QpQiypgVeZIKyPMfw8hKn5+Q
+N87LRT3lSdUUjAzpLibAo98UmLPOP7Ec3zmbU7yFDbMfJqWzE7nDrA==
+-----END CERTIFICATE-----

+ 52 - 0
docker/config/key.pem

@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQChhHQp0/B9vo7D
+DJo3aWP3rgr4YyjXuZ1eY+xkBo4gAxSGjByVZiVK3OyUW3kHKYKkvY/B4FznCfQl
+YMTbE9Y5iyLf1Ufb57gfdAVWSAXGwCMRuC1CWGZjJtVdM+ze5N2aj26cWo45P1Qr
+dARm3suL8pathPYf0UbDjmv55sVMAQdqvuhbpdJz9kbBfkVzrjXEvx5pMF7VCn3f
+Ur9tK8Y3QMEQK46l05n7DmPglpaYIamDUXHZ3IDzJ1/52AOAvhnyqpIPCskiH6za
+uSZ8B0Eqtp+oQWO9VmZAyvneSmytI+3AZP/Xj0+5G+mk0Vp9Wy0t42DSfiUot2sP
+XME7OoaLMqE45N67zfPfP9yNaXMP19QBSeFSVodhx3ehxlmvKYKVJxDGLsZXuHou
+fLEeCeVZvSxre09j9OIeMA7YyqkzGCOjXoMxFcEAwS9LGLci9V8pA1a70QLkpzX9
+J8PVRnA+hh2OSczKmpJBIMycarEET8kFMhM5APEIh0CBRpC/nrgigTJMc9SN48/W
+FTpFEdZv8lRUEadXptPpsDJapR7kTyDc/BcZ6fY/850pJHeOJwUDwWv4vmRXAr13
+3ZpFsD/KjNSmuEADG8q+y0rnwZfLncFDQdrj98BKutldCQSvr75JX8rvmtrpVA48
+eFq9gb68pyYQOBu5GyYKoWFebmGEeQIDAQABAoICAB8vofZJk8/TsWD71/MHCGRU
+WI3pJ4OvtTD6fjQ6B8sqjRYFi6dVF7JCwfNLTi0R2MXSTCWVGpsJkvh8nMXoKJ7n
+vI4Xck6FiUmZb0Zfla3wf1T2iNaclvhiESRz2DKZYihrtWG/ImLvVWMmfYsXTZnx
+9yH94D/4en9byoFwC3xHXpU/00GO3PnC/ZpytRpG8t7QQCDzU/wykGdEZO3BW/5j
+JGPo5RwjpUGSX7qHmQA6z64pVaBJMSTB34gwK0z6Z6wxPw5keL17/DYnNcUQ6YZD
+yMQGDCoMuqWcC27eU+mCXG+fkL6gTzZgq1ZFjgFST4DivFkoPiiEccl/kVfMTxnW
+3c+aYPscyiX7LrCuG0eCUDVtxSkiOWVaUEp8IOsFSM22lz8kgK7jRV8faOpt0U1n
+p7WdLnnDdSFVs3PKkyg3JOWfol7YhorHpKBZctn1+Y++/0oFHRc1Hv95T0pRT2T7
++NhokLrV5SnlpMVO5aBIdCetNjhTHnatAo15FgF0EJx1nxRLzVauIAEX/GwN/LFR
+JTkxQS9idwj8OCce5A0aFgcBSbIbeu30e+sSd2a7A/+fptv0e9AsBzQdNwm+VMWh
+cO5ctXwi/TALL9G9D2Ho2hu53awH7Pqqb4zgdGLNIMn6BTAKonNqm+3yPR/yDCdn
+kVcIKp/2GcCvbNUOSFgBAoIBAQDOoQeS89OKMBQ6JmxXv4QUyzhhZQ1vOwPsBjMN
+Ob6zIzmKouZ4pw2n8UWKYdd6Kg1MqVsdyoQkdrDhqtpAOqC5Nf7Lx374RXiGq/DF
+LoT4PT9oIO/HJaj+3w4kCsIOzpg2y6kH9l/5Ad4Rgq1R0eJArjEBBRQ2vxtTijhn
+dlUeiIy/iob//EYZ3kd5OmncgPPdob808KyW6TXR+3rPBIb+T91V5nD7cLtEg4+m
+5RLnM2MeqSM8hEwJbDrWE386BkRDIuTZltx2NjCoyx21A9h7WpJLrDeTtV7XXmJj
+H/TH7+eRHDPUxE5Wy3fsc4ztFIWmrsMZ0j7+KJXOXDZU+bmBAoIBAQDIHA+m9LWS
+MbYeA+YkHLaUEG/vBzx1oZZOkDUjTs+aghgX0UCz5n5h23GHXn4PT+3xR0bO+Kfw
+KfG4TYxl9PgLZKpEexGqyVqLNJMNRKdb8Ya1AO8Hk2y7rRBt9Zzvp/8DqQq8iMhy
+3xa7pC8x5fzVMg0X4+lIyHzulDTPVXA+rWjhQ9+dAGiS0jcXYFsuCSXcUuIX8QqO
+9RzCZO7TkhccuDbP5Efhl+Y1Rd+aP/TJiPxzNYKIt8U4sVTkqnFiTqVwXTy33HrB
+tveR5HqvsjIBv5LRfvN9ZD+dhJ9ymmrXjIdhNeoGXybiPumlc/366L0thmv1wGQM
+Ct+oyb/ZCxb5AoIBAD2xKGDAucUFa9ScyjY8sQPAVRoHIMa3+QjMJQvq1LwJZVG2
+J1eYkOQ2jguuALaxvXzJB7JSV7fHVDJcX4NTvc7uhx9ydb9Mhpq21VEZ/YtcRgUh
+6FUuMlG+aStrptri5eFeVx5QegEPhKz3tiW6o3GqUGk8ifd6gX8kwohpdSqDnHpH
+stzVBl0M14wjW54BRk5Cz6LPxMfd4zrJebY7ZM6jK7NM9SitVMw093vd4xHybsKl
+vyZ/XXBmxv5Jne/GFTVyQp+QgkwtC8QpuLnl5wmVVh6P0ZwhR6rHvFpaHSxgvWO+
+5t68qaMSZsDLEyVpvimw/gFEm5gop+mP8IzYboECggEAUThSgglAyR20NWqPtoiQ
+8nfZdsMRHw1+3FRbvQD38lZrQP+Fq38aPM28vpzzvXNLuGqdqtPyFeBwY8XV/mv3
+YWhyU2lAOnmPboDgTrpW01KswjqmygRkVahgysCt+ffN4cZyvSMneLhjmbWCElau
+0DmIZvHydcAqNQuig6TB8i6gKw5JbIgF6dG42pwZZg4Ad/SMH/uNtYh3K8+bv/PD
+vbxnQun84uusXGMH5quex036aSiG2sb8HY6cTlN/wc76BylQw6+Rg56nzWdXBIjn
+Ex6WkMFeQr61s1FpBjL2upYnPKWNXxivx5SRcL4rj/N3FUtB9nUoJlsjfHamj4mY
+wQKCAQBUNJ5kjmnBIKZtR1uay3OpuZlECcoOKjEWaOyh57zN4upA6ll9J0rfAvSf
+H6oc6/LFrANDHkt9chh4fhGr8JmGXdhwlWdDBucGqPrbex3IDKWQmUkzYwqyJibg
+q1al9SyxCHsRxxowV2dk9Rata3r0GTILDiY3i1oiKp4hSzTyGKlc1i9n1VlIktcv
+SR4kVQsG5hnABOX1L5rzhKdxLR/3jexBr3zxPh2zejEqBQ0vC1QsYxo0nFz/vnvc
+6d6joR3RVaNZmiShlP9oZylturV0/CQFUJtQm6/6fOFPNK0+HyM+7KuykQhsf3FZ
+WF2mO3wmqmf6zMcK2vqAyogEAjhV
+-----END PRIVATE KEY-----

+ 56 - 0
docker/dev/Dockerfile

@@ -0,0 +1,56 @@
+# Dockerfile for making Algernon serve HTTP on port 3000, in development mode
+FROM golang:alpine as gobuilder
+MAINTAINER Alexander F. Rødseth <xyproto@archlinux.org>
+
+# Prepare the needed files
+COPY . /algernon
+WORKDIR /algernon
+
+# Build Algernon
+RUN GOOS=linux \
+    GOARCH=amd64 \
+    CGO_ENABLED=0 \
+    go build \
+      -mod=vendor \
+      -trimpath \
+      -a \
+      -installsuffix cgo \
+      -ldflags="-w -s" \
+      -o /bin/algernon
+
+RUN apk add upx && upx /bin/algernon
+
+# Start from scratch, only copy in the Algernon executable
+FROM scratch
+COPY --from=gobuilder /bin/algernon /bin/algernon
+
+# Prepare directories
+COPY --from=gobuilder /tmp /tmp
+VOLUME /srv/algernon
+VOLUME /etc/algernon
+WORKDIR /srv/algernon
+
+# Expose port 3000 for HTTP
+EXPOSE 3000
+
+# -c assumes no files will be added or removed, for a slight increase in speed
+# --domain makes Algernon look for a folder named the same as the domain it serves
+# --server turns off interactive and debug mode
+# --cachesize sets a file cache size, in bytes
+# --prod makes Algernon serve HTTP on port 80 and HTTPS+HTTP/2 on port 443
+# --cert and --key is for setting the HTTPS certificate
+#
+# Other parameters that might be of interest is "--addr", ":3000" together with
+# "--server" but without "--prod" for serving only HTTP on port 3000
+#
+# "--log", "/var/log/algernon.log" can be used for logging errors
+#
+# "--dev" enables debug mode, uses regular HTTP, enables Bolt and sets the cache mode to "dev".
+# "--autorefresh" enables the autorefresh feature where pages are refreshed upon file save.
+#
+# The final parameter is the directory to serve, for instance /srv/algernon
+#
+# For "--domain" to work, there should be at least a /srv/algernon/localhost directory.
+#
+ENTRYPOINT ["/bin/algernon", "--domain", "--server", "--dev", "--autorefresh", "/srv/algernon", ":3000"]
+CMD ["/bin/algernon"]

+ 43 - 0
docker/interactive/Dockerfile

@@ -0,0 +1,43 @@
+# Dockerfile for making Algernon serve HTTP on port 4000, in development mode
+FROM golang:alpine as gobuilder
+MAINTAINER Alexander F. Rødseth <xyproto@archlinux.org>
+
+# Prepare the needed files
+COPY . /algernon
+WORKDIR /algernon
+
+# Build Algernon
+RUN GOOS=linux \
+    GOARCH=amd64 \
+    CGO_ENABLED=0 \
+    go build \
+      -mod=vendor \
+      -a \
+      -trimpath \
+      -installsuffix cgo \
+      -ldflags="-w -s" \
+      -o /bin/algernon
+
+RUN apk add upx && upx /bin/algernon
+
+# Start from scratch, only copy in the Algernon executable
+FROM scratch
+COPY --from=gobuilder /bin/algernon /bin/algernon
+
+# Prepare directories
+COPY --from=gobuilder /tmp /tmp
+VOLUME /srv/algernon
+VOLUME /etc/algernon
+WORKDIR /srv/algernon
+
+# Expose port 4000 for HTTP
+EXPOSE 4000
+
+# "--domain" makes Algernon look for a folder named the same as the domain it serves
+# "--dev" enables debug mode, uses regular HTTP, enables Bolt and sets the cache mode to "dev".
+# "--autorefresh" enables the autorefresh feature where pages are refreshed upon file save.
+# "--log", "/var/log/algernon.log" can be used for logging errors
+#
+# The final parameter is the directory or file to serve, for instance /srv/algernon
+ENTRYPOINT ["/bin/algernon", "--domain", "--dev", "--autorefresh", "--addr", "/srv/algernon", ":4000"]
+CMD ["/bin/algernon"]

+ 29 - 0
docker/lua/Dockerfile

@@ -0,0 +1,29 @@
+# Dockerfile for only using the Lua interpreter in Algernon
+FROM golang:alpine as gobuilder
+MAINTAINER Alexander F. Rødseth <xyproto@archlinux.org>
+
+# Prepare the needed files
+COPY . /algernon
+WORKDIR /algernon
+
+# Build Algernon
+RUN GOOS=linux \
+    GOARCH=amd64 \
+    CGO_ENABLED=0 \
+    go build \
+      -mod=vendor \
+      -a \
+      -trimpath \
+      -installsuffix cgo \
+      -ldflags="-w -s" \
+      -o /bin/algernon
+
+RUN apk add upx && upx /bin/algernon
+
+# Start from scratch, only copy in the Algernon executable
+FROM scratch
+COPY --from=gobuilder /bin/algernon /bin/algernon
+COPY --from=gobuilder /tmp /tmp
+
+# Only start the Lua interpreter
+ENTRYPOINT ["/bin/algernon", "--lua"]

+ 51 - 0
docker/prod/Dockerfile

@@ -0,0 +1,51 @@
+# Dockerfile for making Algernon serve HTTP on port 80 and HTTPS+HTTP/2 on port 443
+FROM golang:alpine as gobuilder
+MAINTAINER Alexander F. Rødseth <xyproto@archlinux.org>
+
+# Prepare the needed files
+COPY . /algernon
+WORKDIR /algernon
+
+# Build Algernon
+RUN GOOS=linux \
+    GOARCH=amd64 \
+    CGO_ENABLED=0 \
+    go build \
+      -mod=vendor \
+      -a \
+      -trimpath \
+      -installsuffix cgo \
+      -ldflags="-w -s" \
+      -o /bin/algernon
+
+RUN apk add upx && upx /bin/algernon
+
+# Start from scratch, only copy in the Algernon executable
+FROM scratch
+COPY --from=gobuilder /bin/algernon /bin/algernon
+
+# Prepare directories
+COPY --from=gobuilder /tmp /tmp
+VOLUME /srv/algernon
+VOLUME /etc/algernon
+WORKDIR /srv/algernon
+
+# Expose ports for HTTP and HTTPS
+EXPOSE 80 443
+
+# -c assumes no files will be added or removed, for a slight increase in speed
+# --domain makes Algernon look for a folder named the same as the domain it serves
+# --server turns off interactive and debug mode
+# --cachesize sets a file cache size, in bytes
+# --prod makes Algernon serve HTTP on port 80 and HTTPS+HTTP/2 on port 443
+# --cert and --key is for setting the HTTPS certificate
+#
+# Other parameters that might be of interest is "--addr", ":3000" together with
+# "--server" but without "--prod" for serving only HTTP on port 3000
+#
+# "--log", "/var/log/algernon.log" can be used for logging errors
+#
+# The final parameter is the directory to serve, for instance /srv/algernon
+#
+ENTRYPOINT ["/bin/algernon", "-c", "--domain", "--server", "--cachesize", "67108864", "--prod", "--cert", "/etc/algernon/cert.pem", "--key", "/etc/algernon/key.pem", "/srv/algernon"]
+CMD ["/bin/algernon"]

+ 4 - 0
docker/run_dev.sh

@@ -0,0 +1,4 @@
+#!/bin/sh
+# The --publish argument first takes the local port and then the container port
+# The -v argument first takes the local directory and then the container directory name
+docker run -v `pwd`/serve:/srv/algernon -v `pwd`/config:/etc/algernon --rm --publish 3000:3000 algernon_dev

+ 4 - 0
docker/run_interactive.sh

@@ -0,0 +1,4 @@
+#!/bin/sh
+# The --publish argument first takes the local port and then the container port
+# The -v argument first takes the local directory and then the container directory name
+docker run -v `pwd`/serve:/srv/algernon -v `pwd`/config:/etc/algernon -i -t --rm --publish 4000:4000 algernon_interactive

+ 2 - 0
docker/run_lua.sh

@@ -0,0 +1,2 @@
+#!/bin/sh
+docker run -i -t --rm algernon_lua

+ 4 - 0
docker/run_prod.sh

@@ -0,0 +1,4 @@
+#!/bin/sh
+# The --publish argument first takes the local port and then the container port
+# The -v argument first takes the local directory and then the container directory name
+docker run -v `pwd`/serve:/srv/algernon -v `pwd`/config:/etc/algernon --rm --publish 80:80 --publish 443:443 algernon_prod

+ 1 - 0
docker/serve/127.0.0.1

@@ -0,0 +1 @@
+localhost

+ 5 - 0
docker/serve/localhost/index.md

@@ -0,0 +1,5 @@
+title: hello
+
+# Hi
+
+There

+ 98 - 0
engine/access.go

@@ -0,0 +1,98 @@
+package engine
+
+import (
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+)
+
+// CommonLogFormat returns a line with the data that is available at the start
+// of a request handler. The log line is in NCSA format, the same log format
+// used by Apache. Fields where data is not available are indicated by a "-".
+// See also: https://en.wikipedia.org/wiki/Common_Log_Format
+func (ac *Config) CommonLogFormat(req *http.Request, statusCode int, byteSize int64) string {
+	username := "-"
+	if ac.perm != nil {
+		username = ac.perm.UserState().Username(req)
+	}
+	host, _, err := net.SplitHostPort(req.RemoteAddr)
+	ip := host
+	if err != nil {
+		ip = req.RemoteAddr
+	}
+	statusCodeString := "-"
+	if statusCode > 0 {
+		statusCodeString = strconv.Itoa(statusCode)
+	}
+	byteSizeString := "0"
+	if byteSize > 0 {
+		byteSizeString = fmt.Sprintf("%d", byteSize)
+	}
+	timestamp := strings.Replace(time.Now().Format("02/Jan/2006 15:04:05 -0700"), " ", ":", 1)
+	return fmt.Sprintf("%s - %s [%s] \"%s %s %s\" %s %s", ip, username, timestamp, req.Method, req.RequestURI, req.Proto, statusCodeString, byteSizeString)
+}
+
+// CombinedLogFormat returns a line with the data that is available at the start
+// of a request handler. The log line is in CLF, similar to the Common log format,
+// but with two extra fields.
+// See also: https://httpd.apache.org/docs/1.3/logs.html#combined
+func (ac *Config) CombinedLogFormat(req *http.Request, statusCode int, byteSize int64) string {
+	username := "-"
+	if ac.perm != nil {
+		username = ac.perm.UserState().Username(req)
+	}
+	host, _, err := net.SplitHostPort(req.RemoteAddr)
+	ip := host
+	if err != nil {
+		ip = req.RemoteAddr
+	}
+	statusCodeString := "-"
+	if statusCode > 0 {
+		statusCodeString = strconv.Itoa(statusCode)
+	}
+	byteSizeString := "0"
+	if byteSize > 0 {
+		byteSizeString = fmt.Sprintf("%d", byteSize)
+	}
+	timestamp := strings.Replace(time.Now().Format("02/Jan/2006 15:04:05 -0700"), " ", ":", 1)
+	referer := req.Header.Get("Referer")
+	userAgent := req.Header.Get("User-Agent")
+	return fmt.Sprintf("%s - %s [%s] \"%s %s %s\" %s %s \"%s\" \"%s\"", ip, username, timestamp, req.Method, req.RequestURI, req.Proto, statusCodeString, byteSizeString, referer, userAgent)
+}
+
+// LogAccess creates one entry in the access log, given a http.Request,
+// a HTTP status code and the amount of bytes that have been transferred.
+func (ac *Config) LogAccess(req *http.Request, statusCode int, byteSize int64) {
+	if ac.commonAccessLogFilename != "" {
+		f, err := os.OpenFile(ac.commonAccessLogFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+		if err != nil {
+			log.Warnf("Can not open %s: %s", ac.commonAccessLogFilename, err)
+			return
+		}
+		defer f.Close()
+		_, err = f.WriteString(ac.CommonLogFormat(req, statusCode, byteSize) + "\n")
+		if err != nil {
+			log.Warnf("Can not write to %s: %s", ac.commonAccessLogFilename, err)
+			return
+		}
+	}
+	if ac.combinedAccessLogFilename != "" {
+		f, err := os.OpenFile(ac.combinedAccessLogFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+		if err != nil {
+			log.Warnf("Can not open %s: %s", ac.combinedAccessLogFilename, err)
+			return
+		}
+		defer f.Close()
+		_, err = f.WriteString(ac.CombinedLogFormat(req, statusCode, byteSize) + "\n")
+		if err != nil {
+			log.Warnf("Can not write to %s: %s", ac.combinedAccessLogFilename, err)
+			return
+		}
+	}
+}

+ 480 - 0
engine/basic.go

@@ -0,0 +1,480 @@
+package engine
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/gomarkdown/markdown"
+	"github.com/gomarkdown/markdown/parser"
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/lua/convert"
+	"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
+}
+
+// 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
+		log.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
+		log.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
+		log.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
+		extensions := parser.CommonExtensions | parser.AutoHeadingIDs
+		mdParser := parser.NewWithExtensions(extensions)
+		// Convert the buffer to markdown
+		htmlData := markdown.ToHTML(buf.Bytes(), mdParser, nil)
+		codeStyle := "base16-snazzy"
+		if highlightedHTML, err := splash.Splash(htmlData, codeStyle); err == nil { // success
+			htmlData = highlightedHTML
+		}
+		htmlString := strings.TrimSpace(string(htmlData))
+		L.Push(lua.LString(htmlString))
+		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
+	}))
+}
+
+// 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) {
+	// Print text to the web page that is being served. Add a newline.
+	L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int {
+		if req.Close {
+			if ac.debugMode {
+				log.Error("call to \"print\" after closing the connection")
+			}
+			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
+		buf.WriteTo(w)
+
+		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 req.Close {
+			if ac.debugMode {
+				log.Error("call to \"pprint\" after closing the connection")
+			}
+			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
+		buf.WriteTo(w)
+
+		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(L *lua.LState) int {
+		if req.Close {
+			if ac.debugMode {
+				log.Error("call to \"flush\" after closing the connection")
+			}
+			return 0 // number of results
+		}
+
+		if flushFunc != nil {
+			flushFunc()
+		}
+		return 0 // number of results
+	}))
+
+	// Close the communication with the client by setting a "Connection: close" header,
+	// flushing and setting req.Close to true.
+	L.SetGlobal("close", L.NewFunction(func(L *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 possible
+		if flushFunc != nil {
+			flushFunc()
+		}
+		// Stop Lua functions from writing more to this client
+		req.Close = 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.
+
+		return 0 // number of results
+	}))
+
+	// Set the Content-Type for the page
+	L.SetGlobal("content", L.NewFunction(func(L *lua.LState) int {
+		if req.Close {
+			if ac.debugMode {
+				log.Error("call to \"content\" after closing the connection")
+			}
+			return 0 // number of results
+		}
+
+		lv := L.ToString(1)
+		w.Header().Add("Content-Type", 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 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 req.Close {
+			if ac.debugMode {
+				log.Error("call to \"setheader\" after closing the connection")
+			}
+			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 req.Close {
+			if ac.debugMode {
+				log.Error("call to \"status\" after closing the connection")
+			}
+			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 req.Close {
+			if ac.debugMode {
+				log.Error("call to \"error\" after closing the connection")
+			}
+			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 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
+	}))
+
+	// 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 {
+				log.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 req.Close {
+			if ac.debugMode {
+				log.Error("redirect after closing the connection")
+			}
+			return 0 // 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)
+		return 0 // 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 req.Close {
+			if ac.debugMode {
+				log.Error("permanent_redirect after closing the connection")
+			}
+			return 0 // number of results
+		}
+
+		newurl := L.ToString(1)
+		httpStatusCode := http.StatusMovedPermanently
+		if httpStatus != nil {
+			httpStatus.code = httpStatusCode
+		}
+		http.Redirect(w, req, newurl, httpStatusCode)
+		return 0 // 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) {
+			log.Error("Could not find:", luaFilename)
+			return 0 // number of results
+		}
+		if err := L.DoFile(luaFilename); err != nil {
+			log.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
+	}))
+}

+ 66 - 0
engine/cache.go

@@ -0,0 +1,66 @@
+package engine
+
+import (
+	"net/http"
+
+	"github.com/xyproto/datablock"
+	lua "github.com/xyproto/gopher-lua"
+)
+
+// DataToClient is a helper function for sending file data (that might be cached) to a HTTP client
+func (ac *Config) DataToClient(w http.ResponseWriter, req *http.Request, filename string, data []byte) {
+	datablock.NewDataBlock(data, true).ToClient(w, req, filename, ac.ClientCanGzip(req), gzipThreshold)
+}
+
+// DataToClientModernBrowsers is a helper function for sending file data (that might be cached) to a HTTP client
+func DataToClientModernBrowsers(w http.ResponseWriter, req *http.Request, filename string, data []byte) {
+	datablock.NewDataBlock(data, true).ToClient(w, req, filename, true, gzipThreshold)
+}
+
+// LoadCacheFunctions loads functions related to caching into the given Lua state
+func (ac *Config) LoadCacheFunctions(L *lua.LState) {
+	const disabledMessage = "Caching is disabled"
+	const clearedMessage = "Cache cleared"
+
+	luaCacheStatsFunc := L.NewFunction(func(L *lua.LState) int {
+		if ac.cache == nil {
+			L.Push(lua.LString(disabledMessage))
+			return 1 // number of results
+		}
+		info := ac.cache.Stats()
+		// Return the string, but drop the final newline
+		L.Push(lua.LString(info[:len(info)-1]))
+		return 1 // number of results
+	})
+
+	// Return information about the cache use
+	L.SetGlobal("CacheInfo", luaCacheStatsFunc)
+	L.SetGlobal("CacheStats", luaCacheStatsFunc) // undocumented alias
+
+	// Clear the cache
+	L.SetGlobal("ClearCache", L.NewFunction(func(L *lua.LState) int {
+		if ac.cache == nil {
+			L.Push(lua.LString(disabledMessage))
+			return 1 // number of results
+		}
+		ac.cache.Clear()
+		L.Push(lua.LString(clearedMessage))
+		return 1 // number of results
+	}))
+
+	// Try to load a file into the file cache, if it isn't already there
+	L.SetGlobal("preload", L.NewFunction(func(L *lua.LState) int {
+		filename := L.ToString(1)
+		if ac.cache == nil {
+			L.Push(lua.LBool(false))
+			return 1 // number of results
+		}
+		// Don't read from disk if already in cache, hence "true"
+		if _, err := ac.cache.Read(filename, true); err != nil {
+			L.Push(lua.LBool(false))
+			return 1 // number of results
+		}
+		L.Push(lua.LBool(true)) // success
+		return 1                // number of results
+	}))
+}

+ 718 - 0
engine/config.go

@@ -0,0 +1,718 @@
+// Package engine provides the server configuration struct and several functions for serving files over HTTP
+package engine
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	internallog "log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/evanw/esbuild/pkg/api"
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/cachemode"
+	"github.com/xyproto/algernon/lua/pool"
+	"github.com/xyproto/algernon/platformdep"
+	"github.com/xyproto/algernon/utils"
+	"github.com/xyproto/datablock"
+	"github.com/xyproto/env/v2"
+	"github.com/xyproto/mime"
+	"github.com/xyproto/pinterface"
+	"github.com/xyproto/recwatch"
+	"github.com/xyproto/textoutput"
+	"github.com/xyproto/unzip"
+)
+
+const (
+	// Version number. Stable API within major version numbers.
+	Version = 2.0
+)
+
+// Config is the main structure for the Algernon server.
+// It contains all the state and settings.
+// The order of the fields has been decided by the "fieldalignment" utility.
+type Config struct {
+	perm                         pinterface.IPermissions // the user state, for the permissions system
+	mimereader                   *mime.Reader
+	serverReadyFunctionLua       func()              // configuration that may only be set in the server configuration script(s)
+	pongomutex                   *sync.RWMutex       // workaround for rendering pongo2 pages without concurrency issues
+	fs                           *datablock.FileStat // for checking if file exists, possibly in a cached way
+	luapool                      *pool.LStatePool    // a pool of Lua interpreters
+	cache                        *datablock.FileCache
+	reverseProxyConfig           *ReverseProxyConfig
+	redisAddr                    string
+	defaultEventPath             string
+	defaultEventRefresh          string
+	description                  string // description of the current program
+	versionString                string // program name and version number
+	defaultOpenExecutable        string // default program for opening files and URLs in the current operating system
+	serverHost                   string
+	defaultEventColonPort        string
+	defaultLimitString           string // default rate limit, as a string
+	limitRequestsString          string // store the request limit as a string for faster HTTP header creation later on
+	defaultBoltFilename          string // default bolt database file, for some operating systems
+	defaultLogFile               string // default log file, for some operating systems
+	defaultLuaDataFilename       string // default filename for a Lua script that provides data to a template
+	defaultRedisColonPort        string
+	serverDirOrFilename          string // exposed to the server configuration scripts(s)
+	serverAddr                   string // exposed to the server configuration scripts(s)
+	serverCert                   string // exposed to the server configuration scripts(s)
+	serverKey                    string // exposed to the server configuration scripts(s)
+	serverConfScript             string // exposed to the server configuration scripts(s)
+	defaultWebColonPort          string
+	serverLogFile                string // exposed to the server configuration scripts(s)
+	serverTempDir                string // temporary directory
+	cookieSecret                 string // secret to be used when setting and getting user login cookies
+	defaultTheme                 string // theme for Markdown and error pages
+	openExecutable               string // open the URL after serving, with a specific executable
+	serverAddrLua                string // configuration that may only be set in the server configuration script(s)
+	dbName                       string
+	serverHeaderName             string // used in the HTTP headers as the "Server" name
+	eventAddr                    string // for the Server-Sent Event (SSE) server (host and port)
+	eventRefresh                 string // for the Server-Sent Event (SSE) server (duration of an event cycle)
+	luaServerFilename            string // if a single Lua file is provided, or if Server() is used
+	autoRefreshDir               string // if only watching a single directory recursively
+	combinedAccessLogFilename    string // CLF access log
+	commonAccessLogFilename      string // NCSA access log
+	boltFilename                 string
+	internalLogFilename          string               // exposed to the server configuration scripts(s)
+	mariadbDSN                   string               // connection string
+	mariaDatabase                string               // database name
+	postgresDSN                  string               // connection string
+	postgresDatabase             string               // database name
+	dirBaseURL                   string               // optional Base URL, for the directory listings
+	jsxOptions                   api.TransformOptions // JSX rendering options
+	certMagicDomains             []string
+	serverConfigurationFilenames []string // list of configuration filenames to check
+	cacheMaxGivenDataSize        uint64
+	largeFileSize                uint64        // threshold for not reading large files into memory
+	refreshDuration              time.Duration // for the auto-refresh feature
+	redisDBindex                 int
+	cacheSize                    uint64
+	cacheMode                    cachemode.Setting
+	shutdownTimeout              time.Duration
+	cacheMaxEntitySize           uint64
+	defaultLimit                 int64
+	defaultCacheMaxEntitySize    uint64        // 64 KiB
+	defaultLargeFileSize         uint64        // 42 MiB: the default size for when a static file is large enough to not be read into memory
+	limitRequests                int64         // rate limit to this many requests per client per second
+	writeTimeout                 uint64        // timeout when writing data to a client, in seconds
+	defaultStatCacheRefresh      time.Duration // refresh the stat cache, if the stat cache feature is enabled
+	defaultCacheSize             uint64        // 1 MiB
+	defaultPermissions           os.FileMode
+	quietMode                    bool // no output to the command line
+	autoRefresh                  bool // enable the event server and inject JavaScript to reload pages when sources change
+	serverMode                   bool // server mode: non-interactive
+	productionMode               bool // server mode: non-interactive, assume the server is running as a system service
+	verboseMode                  bool // server mode: be more verbose
+	debugMode                    bool // server mode: enable debug features and better error messages
+	cacheFileStat                bool // assume files will not be removed from the served directories while Algernon is running, which allows caching of costly os.Stat calls
+	serverAddDomain              bool // look for files in the directory with the same name as the requested hostname
+	stricterHeaders              bool // stricter HTTP headers
+	simpleMode                   bool // server mode: for serving a directory with files over regular HTTP, nothing more nothing less
+	openURLAfterServing          bool // open the URL after serving
+	onlyLuaMode                  bool // if only using the Lua REPL, and not serving anything
+	quitAfterFirstRequest        bool // quit when the first request has been responded to?
+	markdownMode                 bool
+	serveJustQUIC                bool // if only QUIC or HTTP/3
+	serveJustHTTP                bool // if only HTTP
+	serveJustHTTP2               bool // if only HTTP/2
+	ctrldTwice                   bool // require a double press of ctrl-d to exit the REPL
+	noHeaders                    bool // HTTP headers
+	redisAddrSpecified           bool
+	noCache                      bool
+	showVersion                  bool
+	curlSupport                  bool // support clients like "curl" that downloads uncompressed by default
+	noBanner                     bool // don't display the ANSI-graphics banner at start
+	cacheCompressionSpeed        bool // compression speed over compactness
+	cacheCompression             bool
+	singleFileMode               bool // if only serving a single file, like a Lua script
+	hyperApp                     bool // convert JSX to HyperApp JS, or React JS?
+	devMode                      bool // server mode: aims to make it easy to get started
+	clearDefaultPathPrefixes     bool // clear default path prefixes like "/admin" from the permission system?
+	disableRateLimiting          bool
+	redirectHTTP                 bool // redirect HTTP traffic to HTTPS?
+	useCertMagic                 bool // use CertMagic and Let's Encrypt for all directories in the given directory that contains a "."
+	useBolt                      bool
+	useNoDatabase                bool // don't use a database. There will be a loss of functionality.
+}
+
+// ErrVersion is returned when the initialization quits because all that is done
+// is showing version information
+var (
+	ErrVersion  = errors.New("only showing version information")
+	ErrDatabase = errors.New("could not find a usable database backend")
+)
+
+// New creates a new server configuration based using the default values
+func New(versionString, description string) (*Config, error) {
+	tmpdir := env.Str("TMPDIR", "/tmp")
+	ac := &Config{
+		curlSupport: true,
+
+		shutdownTimeout: 10 * time.Second,
+
+		defaultWebColonPort:       ":3001",
+		defaultRedisColonPort:     ":6379",
+		defaultEventColonPort:     ":5553",
+		defaultEventRefresh:       "350ms",
+		defaultEventPath:          "/sse",
+		defaultLimit:              10,
+		defaultPermissions:        0o660,
+		defaultCacheSize:          1 * utils.MiB,   // 1 MiB
+		defaultCacheMaxEntitySize: 64 * utils.KiB,  // 64 KB
+		defaultStatCacheRefresh:   time.Minute * 1, // Refresh the stat cache, if the stat cache feature is enabled
+
+		// When is a static file large enough to not read into memory when serving
+		defaultLargeFileSize: 42 * utils.MiB, // 42 MiB
+
+		// Default rate limit, as a string
+		defaultLimitString: strconv.Itoa(10),
+
+		// Default Bolt database file, for some operating systems
+		defaultBoltFilename: filepath.Join(tmpdir, "algernon.db"),
+
+		// Default log file, for some operating systems
+		defaultLogFile: filepath.Join(tmpdir, "algernon.log"),
+
+		// Default filename for a Lua script that provides data to a template
+		defaultLuaDataFilename: "data.lua",
+
+		// List of configuration filenames to check
+		serverConfigurationFilenames: []string{"/etc/algernon/serverconf.lua", "/etc/algernon/server.lua"},
+
+		// Compression speed over compactness
+		cacheCompressionSpeed: true,
+
+		// TODO: Make configurable
+		// Maximum given file size for caching, 7 MiB
+		cacheMaxGivenDataSize: 7 * utils.MiB,
+
+		// Mutex for rendering Pongo2 pages
+		pongomutex: &sync.RWMutex{},
+
+		// Program for opening URLs
+		defaultOpenExecutable: platformdep.DefaultOpenExecutable,
+
+		// General information about Algernon
+		versionString: versionString,
+		description:   description,
+
+		// JSX rendering options
+		jsxOptions: api.TransformOptions{
+			Loader:            api.LoaderJSX,
+			MinifyWhitespace:  true,
+			MinifyIdentifiers: true,
+			MinifySyntax:      true,
+			Charset:           api.CharsetUTF8,
+		},
+	}
+	if err := ac.initFilesAndCache(); err != nil {
+		return nil, err
+	}
+	ac.initializeMime()
+	ac.setupLogging()
+
+	// File stat cache
+	ac.fs = datablock.NewFileStat(ac.cacheFileStat, ac.defaultStatCacheRefresh)
+
+	return ac, nil
+}
+
+// SetFileStatCache can be used to set a different FileStat cache than the default one
+func (ac *Config) SetFileStatCache(fs *datablock.FileStat) {
+	ac.fs = fs
+}
+
+// Initialize a temporary directory, handle flags, output version and handle profiling
+func (ac *Config) initFilesAndCache() error {
+	// Temporary directory that might be used for logging, databases or file extraction
+	serverTempDir, err := os.MkdirTemp("", "algernon")
+	if err != nil {
+		return err
+	}
+	ac.serverTempDir = serverTempDir
+
+	// Set several configuration variables, based on the given flags and arguments
+	ac.handleFlags(ac.serverTempDir)
+
+	// Version (--version)
+	if ac.showVersion {
+		if !ac.quietMode {
+			fmt.Println(ac.versionString)
+		}
+		return ErrVersion
+	}
+
+	// CPU and memory profiling, if it is enabled at build time, and one of these
+	// flags are provided (+ a filename): -cpuprofile, -memprofile, -fgtrace or -trace
+	traceStart()
+
+	// Touch the common access log, if specified
+	if ac.commonAccessLogFilename != "" {
+		// Create if missing
+		f, err := os.OpenFile(ac.commonAccessLogFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
+		if err != nil {
+			return err
+		}
+		f.Close()
+	}
+	// Touch the combined access log, if specified
+	if ac.combinedAccessLogFilename != "" {
+		// Create if missing
+		f, err := os.OpenFile(ac.combinedAccessLogFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
+		if err != nil {
+			return err
+		}
+		f.Close()
+	}
+
+	// Create a cache struct for reading files (contains functions that can
+	// be used for reading files, also when caching is disabled).
+	// The final argument is for compressing with "fast" instead of "best".
+	ac.cache = datablock.NewFileCache(ac.cacheSize, ac.cacheCompression, ac.cacheMaxEntitySize, ac.cacheCompressionSpeed, ac.cacheMaxGivenDataSize)
+	return nil
+}
+
+func (ac *Config) setupLogging() {
+	// Log to a file as JSON, if a log file has been specified
+	if ac.serverLogFile != "" {
+		f, errJSONLog := os.OpenFile(ac.serverLogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, ac.defaultPermissions)
+		if errJSONLog != nil {
+			log.Warnf("Could not log to %s: %s", ac.serverLogFile, errJSONLog)
+		} else {
+			// Log to the given log filename
+			log.SetFormatter(&log.JSONFormatter{})
+			log.SetOutput(f)
+		}
+	} else if ac.quietMode {
+		// If quiet mode is enabled and no log file has been specified, disable logging
+		log.SetOutput(io.Discard)
+	}
+	// Close stdout and stderr if quite mode has been enabled
+	if ac.quietMode {
+		os.Stdout.Close()
+		os.Stderr.Close()
+	}
+}
+
+// Close removes the temporary directory
+func (ac *Config) Close() {
+	os.RemoveAll(ac.serverTempDir)
+}
+
+// Fatal exit
+func (ac *Config) fatalExit(err error) {
+	// Log to file, if a log file is used
+	if ac.serverLogFile != "" {
+		log.Error(err)
+	}
+	// Then switch to stderr and log the message there as well
+	log.SetOutput(os.Stderr)
+	// Use the standard formatter
+	log.SetFormatter(&log.TextFormatter{})
+	// Log and exit
+	log.Fatalln(err.Error())
+}
+
+// Abrupt exit
+func (ac *Config) abruptExit(msg string) {
+	// Log to file, if a log file is used
+	if ac.serverLogFile != "" {
+		log.Info(msg)
+	}
+	// Then switch to stderr and log the message there as well
+	log.SetOutput(os.Stderr)
+	// Use the standard formatter
+	log.SetFormatter(&log.TextFormatter{})
+	// Log and exit
+	log.Info(msg)
+	os.Exit(0)
+}
+
+// Quit after a short duration
+func (ac *Config) quitSoon(msg string, soon time.Duration) {
+	time.Sleep(soon)
+	ac.abruptExit(msg)
+}
+
+// Return true of the given file type (extension) should be cached
+func (ac *Config) shouldCache(ext string) bool {
+	switch ac.cacheMode {
+	case cachemode.On:
+		return true
+	case cachemode.Production, cachemode.Small:
+		switch ext {
+		case ".amber", ".lua", ".tl", ".po2", ".tpl", ".pongo2":
+			return false
+		default:
+			return true
+		}
+	case cachemode.Images:
+		switch ext {
+		case ".png", ".jpg", ".gif", ".svg", ".jpeg", ".ico", ".bmp", ".apng":
+			return true
+		default:
+			return false
+		}
+	case cachemode.Off:
+		return false
+	case cachemode.Development, cachemode.Unset:
+		fallthrough
+	default:
+		switch ext {
+		case ".amber", ".lua", ".tl", ".md", ".gcss", ".jsx", ".po2", ".tpl", ".pongo2", ".happ", ".js", ".scss":
+			return false
+		default:
+			return true
+		}
+	}
+}
+
+// hasHandlers checks if the given filename contains "handle(" or "handle ("
+func hasHandlers(fn string) bool {
+	data, err := os.ReadFile(fn)
+	return err == nil && (bytes.Contains(data, []byte("handle(")) || bytes.Contains(data, []byte("handle (")))
+}
+
+// has checks if a given slice of strings contains a given string
+func has(sl []string, e string) bool {
+	for _, s := range sl {
+		if e == s {
+			return true
+		}
+	}
+	return false
+}
+
+// repeat a string n number of times
+func repeat(s string, n int) string {
+	var sb strings.Builder
+	for i := 0; i < n; i++ {
+		sb.WriteString(s)
+	}
+	return sb.String()
+}
+
+// unique removes all repeated elements from a slice of strings
+func unique(sl []string) []string {
+	var nl []string
+	for _, s := range sl {
+		if !has(nl, s) {
+			nl = append(nl, s)
+		}
+	}
+	return nl
+}
+
+// MustServe sets up a server with handlers
+func (ac *Config) MustServe(mux *http.ServeMux) error {
+	var err error
+
+	defer ac.Close()
+
+	// Output what we are attempting to access and serve
+	if ac.verboseMode {
+		log.Info("Accessing " + ac.serverDirOrFilename)
+	}
+
+	// Check if the given directory really is a directory
+	if !ac.fs.IsDir(ac.serverDirOrFilename) {
+		// It is not a directory
+		serverFile := ac.serverDirOrFilename
+		// Check if the file exists
+		if ac.fs.Exists(serverFile) {
+			if ac.markdownMode {
+				// Serve the given Markdown file as a static HTTP server
+				if serveErr := ac.ServeStaticFile(serverFile, ac.defaultWebColonPort); serveErr != nil {
+					// Must serve
+					ac.fatalExit(serveErr)
+				}
+				return nil
+			}
+			// Switch based on the lowercase filename extension
+			switch strings.ToLower(filepath.Ext(serverFile)) {
+			case ".md", ".markdown":
+				// Serve the given Markdown file as a static HTTP server
+				if serveErr := ac.ServeStaticFile(serverFile, ac.defaultWebColonPort); serveErr != nil {
+					// Must serve
+					ac.fatalExit(serveErr)
+				}
+				return nil
+			case ".zip", ".alg":
+
+				// Assume this to be a compressed Algernon application
+				webApplicationExtractionDir := "/dev/shm" // extract to memory, if possible
+				testfile := filepath.Join(webApplicationExtractionDir, "canary")
+				if _, err := os.Create(testfile); err == nil { // success
+					os.Remove(testfile)
+				} else {
+					// Could not create the test file
+					// Use the server temp dir (typically /tmp) instead of /dev/shm
+					webApplicationExtractionDir = ac.serverTempDir
+				}
+				// Extract the web application
+				if extractErr := unzip.Extract(serverFile, webApplicationExtractionDir); extractErr != nil {
+					return extractErr
+				}
+				// Use the directory where the file was extracted as the server directory
+				ac.serverDirOrFilename = webApplicationExtractionDir
+				// If there is only one directory there, assume it's the
+				// directory of the newly extracted ZIP file.
+				if filenames := utils.GetFilenames(ac.serverDirOrFilename); len(filenames) == 1 {
+					fullPath := filepath.Join(ac.serverDirOrFilename, filenames[0])
+					if ac.fs.IsDir(fullPath) {
+						// Use this as the server directory instead
+						ac.serverDirOrFilename = fullPath
+					}
+				}
+				// If there are server configuration files in the extracted
+				// directory, register them.
+				for _, filename := range ac.serverConfigurationFilenames {
+					configFilename := filepath.Join(ac.serverDirOrFilename, filename)
+					ac.serverConfigurationFilenames = append(ac.serverConfigurationFilenames, configFilename)
+				}
+				// Disregard all configuration files from the current directory
+				// (filenames without a path separator), since we are serving a
+				// ZIP file.
+				for i, filename := range ac.serverConfigurationFilenames {
+					if strings.Count(filepath.ToSlash(filename), "/") == 0 {
+						// Remove the filename from the slice
+						ac.serverConfigurationFilenames = append(ac.serverConfigurationFilenames[:i], ac.serverConfigurationFilenames[i+1:]...)
+					}
+				}
+
+			default:
+				ac.singleFileMode = true
+			}
+		} else {
+			return errors.New("File does not exist: " + serverFile)
+		}
+	}
+
+	// Make a few changes to the defaults if we are serving a single file
+	if ac.singleFileMode {
+		ac.debugMode = true
+		ac.serveJustHTTP = true
+	}
+
+	to := textoutput.NewTextOutput(runtime.GOOS != "windows", !ac.quietMode)
+
+	// Console output
+	if !ac.quietMode && !ac.singleFileMode && !ac.simpleMode && !ac.noBanner {
+		// Output a colorful ansi logo if a proper terminal is available
+		fmt.Println(platformdep.Banner(ac.versionString, ac.description))
+	} else if !ac.quietMode {
+		timestamp := time.Now().Format("2006-01-02 15:04")
+		to.OutputTags("<cyan>" + ac.versionString + "<darkgray> - " + timestamp + "<off>")
+		// colorstring.Println("[cyan]" + ac.versionString + "[dark_gray] - " + timestamp + "[reset]")
+	}
+
+	// Disable the database backend if the BoltDB filename is the /dev/null file (or OS equivalent)
+	if ac.boltFilename == os.DevNull {
+		ac.useNoDatabase = true
+	}
+
+	if !ac.useNoDatabase {
+		// Connect to a database and retrieve a Permissions struct
+		ac.perm, err = ac.DatabaseBackend()
+		if err != nil {
+			return ErrDatabase
+		}
+	}
+
+	// Lua LState pool
+	ac.luapool = pool.New()
+	AtShutdown(func() {
+		// TODO: Why not defer?
+		ac.luapool.Shutdown()
+	})
+
+	// TODO: save repl history + close luapool + close logs ++ at shutdown
+
+	if ac.singleFileMode && (filepath.Ext(ac.serverDirOrFilename) == ".lua" || ac.onlyLuaMode) {
+		ac.luaServerFilename = ac.serverDirOrFilename
+		if ac.luaServerFilename == "index.lua" || ac.luaServerFilename == "data.lua" {
+			// Friendly message to new users
+			if !hasHandlers(ac.luaServerFilename) {
+				log.Warnf("Found no handlers in %s", ac.luaServerFilename)
+				log.Info("How to implement \"Hello, World!\" in " + ac.luaServerFilename + " file:\n\nhandle(\"/\", function()\n  print(\"Hello, World!\")\nend)\n")
+			}
+		}
+		ac.serverDirOrFilename = filepath.Dir(ac.serverDirOrFilename)
+		// Make it possible to read other files from the Lua script
+		ac.singleFileMode = false
+	}
+
+	ac.serverConfigurationFilenames = unique(ac.serverConfigurationFilenames)
+
+	// Color scheme
+	arrowColor := "<lightblue>"
+	filenameColor := "<white>"
+	luaOutputColor := "<darkgray>"
+	dashLineColor := "<red>"
+
+	// Create a Colorize struct that will not reset colors after colorizing
+	// strings meant for the terminal.
+	// c := colorstring.Colorize{Colors: colorstring.DefaultColors, Reset: false}
+
+	if (len(ac.serverConfigurationFilenames) > 0) && !ac.quietMode && !ac.onlyLuaMode {
+		fmt.Println(to.Tags(dashLineColor + repeat("-", 49) + "<off>"))
+	}
+
+	// Read server configuration script, if present.
+	// The scripts may change global variables.
+	var ranConfigurationFilenames []string
+	for _, filename := range unique(ac.serverConfigurationFilenames) {
+		if ac.fs.Exists(filename) {
+			// Dividing line between the banner and output from any of the configuration scripts
+			if !ac.quietMode && !ac.onlyLuaMode {
+				// Output the configuration filename
+				to.Println(arrowColor + "-> " + filenameColor + filename + "<off>")
+				fmt.Print(to.Tags(luaOutputColor))
+			} else if ac.verboseMode {
+				log.Info("Running Lua configuration file: " + filename)
+			}
+			withHandlerFunctions := true
+			errConf := ac.RunConfiguration(filename, mux, withHandlerFunctions)
+			if errConf != nil {
+				if ac.perm != nil {
+					log.Error("Could not use configuration script: " + filename)
+					return errConf
+				}
+				if ac.verboseMode {
+					log.Info("Skipping " + filename + " because the database backend is not in use.")
+				}
+			}
+			ranConfigurationFilenames = append(ranConfigurationFilenames, filename)
+		} else {
+			if ac.verboseMode {
+				log.Info("Looking for: " + filename)
+			}
+		}
+	}
+	// Only keep the active ones. Used when outputting server information.
+	ac.serverConfigurationFilenames = ranConfigurationFilenames
+
+	// Run the standalone Lua server, if specified
+	if ac.luaServerFilename != "" {
+		// Run the Lua server file and set up handlers
+		if !ac.quietMode && !ac.onlyLuaMode {
+			// Output the configuration filename
+			to.Println(arrowColor + "-> " + filenameColor + ac.luaServerFilename + "<off>")
+			fmt.Print(to.Tags(luaOutputColor))
+		} else if ac.verboseMode {
+			fmt.Println("Running Lua configuration file: " + ac.luaServerFilename)
+		}
+		withHandlerFunctions := true
+		errLua := ac.RunConfiguration(ac.luaServerFilename, mux, withHandlerFunctions)
+		if errLua != nil {
+			log.Errorf("Error in %s (interpreted as a server script):\n%s\n", ac.luaServerFilename, errLua)
+			return errLua
+		}
+	} else {
+		// Register HTTP handler functions
+		ac.RegisterHandlers(mux, "/", ac.serverDirOrFilename, ac.serverAddDomain)
+	}
+
+	// Set the values that has not been set by flags nor scripts
+	// (and can be set by both)
+	ranServerReadyFunction := ac.finalConfiguration(ac.serverHost)
+
+	if !ac.quietMode && !ac.onlyLuaMode {
+		to.Print("<off>")
+	}
+
+	// If no configuration files were being ran successfully,
+	// output basic server information.
+	if len(ac.serverConfigurationFilenames) == 0 {
+		if !ac.quietMode && !ac.onlyLuaMode {
+			fmt.Println(ac.Info())
+		}
+		ranServerReadyFunction = true
+	}
+
+	// Separator between the output of the configuration scripts and
+	// the rest of the server output.
+	if ranServerReadyFunction && (len(ac.serverConfigurationFilenames) > 0) && !ac.quietMode && !ac.onlyLuaMode {
+		to.Tags(dashLineColor + repeat("-", 49) + "<off>")
+	}
+
+	// Direct internal logging elsewhere
+	internalLogFile, err := os.Open(ac.internalLogFilename)
+	if err != nil {
+		// Could not open the internalLogFilename filename, try using another filename
+		internalLogFile, err = os.OpenFile("internal.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, ac.defaultPermissions)
+		AtShutdown(func() {
+			// TODO This one is is special and should be closed after the other shutdown functions.
+			//      Set up a "done" channel instead of sleeping.
+			time.Sleep(100 * time.Millisecond)
+			internalLogFile.Close()
+		})
+		if err != nil {
+			ac.fatalExit(fmt.Errorf("could not write to %s nor %s", ac.internalLogFilename, "internal.log"))
+		}
+	}
+	defer internalLogFile.Close()
+	internallog.SetOutput(internalLogFile)
+
+	// Serve filesystem events in the background.
+	// Used for reloading pages when the sources change.
+	// Can also be used when serving a single file.
+	if ac.autoRefresh {
+		ac.refreshDuration, err = time.ParseDuration(ac.eventRefresh)
+		if err != nil {
+			log.Warnf("%s is an invalid duration. Using %s instead.", ac.eventRefresh, ac.defaultEventRefresh)
+			// Ignore the error, since defaultEventRefresh is a constant and must be parseable
+			ac.refreshDuration, _ = time.ParseDuration(ac.defaultEventRefresh)
+		}
+		recwatch.SetVerbose(ac.verboseMode)
+		recwatch.LogError = func(err error) {
+			log.Error(err)
+		}
+		recwatch.FatalExit = ac.fatalExit
+		recwatch.Exists = ac.fs.Exists
+		if ac.autoRefreshDir != "" {
+			absdir, err := filepath.Abs(ac.autoRefreshDir)
+			if err != nil {
+				absdir = ac.autoRefreshDir
+			}
+			// Only watch the autoRefreshDir, recursively
+			recwatch.EventServer(absdir, "*", ac.eventAddr, ac.defaultEventPath, ac.refreshDuration)
+		} else {
+			absdir, err := filepath.Abs(ac.serverDirOrFilename)
+			if err != nil {
+				absdir = ac.serverDirOrFilename
+			}
+			// Watch everything in the server directory, recursively
+			recwatch.EventServer(absdir, "*", ac.eventAddr, ac.defaultEventPath, ac.refreshDuration)
+		}
+	}
+
+	// For communicating to and from the REPL
+	ready := make(chan bool) // for when the server is up and running
+	done := make(chan bool)  // for when the user wish to quit the server
+
+	// The Lua REPL
+	if !ac.serverMode {
+		// If the REPL uses readline, the SIGWINCH signal is handled there
+		go ac.REPL(ready, done)
+	} else {
+		// Ignore SIGWINCH if we are not going to use a REPL
+		platformdep.IgnoreTerminalResizeSignal()
+	}
+
+	// Run the shutdown functions if graceful does not
+	defer ac.GenerateShutdownFunction(nil)()
+
+	// Serve HTTP, HTTP/2 and/or HTTPS
+	return ac.Serve(mux, done, ready)
+}

+ 157 - 0
engine/dirhandler.go

@@ -0,0 +1,157 @@
+package engine
+
+// Directory Index
+
+import (
+	"bytes"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"github.com/go-gcfg/gcfg"
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/themes"
+	"github.com/xyproto/algernon/utils"
+)
+
+// List of filenames that should be displayed instead of a directory listing
+var indexFilenames = []string{"index.lua", "index.html", "index.md", "index.txt", "index.pongo2", "index.tmpl", "index.po2", "index.amber", "index.happ", "index.hyper", "index.hyper.js", "index.hyper.jsx", "index.tl"}
+
+const (
+	dotSlash        = "." + utils.Pathsep           /* ./ */
+	doubleP         = utils.Pathsep + utils.Pathsep /* // */
+	dirconfFilename = ".algernon"
+)
+
+// DirConfig keeps a directory listing configuration
+type DirConfig struct {
+	Main struct {
+		Title string
+		Theme string
+	}
+}
+
+// DirectoryListing serves the given directory as a web page with links the the contents
+func (ac *Config) DirectoryListing(w http.ResponseWriter, req *http.Request, rootdir, dirname, theme string) {
+	var (
+		buf          bytes.Buffer
+		fullFilename string
+		URLpath      string
+		title        = dirname
+	)
+
+	// Remove a trailing slash after the root directory, if present
+	rootdir = strings.TrimSuffix(rootdir, "/")
+
+	// Fill the coming HTML body with a list of all the filenames in `dirname`
+	for _, filename := range utils.GetFilenames(dirname) {
+
+		if filename == dirconfFilename {
+			// Skip
+			continue
+		}
+
+		// Find the full name
+		fullFilename = dirname
+
+		// Add a "/" after the directory name, if missing
+		if !strings.HasSuffix(fullFilename, utils.Pathsep) {
+			fullFilename += utils.Pathsep
+		}
+
+		// Add the filename at the end
+		fullFilename += filename
+
+		// Remove the root directory from the link path
+		URLpath = fullFilename[len(rootdir)+1:]
+
+		// Output different entries for files and directories
+		buf.WriteString(themes.HTMLLink(filename, URLpath, ac.fs.IsDir(fullFilename)))
+	}
+
+	// Read directory configuration, if present
+	fullDirConfFilename := filepath.Join(dirname, dirconfFilename)
+	if ac.fs.Exists(fullDirConfFilename) {
+		var dirConf DirConfig
+		if err := gcfg.ReadFileInto(&dirConf, fullDirConfFilename); err == nil { // if no error
+			if dirConf.Main.Title != "" {
+				title = dirConf.Main.Title
+			}
+			if dirConf.Main.Theme != "" {
+				theme = dirConf.Main.Theme
+			}
+		}
+	} else {
+		// Strip the leading "./" from the current directory
+		title = strings.TrimPrefix(title, dotSlash)
+
+		// Replace "//" with just "/"
+		title = strings.ReplaceAll(title, doubleP, utils.Pathsep)
+	}
+
+	// Check if the current page contents are empty
+	if buf.Len() == 0 {
+		buf.WriteString("Empty directory")
+	}
+
+	htmldata := themes.MessagePageBytes(title, buf.Bytes(), theme)
+
+	// If the auto-refresh feature has been enabled
+	if ac.autoRefresh {
+		// Insert JavaScript for refreshing the page into the generated HTML
+		htmldata = ac.InsertAutoRefresh(req, htmldata)
+	}
+
+	// Serve the page
+	w.Header().Add("Content-Type", "text/html;charset=utf-8")
+	ac.DataToClient(w, req, dirname, htmldata)
+}
+
+// DirPage serves a directory, using index.* files, if present.
+// The directory must exist.
+// rootdir is the base directory (can be ".")
+// dirname is the specific directory that is to be served (should never be ".")
+func (ac *Config) DirPage(w http.ResponseWriter, req *http.Request, rootdir, dirname, theme string) {
+	// Check if we are instructed to quit after serving the first file
+	if ac.quitAfterFirstRequest {
+		go ac.quitSoon("Quit after first request", defaultSoonDuration)
+	}
+
+	// If the URL does not end with a slash, redirect to an URL that does
+	if !strings.HasSuffix(req.URL.Path, "/") {
+		if req.Method == "POST" {
+			log.Warn("Redirecting a POST request: " + req.URL.Path + " -> " + req.URL.Path + "/.")
+			log.Warn("Header data may be lost! Please add the missing slash.")
+		}
+		http.Redirect(w, req, req.URL.Path+"/", http.StatusMovedPermanently)
+		return
+	}
+
+	// Handle the serving of index files, if needed
+	var filename string
+	for _, indexfile := range indexFilenames {
+		filename = filepath.Join(dirname, indexfile)
+		if ac.fs.Exists(filename) {
+			ac.FilePage(w, req, filename, ac.defaultLuaDataFilename)
+			return
+		}
+	}
+
+	// Serve handler.lua, if found in ancestors
+	var ancestor string
+	ancestor = filepath.Dir(dirname)
+	for x := 0; x < 100; x++ { // a maximum of 100 directories deep
+		filename = filepath.Join(ancestor, "handler.lua")
+		if ac.fs.Exists(filename) {
+			ac.FilePage(w, req, filename, ac.defaultLuaDataFilename)
+			return
+		}
+		if ancestor == "." {
+			break
+		}
+		ancestor = filepath.Dir(ancestor)
+	}
+
+	// Serve a directory listing if no index file is found
+	ac.DirectoryListing(w, req, rootdir, dirname, theme)
+}

+ 426 - 0
engine/flags.go

@@ -0,0 +1,426 @@
+package engine
+
+import (
+	"flag"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+
+	"github.com/xyproto/algernon/cachemode"
+	"github.com/xyproto/algernon/themes"
+	"github.com/xyproto/datablock"
+	"github.com/xyproto/env/v2"
+	"github.com/xyproto/files"
+)
+
+// Parse the flags, return the default hostname
+func (ac *Config) handleFlags(serverTempDir string) {
+	var (
+		// The short version of some flags
+		serveJustHTTPShort, autoRefreshShort, productionModeShort,
+		debugModeShort, serverModeShort, useBoltShort, devModeShort,
+		showVersionShort, quietModeShort, cacheFileStatShort, simpleModeShort,
+		noBannerShort, quitAfterFirstRequestShort, verboseModeShort,
+		serveJustQUICShort, onlyLuaModeShort, redirectShort bool
+		// Used when setting the cache mode
+		cacheModeString string
+		// Used if disabling cache compression
+		rawCache bool
+		// Used if disabling the database backend
+		noDatabase bool
+	)
+
+	// The usage function that provides more help (for --help or -h)
+	flag.Usage = generateUsageFunction(ac)
+
+	// The default for running the redis server on Windows is to listen
+	// to "localhost:port", but not just ":port".
+	host := ""
+	if runtime.GOOS == "windows" {
+		host = "localhost"
+		// Default Bolt database file
+		ac.defaultBoltFilename = filepath.Join(serverTempDir, "algernon.db")
+		// Default log file
+		ac.defaultLogFile = filepath.Join(serverTempDir, "algernon.log")
+	}
+
+	// Commandline flag configuration
+
+	flag.StringVar(&ac.serverDirOrFilename, "dir", ".", "Server directory")
+	flag.StringVar(&ac.serverAddr, "addr", "", "Server [host][:port] (ie \":443\")")
+	flag.StringVar(&ac.serverCert, "cert", "cert.pem", "Server certificate")
+	flag.StringVar(&ac.serverKey, "key", "key.pem", "Server key")
+	flag.StringVar(&ac.redisAddr, "redis", "", "Redis [host][:port] (ie \""+ac.defaultRedisColonPort+"\")")
+	flag.IntVar(&ac.redisDBindex, "dbindex", 0, "Redis database index")
+	flag.StringVar(&ac.serverConfScript, "conf", "serverconf.lua", "Server configuration written in Lua")
+	flag.StringVar(&ac.serverLogFile, "log", "", "Server log file")
+	flag.StringVar(&ac.internalLogFilename, "internal", os.DevNull, "Internal log file")
+	flag.BoolVar(&ac.serveJustHTTP2, "http2only", false, "Serve HTTP/2, not HTTPS + HTTP/2")
+	flag.BoolVar(&ac.serveJustHTTP, "httponly", false, "Serve plain old HTTP")
+	flag.BoolVar(&ac.productionMode, "prod", false, "Production mode (when running as a system service)")
+	flag.BoolVar(&ac.debugMode, "debug", false, "Debug mode")
+	flag.BoolVar(&ac.verboseMode, "verbose", false, "Verbose logging")
+	flag.BoolVar(&ac.redirectHTTP, "redirect", false, "Redirect HTTP traffic to HTTPS if both are enabled")
+	flag.BoolVar(&ac.autoRefresh, "autorefresh", false, "Enable the auto-refresh feature")
+	flag.StringVar(&ac.autoRefreshDir, "watchdir", "", "Directory to watch (also enables auto-refresh)")
+	flag.StringVar(&ac.eventAddr, "eventserver", "", "SSE [host][:port] (ie \""+ac.defaultEventColonPort+"\")")
+	flag.StringVar(&ac.eventRefresh, "eventrefresh", ac.defaultEventRefresh, "Event refresh interval (ie \""+ac.defaultEventRefresh+"\")")
+	flag.BoolVar(&ac.serverMode, "server", false, "Server mode (disable interactive mode)")
+	flag.StringVar(&ac.mariadbDSN, "maria", "", "MariaDB/MySQL connection string (DSN)")
+	flag.StringVar(&ac.mariaDatabase, "mariadb", "", "MariaDB/MySQL database name")
+	flag.StringVar(&ac.postgresDSN, "postgres", "", "PostgreSQL connection string (DSN)")
+	flag.StringVar(&ac.postgresDatabase, "postgresdb", "", "PostgreSQL database name")
+	flag.BoolVar(&ac.useBolt, "bolt", false, "Use the default Bolt filename")
+	flag.StringVar(&ac.boltFilename, "boltdb", "", "Bolt database filename")
+	flag.Int64Var(&ac.limitRequests, "limit", ac.defaultLimit, "Limit clients to a number of requests per second")
+	flag.BoolVar(&ac.disableRateLimiting, "nolimit", false, "Disable rate limiting")
+	flag.BoolVar(&ac.devMode, "dev", false, "Development mode")
+	flag.BoolVar(&ac.showVersion, "version", false, "Version")
+	flag.StringVar(&cacheModeString, "cache", "", "Cache everything but Amber, Lua, GCSS and Markdown")
+	flag.Uint64Var(&ac.cacheSize, "cachesize", ac.defaultCacheSize, "Cache size, in bytes")
+	flag.Uint64Var(&ac.largeFileSize, "largesize", ac.defaultLargeFileSize, "Threshold for not reading static files into memory, in bytes")
+	flag.Uint64Var(&ac.writeTimeout, "timeout", 10, "Timeout when writing to a client, in seconds")
+	flag.BoolVar(&ac.quietMode, "quiet", false, "Quiet")
+	flag.BoolVar(&rawCache, "rawcache", false, "Disable cache compression")
+	flag.StringVar(&ac.serverHeaderName, "servername", ac.versionString, "Server header name")
+	flag.BoolVar(&ac.cacheFileStat, "statcache", false, "Cache os.Stat")
+	flag.BoolVar(&ac.serverAddDomain, "domain", false, "Look for files in the directory named the same as the hostname")
+	flag.BoolVar(&ac.simpleMode, "simple", false, "Serve a directory of files over HTTP")
+	flag.StringVar(&ac.openExecutable, "open", "", "Open URL after serving, with an application")
+	flag.BoolVar(&ac.quitAfterFirstRequest, "quit", false, "Quit after the first request")
+	flag.BoolVar(&ac.noCache, "nocache", false, "Disable caching")
+	flag.BoolVar(&ac.noHeaders, "noheaders", false, "Don't set any HTTP headers by default")
+	flag.BoolVar(&ac.stricterHeaders, "stricter", false, "Stricter HTTP headers")
+	flag.StringVar(&ac.defaultTheme, "theme", themes.DefaultTheme, "Theme for Markdown and directory listings")
+	flag.BoolVar(&ac.noBanner, "nobanner", false, "Don't show a banner at start")
+	flag.BoolVar(&ac.ctrldTwice, "ctrld", false, "Press ctrl-d twice to exit")
+	if quicEnabled {
+		flag.BoolVar(&ac.serveJustQUIC, "quic", false, "Serve just QUIC")
+	}
+	flag.BoolVar(&noDatabase, "nodb", false, "No database backend")
+	flag.BoolVar(&ac.onlyLuaMode, "lua", false, "Only present the Lua REPL")
+	flag.StringVar(&ac.combinedAccessLogFilename, "accesslog", "", "Combined access log filename")
+	flag.StringVar(&ac.commonAccessLogFilename, "ncsa", "", "NCSA access log filename")
+	flag.BoolVar(&ac.clearDefaultPathPrefixes, "clear", false, "Clear the default URI prefixes for handling permissions")
+	flag.StringVar(&ac.cookieSecret, "cookiesecret", "", "Secret to be used when setting and getting login cookies")
+	flag.BoolVar(&ac.useCertMagic, "letsencrypt", false, "Use Let's Encrypt for all served domains and serve regular HTTPS")
+	flag.StringVar(&ac.dirBaseURL, "dirbaseurl", "", "Base URL for the directory listing (optional)")
+
+	// The short versions of some flags
+	flag.BoolVar(&serveJustHTTPShort, "t", false, "Serve plain old HTTP")
+	flag.BoolVar(&autoRefreshShort, "a", false, "Enable the auto-refresh feature")
+	flag.BoolVar(&serverModeShort, "s", false, "Server mode (non-interactive)")
+	flag.BoolVar(&useBoltShort, "b", false, "Use the default Bolt filename")
+	flag.BoolVar(&productionModeShort, "p", false, "Production mode (when running as a system service)")
+	flag.BoolVar(&debugModeShort, "d", false, "Debug mode")
+	flag.BoolVar(&devModeShort, "e", false, "Development mode")
+	flag.BoolVar(&showVersionShort, "v", false, "Version")
+	flag.BoolVar(&verboseModeShort, "V", false, "Verbose")
+	flag.BoolVar(&quietModeShort, "q", false, "Quiet")
+	flag.BoolVar(&cacheFileStatShort, "c", false, "Cache os.Stat")
+	flag.BoolVar(&simpleModeShort, "x", false, "Simple mode")
+	flag.BoolVar(&ac.openURLAfterServing, "o", false, "Open URL after serving")
+	flag.BoolVar(&quitAfterFirstRequestShort, "z", false, "Quit after the first request")
+	flag.BoolVar(&ac.markdownMode, "m", false, "Markdown mode")
+	flag.BoolVar(&noBannerShort, "n", false, "Don't show a banner at start")
+	if quicEnabled {
+		flag.BoolVar(&serveJustQUICShort, "u", false, "Serve just QUIC")
+	}
+	flag.BoolVar(&onlyLuaModeShort, "l", false, "Only present the Lua REPL")
+	flag.BoolVar(&redirectShort, "r", false, "Redirect HTTP traffic to HTTPS, if both are enabled")
+
+	flag.Parse()
+
+	// Accept both long and short versions of some flags
+	ac.serveJustHTTP = ac.serveJustHTTP || serveJustHTTPShort
+	ac.autoRefresh = ac.autoRefresh || autoRefreshShort
+	ac.debugMode = ac.debugMode || debugModeShort
+	ac.serverMode = ac.serverMode || serverModeShort
+	ac.useBolt = ac.useBolt || useBoltShort
+	ac.productionMode = ac.productionMode || productionModeShort
+	ac.devMode = ac.devMode || devModeShort
+	ac.showVersion = ac.showVersion || showVersionShort
+	ac.quietMode = ac.quietMode || quietModeShort
+	ac.cacheFileStat = ac.cacheFileStat || cacheFileStatShort
+	ac.simpleMode = ac.simpleMode || simpleModeShort
+	ac.openURLAfterServing = ac.openURLAfterServing || (ac.openExecutable != "")
+	ac.quitAfterFirstRequest = ac.quitAfterFirstRequest || quitAfterFirstRequestShort
+	ac.verboseMode = ac.verboseMode || verboseModeShort
+	ac.noBanner = ac.noBanner || noBannerShort
+	if quicEnabled {
+		ac.serveJustQUIC = ac.serveJustQUIC || serveJustQUICShort
+	}
+	ac.onlyLuaMode = ac.onlyLuaMode || onlyLuaModeShort
+	ac.redirectHTTP = ac.redirectHTTP || redirectShort
+
+	// Serve a single Markdown file once, and open it in the browser
+	if ac.markdownMode {
+		ac.quietMode = true
+		ac.openURLAfterServing = true
+		ac.quitAfterFirstRequest = true
+	}
+
+	// If only using the Lua REPL, don't include the banner, and don't serve anything
+	if ac.onlyLuaMode {
+		ac.noBanner = true
+		ac.debugMode = true
+		ac.serverConfScript = ""
+	}
+
+	// Check if IGNOREEOF is set
+	if ignoreEOF := env.Int("IGNOREEOF", 0); ignoreEOF > 1 {
+		ac.ctrldTwice = true
+	}
+
+	// Disable verbose mode if quiet mode has been enabled
+	if ac.quietMode {
+		ac.verboseMode = false
+	}
+
+	// Enable cache compression unless raw cache is specified
+	ac.cacheCompression = !rawCache
+
+	ac.redisAddrSpecified = ac.redisAddr != ""
+	if ac.redisAddr == "" {
+		// The default host and port
+		ac.redisAddr = host + ac.defaultRedisColonPort
+	}
+
+	// May be overridden by devMode
+	if ac.serverMode {
+		ac.debugMode = false
+	}
+
+	if noDatabase {
+		ac.boltFilename = os.DevNull
+	}
+
+	// TODO: If flags are set in addition to -p or -e, don't override those
+	//       when -p or -e is set.
+
+	// Change several defaults if production mode is enabled
+	switch {
+	case ac.productionMode:
+		// Use system directories
+		ac.serverDirOrFilename = "/srv/algernon"
+		ac.serverCert = "/etc/algernon/cert.pem"
+		ac.serverKey = "/etc/algernon/key.pem"
+		ac.cacheMode = cachemode.Production
+		ac.serverMode = true
+	case ac.devMode:
+		// Change several defaults if development mode is enabled
+		ac.serveJustHTTP = true
+		// serverLogFile = defaultLogFile
+		ac.debugMode = true
+		// TODO: Make it possible to set --limit to the default limit also when -e is used
+		if ac.limitRequests == ac.defaultLimit {
+			ac.limitRequests = 700 // Increase the rate limit considerably
+		}
+		ac.cacheMode = cachemode.Development
+	case ac.simpleMode:
+		ac.useBolt = true
+		ac.boltFilename = os.DevNull
+		ac.serveJustHTTP = true
+		ac.serverMode = true
+		ac.cacheMode = cachemode.Off
+		ac.noCache = true
+		ac.disableRateLimiting = true
+		ac.clearDefaultPathPrefixes = true
+		ac.noHeaders = true
+		ac.writeTimeout = 3600 * 24
+	}
+
+	if ac.onlyLuaMode {
+		// Use a random database, so that several lua REPLs can be started without colliding,
+		// but only if the current default bolt database file can not be opened.
+		if ac.boltFilename != os.DevNull && !files.CanRead(ac.boltFilename) {
+			tempFile, err := os.CreateTemp("", "algernon_repl*.db")
+			if err == nil { // no issue
+				ac.boltFilename = tempFile.Name()
+			}
+		}
+	}
+
+	// If a watch directory is given, enable the auto refresh feature
+	if ac.autoRefreshDir != "" {
+		ac.autoRefresh = true
+	}
+
+	// If nocache is given, disable the cache
+	if ac.noCache {
+		ac.cacheMode = cachemode.Off
+		ac.cacheFileStat = false
+	}
+
+	// Convert the request limit to a string
+	ac.limitRequestsString = strconv.FormatInt(ac.limitRequests, 10)
+
+	// If auto-refresh is enabled, change the caching
+	if ac.autoRefresh {
+		if cacheModeString == "" {
+			// Disable caching by default, when auto-refresh is enabled
+			ac.cacheMode = cachemode.Off
+			ac.cacheFileStat = false
+		}
+	}
+
+	// The cache flag overrides the settings from the other modes
+	if cacheModeString != "" {
+		ac.cacheMode = cachemode.New(cacheModeString)
+	}
+
+	// Disable cache entirely if cacheSize is set to 0
+	if ac.cacheSize == 0 {
+		ac.cacheMode = cachemode.Off
+	}
+
+	// Set cacheSize to 0 if the cache is disabled
+	if ac.cacheMode == cachemode.Off {
+		ac.cacheSize = 0
+	}
+
+	// If cache mode is unset, use the dev mode
+	if ac.cacheMode == cachemode.Unset {
+		ac.cacheMode = cachemode.Default
+	}
+
+	if ac.cacheMode == cachemode.Small {
+		ac.cacheMaxEntitySize = ac.defaultCacheMaxEntitySize
+	}
+
+	// For backward compatibility with previous versions of Algernon
+	// TODO: Remove, in favor of a better config/flag system
+	serverAddrChanged := false
+	if len(flag.Args()) >= 1 {
+		// Only override the default server directory if Algernon can find it
+		firstArg := flag.Args()[0]
+		fs := datablock.NewFileStat(ac.cacheFileStat, ac.defaultStatCacheRefresh)
+		// Interpret as a file or directory
+		if fs.IsDir(firstArg) || fs.Exists(firstArg) {
+			if strings.HasSuffix(firstArg, string(os.PathSeparator)) {
+				ac.serverDirOrFilename = firstArg[:len(firstArg)-1]
+			} else {
+				ac.serverDirOrFilename = firstArg
+			}
+		} else if strings.Contains(firstArg, ":") {
+			// Interpret as the server address
+			ac.serverAddr = firstArg
+			serverAddrChanged = true
+		} else if _, err := strconv.Atoi(firstArg); err == nil { // no error
+			// Is a number. Interpret as the server address
+			ac.serverAddr = ":" + firstArg
+			serverAddrChanged = true
+		}
+	}
+
+	// Clean up path in ac.serverDirOrFilename
+	// .Rel calls .Clean on the result.
+	if pwd, err := os.Getwd(); err == nil { // no error
+		if cleanPath, err := filepath.Rel(pwd, ac.serverDirOrFilename); err == nil { // no error
+			ac.serverDirOrFilename = cleanPath
+		}
+	}
+
+	// TODO: Replace the code below with a good config/flag package.
+	shift := 0
+	if serverAddrChanged {
+		shift = 1
+	}
+	if len(flag.Args()) >= 2 {
+		secondArg := flag.Args()[1]
+		if strings.Contains(secondArg, ":") {
+			ac.serverAddr = secondArg
+		} else if _, err := strconv.Atoi(secondArg); err == nil { // no error
+			// Is a number. Interpret as the server address.
+			ac.serverAddr = ":" + secondArg
+		} else if len(flag.Args()) >= 3-shift {
+			ac.serverCert = flag.Args()[2-shift]
+		}
+	}
+	if len(flag.Args()) >= 4-shift {
+		ac.serverKey = flag.Args()[3-shift]
+	}
+	if len(flag.Args()) >= 5-shift {
+		ac.redisAddr = flag.Args()[4-shift]
+		ac.redisAddrSpecified = true
+	}
+	if len(flag.Args()) >= 6-shift {
+		// Convert the dbindex from string to int
+		DBindex, err := strconv.Atoi(flag.Args()[5-shift])
+		if err != nil {
+			ac.redisDBindex = DBindex
+		}
+	}
+
+	// Use the default openExecutable if none is set
+	if ac.openURLAfterServing && ac.openExecutable == "" {
+		ac.openExecutable = ac.defaultOpenExecutable
+	}
+
+	// Add the serverConfScript to the list of configuration scripts to be read and executed
+	if ac.serverConfScript != "" && ac.serverConfScript != os.DevNull {
+		ac.serverConfigurationFilenames = append(ac.serverConfigurationFilenames, ac.serverConfScript, filepath.Join(ac.serverDirOrFilename, ac.serverConfScript))
+	}
+
+	ac.serverHost = host
+
+	// CertMagic and Let's Encrypt
+	if ac.useCertMagic {
+		log.Info("Use Cert Magic")
+		if dirEntries, err := os.ReadDir(ac.serverDirOrFilename); err != nil {
+			log.Error("Could not use Cert Magic:" + err.Error())
+			ac.useCertMagic = false
+		} else {
+			// log.Infof("Looping over %v files", len(files))
+			for _, dirEntry := range dirEntries {
+				basename := filepath.Base(dirEntry.Name())
+				dirOrSymlink := dirEntry.IsDir() || ((dirEntry.Type() & os.ModeSymlink) == os.ModeSymlink)
+				// TODO: Confirm that the symlink is a symlink to a directory, if it's a symlink
+				if dirOrSymlink && strings.Contains(basename, ".") && !strings.HasPrefix(basename, ".") && !strings.HasSuffix(basename, ".old") {
+					ac.certMagicDomains = append(ac.certMagicDomains, basename)
+				}
+			}
+			// Using Let's Encrypt implies --domain, to search for suitable directories in the directory to be served
+			ac.serverAddDomain = true
+		}
+	}
+}
+
+// Set the values that has not been set by flags nor scripts (and can be set by both)
+// Returns true if a "ready function" has been run.
+func (ac *Config) finalConfiguration(host string) bool {
+	// Set the server host and port (commandline flags overrides Lua configuration)
+	if ac.serverAddr == "" {
+		if ac.serverAddrLua != "" {
+			ac.serverAddr = ac.serverAddrLua
+		} else {
+			ac.serverAddr = host + ac.defaultWebColonPort
+		}
+	}
+
+	// Set the event server host and port
+	if ac.eventAddr == "" {
+		ac.eventAddr = host + ac.defaultEventColonPort
+	}
+
+	// Turn off debug mode if production mode is enabled
+	if ac.productionMode {
+		// Turn off debug mode
+		ac.debugMode = false
+	}
+
+	hasReadyFunction := ac.serverReadyFunctionLua != nil
+
+	// Run the Lua function specified with the OnReady function, if available
+	if hasReadyFunction {
+		// Useful for outputting configuration information after both
+		// configuration scripts have been run and flags have been parsed
+		ac.serverReadyFunctionLua()
+	}
+
+	return hasReadyFunction
+}

+ 53 - 0
engine/funcmap.go

@@ -0,0 +1,53 @@
+package engine
+
+import (
+	"html/template"
+	"net/http"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/datablock"
+)
+
+// Functions for concurrent use by rendering.go and handlers.go
+
+// Lua2funcMap runs in a Lua file and returns the functions as a template.FuncMap (or an error)
+func (ac *Config) Lua2funcMap(w http.ResponseWriter, req *http.Request, filename, luafilename, ext string, errChan chan error, funcMapChan chan template.FuncMap) {
+	// Make functions from the given Lua data available
+	funcs := make(template.FuncMap)
+
+	// Try reading data.lua, if possible
+	luablock, err := ac.cache.Read(luafilename, ac.shouldCache(ext))
+	if err != nil {
+		// Could not find and/or read data.lua
+		luablock = datablock.EmptyDataBlock
+
+		// This only means the file wasn't cached, so just ignore this error
+	}
+
+	// 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 {
+			funcMapChan <- funcs
+			errChan <- 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
+			log.Info(s)
+		}
+	}
+	funcMapChan <- funcs
+	errChan <- err
+}

+ 564 - 0
engine/handlers.go

@@ -0,0 +1,564 @@
+package engine
+
+import (
+	"fmt"
+	"html/template"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/didip/tollbooth"
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/themes"
+	"github.com/xyproto/algernon/utils"
+	"github.com/xyproto/datablock"
+	"github.com/xyproto/recwatch"
+	"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
+)
+
+// 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("Content-Type", "text/html;charset=utf-8")
+	pongoblock, err := ac.cache.Read(filename, ac.shouldCache(ext))
+	if err != nil {
+		if ac.debugMode {
+			fmt.Fprintf(w, "Unable to read %s: %s", filename, err)
+		} else {
+			log.Errorf("Unable to read %s: %s", filename, err)
+		}
+		return
+	}
+
+	// Make the functions in luaDataFilename available for the Pongo2 template
+
+	luafilename := filepath.Join(filepath.Dir(filename), ac.defaultLuaDataFilename)
+	if ac.fs.Exists(ac.defaultLuaDataFilename) {
+		luafilename = ac.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 {
+				log.Error(err)
+			}
+			return
+		}
+
+		// Render the Pongo2 page, using functions from luaDataFilename, if available
+		ac.pongomutex.Lock()
+		ac.PongoPage(w, req, filename, pongoblock.Bytes(), funcs)
+		ac.pongomutex.Unlock()
+
+		return
+	}
+
+	// Output a warning if something different from default has been given
+	if !strings.HasSuffix(luafilename, ac.defaultLuaDataFilename) {
+		log.Warn("Could not read ", luafilename)
+	}
+
+	// Use the Pongo2 template without any Lua functions
+	ac.pongomutex.Lock()
+	funcs := make(template.FuncMap)
+	ac.PongoPage(w, req, filename, pongoblock.Bytes(), funcs)
+	ac.pongomutex.Unlock()
+}
+
+// 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", filename, err)
+		} else {
+			log.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, _ string) {
+	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
+	switch ext {
+
+	// HTML pages are handled differently, if auto-refresh has been enabled
+	case ".html", ".htm":
+		w.Header().Add("Content-Type", "text/html;charset=utf-8")
+
+		// 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":
+		w.Header().Add("Content-Type", "text/html;charset=utf-8")
+		if markdownblock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil { // if no error
+			// Render the markdown page
+			ac.MarkdownPage(w, req, markdownblock.Bytes(), filename)
+		}
+		return
+
+	case ".frm", ".form":
+		w.Header().Add("Content-Type", "text/html;charset=utf-8")
+		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("Content-Type", "text/html;charset=utf-8")
+		amberblock, err := ac.ReadAndLogErrors(w, filename, ext)
+		if err != nil {
+			return
+		}
+
+		// Try reading luaDataFilename as well, if possible
+		luafilename := filepath.Join(filepath.Dir(filename), ac.defaultLuaDataFilename)
+		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 {
+					log.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
+				log.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 _, err := os.Create(testfile); err == nil { // success
+			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 { // no error
+			firstname := path.Base(filename)
+			if strings.HasSuffix(filename, ".alg") {
+				firstname = path.Base(filename[:len(filename)-4])
+			}
+			serveDir := path.Join(webApplicationExtractionDir, firstname)
+			log.Warn(".alg web applications must be given as an argument to algernon to be served correctly")
+			ac.DirPage(w, req, serveDir, serveDir, ac.defaultTheme)
+		}
+		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() {
+				utils.WriteRecorder(w, recorder)
+				recwatch.Flush(w)
+			}
+			// 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)
+				}
+				// Then write to the ResponseWriter
+				utils.WriteRecorder(w, recorder)
+			}
+		} else {
+			// The flush function just flushes the ResponseWriter
+			flushFunc := func() {
+				recwatch.Flush(w)
+			}
+			// 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) {
+					log.Error("Error at " + err.Error())
+				} else {
+					log.Error("Error in " + filename + ": " + err.Error())
+				}
+			}
+		}
+		return
+
+	case ".gcss":
+		if gcssblock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil { // if no error
+			w.Header().Add("Content-Type", "text/css;charset=utf-8")
+			// Render the GCSS page as CSS
+			ac.GCSSPage(w, req, filename, gcssblock.Bytes())
+		}
+		return
+
+	case ".scss":
+		if scssblock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil { // if no error
+			// Render the SASS page (with .scss extension) as CSS
+			w.Header().Add("Content-Type", "text/css;charset=utf-8")
+			ac.SCSSPage(w, req, filename, scssblock.Bytes())
+		}
+		return
+
+	case ".happ", ".hyper", ".hyper.jsx", ".hyper.js": // hyperApp JSX -> JS, wrapped in HTML
+		if jsxblock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil { // if no error
+			// Render the JSX page as HTML with embedded JavaScript
+			w.Header().Add("Content-Type", "text/html;charset=utf-8")
+			ac.HyperAppPage(w, req, filename, jsxblock.Bytes())
+		} else {
+			log.Error("Error when serving " + filename + ":" + err.Error())
+		}
+		return
+
+	// This case must come after the .hyper.jsx case
+	case ".jsx":
+		if jsxblock, err := ac.ReadAndLogErrors(w, filename, ext); err == nil { // if no error
+			// Render the JSX page as JavaScript
+			w.Header().Add("Content-Type", "text/javascript;charset=utf-8")
+			ac.JSXPage(w, req, filename, jsxblock.Bytes())
+		}
+		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("Content-Type", "text/plain;charset=utf-8")
+
+	// Source files that may be used by web pages
+	case ".js":
+		w.Header().Add("Content-Type", "text/javascript;charset=utf-8")
+
+	// JSON
+	case ".json":
+		w.Header().Add("Content-Type", "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", ".ts", ".tsx":
+		// Set headers for displaying it in the browser.
+		w.Header().Set("Content-Type", "text/plain;charset=utf-8")
+
+	// 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("Content-Type", "text/plain;charset=utf-8")
+		} else {
+			// Set the correct Content-Type
+			if ac.mimereader != nil {
+				ac.mimereader.SetHeader(w, ext)
+			} else {
+				log.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 {
+		log.Error("Could not open " + filename + "! " + err.Error())
+		return
+	}
+	defer f.Close()
+	fInfo, err := f.Stat()
+	if err != nil {
+		log.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 {
+		// log.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(w, 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 { // if no error
+		// Serve the file
+		dataBlock.ToClient(w, req, filename, ac.ClientCanGzip(req), gzipThreshold)
+	} else {
+		log.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
+	allRequests := func(w http.ResponseWriter, req *http.Request) {
+		// Rejecting requests is handled by the permission system, which
+		// in turn requires a database backend.
+		if ac.perm != nil {
+			if ac.perm.Rejected(w, req) {
+				// Prepare to count bytes written
+				sc := sheepcounter.New(w)
+				// Get and call the Permission Denied function
+				ac.perm.DenyFunction()(sc, req)
+				// Log the response
+				ac.LogAccess(req, http.StatusForbidden, sc.Counter())
+				// Reject the request by just returning
+				return
+			}
+		}
+
+		// Local to this function
+		servedir := servedir
+
+		// Look for the directory that is named the same as the host
+		if addDomain {
+			servedir = filepath.Join(servedir, utils.GetDomain(req))
+		}
+
+		urlpath := req.URL.Path
+
+		//log.Debugln("Checking reverse proxy", urlpath, ac.reverseProxyConfig)
+		if ac.reverseProxyConfig != nil {
+			if rproxy := ac.reverseProxyConfig.FindMatchingReverseProxy(urlpath); rproxy != nil {
+				//log.Debugf("Querying reverse proxy %+v, %+v\n", rproxy, req)
+				res, err := rproxy.DoProxyPass(*req)
+				if err != nil {
+					w.WriteHeader(http.StatusBadGateway)
+					w.Write([]byte("reverse proxy error, please check your server config for AddReverseProxy calls\n"))
+					return
+				}
+				data, err := io.ReadAll(res.Body)
+				if err != nil {
+					w.WriteHeader(http.StatusInternalServerError)
+					return
+				}
+				res.Body.Close()
+				for k, vals := range res.Header {
+					for _, v := range vals {
+						w.Header().Set(k, v)
+					}
+				}
+				w.WriteHeader(res.StatusCode)
+				w.Write(data)
+				return
+			}
+		}
+
+		filename := utils.URL2filename(servedir, urlpath)
+		// Remove the trailing slash from the filename, if any
+		noslash := filename
+		if strings.HasSuffix(filename, utils.Pathsep) {
+			noslash = filename[:len(filename)-1]
+		}
+		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)
+			// 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, ac.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("text/html;charset=utf-8")
+		mux.Handle(handlePath, tollbooth.LimitFuncHandler(limiter, allRequests))
+	}
+}

+ 628 - 0
engine/help.go

@@ -0,0 +1,628 @@
+package engine
+
+import (
+	"fmt"
+	"os"
+)
+
+const generalHelpText = `Available functions:
+
+Data structures
+
+// Get or create database-backed Set (takes a name, returns a set object)
+Set(string) -> userdata
+// Add an element to the set
+set:add(string)
+// Remove an element from the set
+set:del(string)
+// Check if a set contains a value.
+// Returns true only if the value exists and there were no errors.
+set:has(string) -> bool
+// Get all members of the set
+set:getall() -> table
+// Remove the set itself. Returns true if successful.
+set:remove() -> bool
+// Clear the set. Returns true if successful.
+set:clear() -> bool
+
+// Get or create a database-backed List (takes a name, returns a list object)
+List(string) -> userdata
+// Add an element to the list
+list:add(string)
+// Get all members of the list
+list:getall() -> table
+// Get the last element of the list. The returned value can be empty
+list:getlast() -> string
+// Get the N last elements of the list
+list:getlastn(number) -> table
+// Remove the list itself. Returns true if successful.
+list:remove() -> bool
+// Clear the list. Returns true if successful.
+list:clear() -> bool
+// Return all list elements (expected to be JSON strings) as a JSON list
+list:json() -> string
+
+// Get or create a database-backed HashMap
+// (takes a name, returns a hash map object)
+HashMap(string) -> userdata
+// For a given element id (for instance a user id), set a key.
+// Returns true if successful.
+hash:set(string, string, string) -> bool
+// For a given element id (for instance a user id), and a key, return a value.
+hash:get(string, string) -> string
+// For a given element id (for instance a user id), and a key,
+// check if the key exists in the hash map.
+hash:has(string, string) -> bool
+// For a given element id (for instance a user id), check if it exists.
+hash:exists(string) -> bool
+// Get all keys of the hash map
+hash:getall() -> table
+// Remove a key for an entry in a hash map. Returns true if successful
+hash:delkey(string, string) -> bool
+// Remove an element (for instance a user). Returns true if successful
+hash:del(string) -> bool
+// Remove the hash map itself. Returns true if successful.
+hash:remove() -> bool
+// Clear the hash map. Returns true if successful.
+hash:clear() -> bool
+
+// Get or create a database-backed KeyValue collection
+// (takes a name, returns a key/value object)
+KeyValue(string) -> userdata
+// Set a key and value. Returns true if successful.
+kv:set(string, string) -> bool
+// Takes a key, returns a value. May return an empty string.
+kv:get(string) -> string
+// Takes a key, returns the value+1.
+// Creates a key/value and returns "1" if it did not already exist.
+kv:inc(string) -> string
+// Remove a key. Returns true if successful.
+kv:del(string) -> bool
+// Remove the KeyValue itself. Returns true if successful.
+kv:remove() -> bool
+// Clear the KeyValue. Returns true if successful.
+kv:clear() -> bool
+
+Live server configuration
+
+// Reset the URL prefixes and make everything *public*.
+ClearPermissions()
+// Add an URL prefix that will have *admin* rights.
+AddAdminPrefix(string)
+// Add an URL prefix that will have *user* rights.
+AddUserPrefix(string)
+// Provide a lua function that will be used as the permission denied handler.
+DenyHandler(function)
+// Direct the logging to the given filename. If the filename is an empty
+// string, direct logging to stderr. Returns true if successful.
+LogTo(string) -> bool
+// Add a reverse proxy given a path prefix and an endpoint URL
+AddReverseProxy(string, string)
+
+Output
+
+// Log the given strings as info. Takes a variable number of strings.
+log(...)
+// Log the given strings as a warning. Takes a variable number of strings.
+warn(...)
+// Log the given strings as an error. Takes a variable number of strings.
+err(...)
+// Output text. Takes a variable number of strings.
+print(...)
+// Output rendered HTML given Markdown. Takes a variable number of strings.
+mprint(...)
+// Output rendered HTML given Amber. Takes a variable number of strings.
+aprint(...)
+// Output rendered CSS given GCSS. Takes a variable number of strings.
+gprint(...)
+// Output rendered JavaScript given JSX for HyperApp. Takes a variable number of strings.
+hprint(...)
+// Output rendered JavaScript given JSX for React. Takes a variable number of strings.
+jprint(...)
+// Output a Pongo2 template and key/value table as rendered HTML. Use "{{ key }}" to insert a key.
+poprint(string[, table])
+// Output a simple HTML page with a message, title and theme.
+msgpage(string[, string][, string])
+
+Cache
+
+CacheInfo() -> string // Return information about the file cache.
+ClearCache() // Clear the file cache.
+preload(string) -> bool // Load a file into the cache, returns true on success.
+
+JSON
+
+// Use, or create, a JSON document/file.
+JFile(filename) -> userdata
+// Retrieve a string, given a valid JSON path. May return an empty string.
+jfile:getstring(string) -> string
+// Retrieve a JSON node, given a valid JSON path. May return nil.
+jfile:getnode(string) -> userdata
+// Retrieve a value, given a valid JSON path. May return nil.
+jfile:get(string) -> value
+// Change an entry given a JSON path and a value. Returns true if successful.
+jfile:set(string, string) -> bool
+// Given a JSON path (optional) and JSON data, add it to a JSON list.
+// Returns true if successful.
+jfile:add([string, ]string) -> bool
+// Removes a key in a map in a JSON document. Returns true if successful.
+jfile:delkey(string) -> bool
+// Convert a Lua table with strings or ints to JSON.
+// Takes an optional number of spaces to indent the JSON data.
+json(table[, number]) -> string
+// Create a JSON document node.
+JNode() -> userdata
+// Add JSON data to a node. The first argument is an optional JSON path.
+// The second argument is a JSON data string. Returns true on success.
+// "x" is the default JSON path.
+jnode:add([string, ]string) ->
+// Given a JSON path, retrieves a JSON node.
+jnode:get(string) -> userdata
+// Given a JSON path, retrieves a JSON string.
+jnode:getstring(string) -> string
+// Given a JSON path and a JSON string, set the value.
+jnode:set(string, string)
+// Given a JSON path, remove a key from a map.
+jnode:delkey(string) -> bool
+// Return the JSON data, nicely formatted.
+jnode:pretty() -> string
+// Return the JSON data, as a compact string.
+jnode:compact() -> string
+// Sends JSON data to the given URL. Returns the HTTP status code as a string.
+// The content type is set to "application/json;charset=utf-8".
+// The second argument is an optional authentication token that is used for the
+// Authorization header field. Uses HTTP POST.
+jnode:POST(string[, string]) -> string
+// Sends JSON data to the given URL. Returns the HTTP status code as a string.
+// The content type is set to "application/json;charset=utf-8".
+// The second argument is an optional authentication token that is used for the
+// Authorization header field. Uses HTTP PUT.
+jnode:PUT(string[, string]) -> string
+// Alias for jnode:POST
+jnode:send(string[, string]) -> string
+// Fetches JSON over HTTP given an URL that starts with http or https.
+// The JSON data is placed in the JNode. Returns the HTTP status code as a string.
+jnode:GET(string) -> string
+// Alias for jnode:GET
+jnode:receive(string) -> string
+// Convert from a simple Lua table to a JSON string
+JSON(table) -> string
+
+HTTP Requests
+
+// Create a new HTTP Client object
+HTTPClient() -> userdata
+// Select Accept-Language (ie. "en-us")
+hc:SetLanguage(string)
+// Set the request timeout (in milliseconds)
+hc:SetTimeout(number)
+// Set a cookie (name and value)
+hc:SetCookie(string, string)
+// Set the user agent (ie. "curl")
+hc:SetUserAgent(string)
+// Perform a HTTP GET request. First comes the URL, then an optional table with
+// URL paramets, then an optional table with HTTP headers.
+hc:Get(string, [table], [table]) -> string
+// Perform a HTTP POST request. It's the same arguments as for hc:Get, except
+// the fourth optional argument is the POST body.
+hc:Post(string, [table], [table], [string]) -> string
+// Like hc:Get, except the first argument is the HTTP method (like "PUT")
+hc:Do(string, string, [table], [table]) -> string
+// Shorthand for HTTPClient():Get(). Retrieve an URL, with optional tables for
+// URL parameters and HTTP headers.
+GET(string, [table], [table]) -> string
+// Shorthand for HTTPClient():Post(). Post to an URL, with optional tables for
+// URL parameters and HTTP headers, followed by a string for the body.
+POST(string, [table], [table], [string]) -> string
+// Shorthand for HTTPClient():Do(). Like Get, but the first argument is the
+// method, like ie. "PUT".
+DO(string, string, [table], [table]) -> string
+
+Plugins
+
+// Load a plugin given the path to an executable. Returns true if successful.
+// Will return the plugin help text if called on the Lua prompt. Pass true as
+// the last argument to keep it running.
+Plugin(string, [bool]) -> bool
+// Returns the Lua code as returned by the Lua.Code function in the plugin,
+// given a plugin path. Pass true as the last argument to keep it running.
+// May return an empty string.
+PluginCode(string, [bool]) -> string
+// Takes a plugin path, function name and arguments. Returns an empty string
+// if the function call fails, or the results as a JSON string if successful.
+CallPlugin(string, string, ...) -> string
+
+Code libraries
+
+// Create or use a code library object. Takes an optional data structure name.
+CodeLib([string]) -> userdata
+// Given a namespace and Lua code, add the given code to the namespace.
+// Returns true if successful.
+codelib:add(string, string) -> bool
+// Given a namespace and Lua code, set the given code as the only code
+// in the namespace. Returns true if successful.
+codelib:set(string, string) -> bool
+// Given a namespace, return Lua code, or an empty string.
+codelib:get(string) -> string
+// Import (eval) code from the given namespace into the current Lua state.
+// Returns true if successful.
+codelib:import(string) -> bool
+// Completely clear the code library. Returns true if successful.
+codelib:clear() -> bool
+
+Various
+
+// Return a string with various server information
+ServerInfo() -> string
+// Return the version string for the server
+version() -> string
+// Tries to extract and print the contents of the given Lua values
+pprint(...)
+// Sleep the given number of seconds (can be a float)
+sleep(number)
+// Return the number of nanoseconds from 1970 ("Unix time")
+unixnano() -> number
+// Convert Markdown to HTML
+markdown(string) -> string
+// Query a PostgreSQL database with a query and a connection string.
+// Default connection string: "host=localhost port=5432 user=postgres dbname=test sslmode=disable"
+PQ([string], [string]) -> table
+// Query a MSSQL database with a query and a connection string.
+// Default connection string: "server=localhost;user=user;password=password,port=1433"
+MSSQL([string], [string]) -> table
+
+REPL-only
+
+// Output the current working directory
+cwd | pwd
+// Output the current file or directory that is being served
+serverdir | serverfile
+// Exit Algernon
+exit | halt | quit | shutdown
+
+Extra
+
+// Takes a Python filename, executes the script with the "python" binary in the Path.
+// Returns the output as a Lua table, where each line is an entry.
+py(string) -> table
+// Takes one or more system commands (possibly separated by ";") and runs them.
+// Returns the output lines as a table.
+run(string) -> table
+// Lists the keys and values of a Lua table. Returns a string.
+// Lists the contents of the global namespace "_G" if no arguments are given.
+dir([table]) -> string
+`
+
+const usageMessage = `
+Type "webhelp" for an overview of functions that are available when
+handling requests. Or "confighelp" for an overview of functions that are
+available when configuring an Algernon application.
+`
+
+const webHelpText = `Available functions:
+
+Handling users and permissions
+
+// Check if the current user has "user" rights
+UserRights() -> bool
+// Check if the given username exists (does not check unconfirmed users)
+HasUser(string) -> bool
+// Check if the given username exists in the list of unconfirmed users
+HasUnconfirmedUser(string) -> bool
+// Get the value from the given boolean field
+// Takes a username and field name
+BooleanField(string, string) -> bool
+// Save a value as a boolean field
+// Takes a username, field name and boolean value
+SetBooleanField(string, string, bool)
+// Check if a given username is confirmed
+IsConfirmed(string) -> bool
+// Check if a given username is logged in
+IsLoggedIn(string) -> bool
+// Check if the current user has "admin rights"
+AdminRights() -> bool
+// Check if a given username is an admin
+IsAdmin(string) -> bool
+// Get the username stored in a cookie, or an empty string
+UsernameCookie() -> string
+// Store the username in a cookie, returns true if successful
+SetUsernameCookie(string) -> bool
+// Clear the login cookie
+ClearCookie()
+// Get a table containing all usernames
+AllUsernames() -> table
+// Get the email for a given username, or an empty string
+Email(string) -> string
+// Get the password hash for a given username, or an empty string
+PasswordHash(string) -> string
+// Get all unconfirmed usernames
+AllUnconfirmedUsernames() -> table
+// Get the existing confirmation code for a given user,
+// or an empty string. Takes a username.
+ConfirmationCode(string) -> string
+// Add a user to the list of unconfirmed users.
+// Takes a username and a confirmation code.
+// Remember to also add a user, when registering new users.
+AddUnconfirmed(string, string)
+// Remove a user from the list of unconfirmed users. Takes a username.
+RemoveUnconfirmed(string)
+// Mark a user as confirmed. Takes a username.
+MarkConfirmed(string)
+// Removes a user. Takes a username.
+RemoveUser(string)
+// Make a user an admin. Takes a username.
+SetAdminStatus(string)
+// Make an admin user a regular user. Takes a username.
+RemoveAdminStatus(string)
+// Add a user. Takes a username, password and email.
+AddUser(string, string, string)
+// Set a user as logged in on the server (not cookie). Takes a username.
+SetLoggedIn(string)
+// Set a user as logged out on the server (not cookie). Takes a username.
+SetLoggedOut(string)
+// Log in a user, both on the server and with a cookie. Takes a username.
+Login(string)
+// Log out a user, on the server (which is enough). Takes a username.
+Logout(string)
+// Get the current username, from the cookie
+Username() -> string
+// Get the current cookie timeout. Takes a username.
+CookieTimeout(string) -> number
+// Set the current cookie timeout. Takes a timeout, in seconds.
+SetCookieTimeout(number)
+// Get the current server-wide cookie secret, for persistent logins
+CookieSecret() -> string
+// Set the current server-side cookie secret, for persistent logins
+SetCookieSecret(string)
+// Get the current password hashing algorithm (bcrypt, bcrypt+ or sha256)
+PasswordAlgo() -> string
+// Set the current password hashing algorithm (bcrypt, bcrypt+ or sha256)
+// Takes a string
+SetPasswordAlgo(string)
+// Hash the password
+// Takes a username and password (username can be used for salting)
+HashPassword(string, string) -> string
+// Change the password for a user, given a username and a new password
+SetPassword(string, string)
+// Check if a given username and password is correct
+// Takes a username and password
+CorrectPassword(string, string) -> bool
+// Checks if a confirmation code is already in use
+// Takes a confirmation code
+AlreadyHasConfirmationCode(string) -> bool
+// Find a username based on a given confirmation code,
+// or returns an empty string. Takes a confirmation code
+FindUserByConfirmationCode(string) -> string
+// Mark a user as confirmed
+// Takes a username
+Confirm(string)
+// Mark a user as confirmed, returns true if successful
+// Takes a confirmation code
+ConfirmUserByConfirmationCode(string) -> bool
+// Set the minimum confirmation code length
+// Takes the minimum number of characters
+SetMinimumConfirmationCodeLength(number)
+// Generates a unique confirmation code, or an empty string
+GenerateUniqueConfirmationCode() -> string
+
+File uploads
+
+// Creates a file upload object. Takes a form ID (from a POST request) as the
+// first parameter. Takes an optional maximum upload size (in MiB) as the
+// second parameter. Returns nil and an error string on failure, or userdata
+// and an empty string on success.
+UploadedFile(string[, number]) -> userdata, string
+// Return the uploaded filename, as specified by the client
+uploadedfile:filename() -> string
+// Return the size of the data that has been received
+uploadedfile:size() -> number
+// Return the mime type of the uploaded file, as specified by the client
+uploadedfile:mimetype() -> string
+// Return the full textual content of the uploaded file
+uploadedfile:content() -> string
+// Save the uploaded data locally. Takes an optional filename.
+uploadedfile:save([string]) -> bool
+// Save the uploaded data as the client-provided filename, in the specified
+// directory. Takes a relative or absolute path. Returns true on success.
+uploadedfile:savein(string)  -> bool
+
+Handling requests
+
+// Set the Content-Type for a page.
+content(string)
+// Return the requested HTTP method (GET, POST etc).
+method() -> string
+// Output text to the browser/client. Takes a variable number of strings.
+print(...)
+// Return the requested URL path.
+urlpath() -> string
+// Return the HTTP header in the request, for a given key, or an empty string.
+header(string) -> string
+// Set an HTTP header given a key and a value.
+setheader(string, string)
+// Return the HTTP headers, as a table.
+headers() -> table
+// Return the HTTP body in the request
+// (will only read the body once, since it's streamed).
+body() -> string
+// Set a HTTP status code (like 200 or 404).
+// Must be used before other functions that writes to the client!
+status(number)
+// Set a HTTP status code and output a message (optional).
+error(number[, string])
+// Return the directory where the script is running. If a filename (optional)
+// is given, then the path to where the script is running, joined with a path
+// separator and the given filename, is returned.
+scriptdir([string]) -> string
+// Return the directory where the server is running. If a filename (optional)
+// is given, then the path to where the server is running, joined with a path
+// separator and the given filename, is returned.
+serverdir([string]) -> string
+// Serve a file that exists in the same directory as the script.
+serve(string)
+// Serve a Pongo2 template file, with an optional table with key/values.
+serve2(string[, table)
+// Return the rendered contents of a file that exists in the same directory
+// as the script. Takes a filename.
+render(string) -> string
+// Return a table with keys and values as given in a posted form, or as given
+// in the URL ("/some/page?x=7" makes "x" with the value "7" available).
+formdata() -> table
+// Redirect to an absolute or relative URL. Also takes a HTTP status code.
+redirect(string[, number])
+// Permanently redirect to an absolute or relative URL. Uses status code 302.
+permanent_redirect(string)
+// Send "Connection: close" as a header to the client, flush the body and also
+// stop Lua functions from writing more data to the HTTP body.
+close()
+// Transmit what has been outputted so far, to the client.
+flush()
+`
+
+const configHelpText = `Available functions:
+
+Only available when used in serverconf.lua
+
+// Set the default address for the server on the form [host][:port].
+SetAddr(string)
+// Reset the URL prefixes and make everything *public*.
+ClearPermissions()
+// Add an URL prefix that will have *admin* rights.
+AddAdminPrefix(string)
+// Add an URL prefix that will have *user* rights.
+AddUserPrefix(string)
+// Provide a lua function that will be used as the permission denied handler.
+DenyHandler(function)
+// Provide a lua function that will be run once,
+// when the server is ready to start serving.
+OnReady(function)
+// Use a Lua file for setting up HTTP handlers instead of using the directory structure.
+ServerFile(string) -> bool
+// Get the cookie secret from the server configuration.
+CookieSecret() -> string
+// Set the cookie secret that will be used when setting and getting browser cookies.
+SetCookieSecret(string)
+
+`
+
+func generateUsageFunction(ac *Config) func() {
+	return func() {
+		fmt.Println("\n" + ac.versionString + "\n\n" + ac.description)
+
+		var quicExample string
+		var quicUsageOrMessage string
+		var quicFinalMessage string
+
+		// Prepare and/or output a message, depending on if QUIC support is compiled in or not
+		if quicEnabled {
+			quicUsageOrMessage = "\n  -u                           Serve over QUIC / HTTP3."
+			quicExample = "\n  Serve the current dir over QUIC, port 7000, no banner:\n    algernon -s -u -n . :7000\n"
+		} else {
+			quicFinalMessage = "\n\nThis Algernon executable was built without QUIC support."
+		}
+
+		// Possible arguments are also, for backward compatibility:
+		// server dir, server addr, certificate file, key file, redis addr and redis db index
+		// They are not mentioned here, but are possible to use, in that strict order.
+		fmt.Println(`
+Syntax:
+  algernon [flags] [file or directory to serve] [host][:port]
+
+Available flags:
+  -a, --autorefresh            Enable event server and auto-refresh feature.
+                               Sets cache mode to "images".
+  -b, --bolt                   Use "` + ac.defaultBoltFilename + `"
+                               for the Bolt database.
+  -c, --statcache              Speed up responses by caching os.Stat.
+                               Only use if served files will not be removed.
+  -d, --debug                  Enable debug mode (show errors in the browser).
+  -e, --dev                    Development mode: Enables Debug mode, uses
+                               regular HTTP, Bolt and sets cache mode "dev".
+  -h, --help                   This help text
+  -l, --lua                    Don't serve anything, just present the Lua REPL.
+  -m                           View the given Markdown file in the browser.
+                               Quits after the file has been served once.
+                               ("-m" is equivalent to "-q -o -z").
+  -n, --nobanner               Don't display a colorful banner at start.
+  -o, --open=EXECUTABLE        Open the served URL with ` + ac.defaultOpenExecutable + `,
+                               or with the given application.
+  -p, --prod                   Serve HTTP/2+HTTPS on port 443. Serve regular
+                               HTTP on port 80. Uses /srv/algernon for files.
+                               Disables debug mode. Disables auto-refresh.
+                               Enables server mode. Sets cache to "prod".
+  -q, --quiet                  Don't output anything to stdout or stderr.
+  -r, --redirect               Redirect HTTP traffic to HTTPS, if both are enabled.
+  -s, --server                 Server mode (disable debug + interactive mode).
+  -t, --httponly               Serve regular HTTP.` + quicUsageOrMessage + `
+  -v, --version                Application name and version
+  -V, --verbose                Slightly more verbose logging.
+  -z, --quit                   Quit after the first request has been served.
+  --accesslog=FILENAME         Access log filename. Logged in Combined Log Format (CLF).
+  --addr=[HOST][:PORT]         Server host and port ("` + ac.defaultWebColonPort + `" is default)
+  --boltdb=FILENAME            Use a specific file for the Bolt database
+  --cache=MODE                 Sets a cache mode. The default is "on".
+                               "on"      - Cache everything.
+                               "dev"     - Everything, except Amber,
+                                           Lua, GCSS, Markdown and JSX.
+                               "prod"    - Everything, except Amber and Lua.
+                               "small"   - Like "prod", but only files <= 64KB.
+                               "images"  - Only images (png, jpg, gif, svg).
+                               "off"     - Disable caching.
+  --cachesize=N                Set the total cache size, in bytes.
+  --cert=FILENAME              TLS certificate, if using HTTPS.
+  --conf=FILENAME              Lua script with additional configuration.
+  --clear                      Clear the default URI prefixes that are used
+                               when handling permissions.
+  --cookiesecret=STRING        Secret that will be used for login cookies.
+  --ctrld                      Press ctrl-d twice to exit the REPL.
+  --dbindex=INDEX              Redis database index (0 is default).
+  --dir=DIRECTORY              Set the server directory
+  --domain                     Serve files from the subdirectory with the same
+                               name as the requested domain.
+  --eventrefresh=DURATION      How often the event server should refresh
+                               (the default is "` + ac.defaultEventRefresh + `").
+  --eventserver=[HOST][:PORT]  SSE server address (for filesystem changes).
+  --http2only                  Serve HTTP/2, without HTTPS.
+  --internal=FILENAME          Internal log file (can be a bit verbose).
+  --key=FILENAME               TLS key, if using HTTPS.
+  --largesize=N                Threshold for not reading static files into memory, in bytes.
+  --letsencrypt                Use certificates provided by Let's Encrypt for all served
+                               domains and serve over regular HTTPS by using CertMagic.
+  --limit=N                    Limit clients to N requests per second
+                               (the default is ` + ac.defaultLimitString + `).
+  --log=FILENAME               Log to a file instead of to the console.
+  --maria=DSN                  Use the given MariaDB or MySQL host/database.
+  --mariadb=NAME               Use the given MariaDB or MySQL database name.
+  --ncsa=FILENAME              Alternative access log filename. Logged in Common Log Format (NCSA).
+  --nocache                    Another way to disable the caching.
+  --nodb                       No database backend. (same as --boltdb=` + os.DevNull + `).
+  --noheaders                  Don't use the security-related HTTP headers.
+  --nolimit                    Disable rate limiting.
+  --postgres=DSN               Use the given PostgreSQL host/database.
+  --postgresdb=NAME            Use the given PostgreSQL database name.
+  --redis=[HOST][:PORT]        Use "` + ac.defaultRedisColonPort + `" for the Redis database.
+  --rawcache                   Disable cache compression.
+  --servername=STRING          Custom HTTP header value for the Server field.
+  --stricter                   Stricter HTTP headers (same origin policy).
+  --theme=NAME                 Builtin theme to use for Markdown, error pages,
+                               directory listings and HyperApp apps.
+                               Possible values are: light, dark, bw, redbox, wing,
+                               material, neon or werc.
+  --timeout=N                  Timeout when serving files, in seconds.
+  --watchdir=DIRECTORY         Enables auto-refresh for only this directory.
+  -x, --simple                 Serve as regular HTTP, enable server mode and
+                               disable all features that requires a database.
+
+Example usage:
+
+  For auto-refreshing a webpage while developing:
+    algernon --dev --httponly --debug --autorefresh --bolt --server . :4000
+
+  Serve /srv/mydomain.com and /srv/otherweb.com over HTTP and HTTPS + HTTP/2:
+    algernon -c --domain --server --cachesize 67108864 --prod /srv
+` + quicExample + `
+  Serve the current directory over HTTP, port 3000. No limits, cache,
+  permissions or database connections:
+    algernon -x` + quicFinalMessage)
+	}
+}

文件差異過大導致無法顯示
+ 17 - 0
engine/hyperapp.go


+ 268 - 0
engine/jsonfile.go

@@ -0,0 +1,268 @@
+package engine
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/lua/jnode"
+	"github.com/xyproto/datablock"
+	lua "github.com/xyproto/gopher-lua"
+	"github.com/xyproto/jpath"
+)
+
+// For dealing with JSON documents and strings
+
+const (
+	// Identifier for the JFile class in Lua
+	lJFileClass = "JFile"
+)
+
+// Get the first argument, "self", and cast it from userdata to a library (which is really a hash map).
+func checkJFile(L *lua.LState) *jpath.JFile {
+	ud := L.CheckUserData(1)
+	if jfile, ok := ud.Value.(*jpath.JFile); ok {
+		return jfile
+	}
+	L.ArgError(1, "JSON file expected")
+	return nil
+}
+
+// Takes a JFile, a JSON path (optional) and JSON data.
+// Stores the JSON data. Returns true if successful.
+func jfileAdd(L *lua.LState) int {
+	jfile := checkJFile(L) // arg 1
+	top := L.GetTop()
+	jsonpath := "x"
+	jsondata := ""
+	if top == 2 {
+		jsondata = L.ToString(2)
+		if jsondata == "" {
+			L.ArgError(2, "JSON data expected")
+		}
+	} else if top == 3 {
+		jsonpath = L.ToString(2)
+		// Check for { to help avoid allowing JSON data as a JSON path
+		if jsonpath == "" || strings.Contains(jsonpath, "{") {
+			L.ArgError(2, "JSON path expected")
+		}
+		jsondata = L.ToString(3)
+		if jsondata == "" {
+			L.ArgError(3, "JSON data expected")
+		}
+	}
+	err := jfile.AddJSON(jsonpath, []byte(jsondata))
+	if err != nil {
+		if top == 2 || strings.HasPrefix(err.Error(), "invalid character") {
+			log.Error("JSON data: ", err)
+		} else {
+			log.Error(err)
+		}
+	}
+	L.Push(lua.LBool(err == nil))
+	return 1 // number of results
+}
+
+// Takes a JFile and a JSON path.
+// Returns a string value or an empty string.
+func jfileGetString(L *lua.LState) int {
+	jfile := checkJFile(L) // arg 1
+	jsonpath := L.ToString(2)
+	if jsonpath == "" {
+		L.ArgError(2, "JSON path expected")
+	}
+	val, err := jfile.GetString(jsonpath)
+	if err != nil {
+		log.Error(err)
+		val = ""
+	}
+	L.Push(lua.LString(val))
+	return 1 // number of results
+}
+
+// Takes a JFile and a JSON path.
+// Returns a JNode or nil.
+func jfileGetNode(L *lua.LState) int {
+	jfile := checkJFile(L) // arg 1
+	jsonpath := L.ToString(2)
+	if jsonpath == "" {
+		L.ArgError(2, "JSON path expected")
+	}
+	node, err := jfile.GetNode(jsonpath)
+	if err != nil {
+		L.Push(lua.LNil)
+		return 1 // number of results
+	}
+
+	// Return the JNode
+	ud := L.NewUserData()
+	ud.Value = node
+	L.SetMetatable(ud, L.GetTypeMetatable(jnode.Class))
+	L.Push(ud)
+	return 1 // number of results
+}
+
+// Takes a JFile and a JSON path.
+// Returns a value or nil.
+func jfileGet(L *lua.LState) int {
+	jfile := checkJFile(L) // arg 1
+	jsonpath := L.ToString(2)
+	if jsonpath == "" {
+		L.ArgError(2, "JSON path expected")
+	}
+
+	// Will handle nil nodes below, so the error value can be ignored
+	node, _ := jfile.GetNode(jsonpath)
+
+	// Convert the JSON node to a Lua value, if possible
+	var retval lua.LValue
+	if node == jpath.NilNode {
+		retval = lua.LNil
+	} else if _, ok := node.CheckMap(); ok {
+		// Return the JNode instead of converting the map
+		log.Info("Returning a JSON node instead of a Lua map")
+		ud := L.NewUserData()
+		ud.Value = node
+		L.SetMetatable(ud, L.GetTypeMetatable(jnode.Class))
+		retval = ud
+		// buf.WriteString(fmt.Sprintf("Map with %d elements.", len(m)))
+	} else if _, ok := node.CheckList(); ok {
+		log.Info("Returning a JSON node instead of a Lua map")
+		// Return the JNode instead of converting the list
+		ud := L.NewUserData()
+		ud.Value = node
+		L.SetMetatable(ud, L.GetTypeMetatable(jnode.Class))
+		retval = ud
+		// buf.WriteString(fmt.Sprintf("List with %d elements.", len(l)))
+	} else if s, ok := node.CheckString(); ok {
+		retval = lua.LString(s)
+	} else if s, ok := node.CheckInt(); ok {
+		retval = lua.LNumber(s)
+	} else if b, ok := node.CheckBool(); ok {
+		retval = lua.LBool(b)
+	} else if i, ok := node.CheckInt64(); ok {
+		retval = lua.LNumber(i)
+	} else if u, ok := node.CheckUint64(); ok {
+		retval = lua.LNumber(u)
+	} else if f, ok := node.CheckFloat64(); ok {
+		retval = lua.LNumber(f)
+	} else {
+		log.Error("Unknown JSON node type")
+		return 0
+	}
+	// Return the LValue
+	L.Push(retval)
+	return 1 // number of results
+}
+
+// Take a JFile, a JSON path and a string.
+// Returns a value or an empty string.
+func jfileSet(L *lua.LState) int {
+	jfile := checkJFile(L) // arg 1
+	jsonpath := L.ToString(2)
+	if jsonpath == "" {
+		L.ArgError(2, "JSON path expected")
+	}
+	sval := L.ToString(3)
+	if sval == "" {
+		L.ArgError(3, "String value expected")
+	}
+	err := jfile.SetString(jsonpath, sval)
+	if err != nil {
+		log.Error(err)
+	}
+	L.Push(lua.LBool(err == nil))
+	return 1 // number of results
+}
+
+// Take a JFile and a JSON path.
+// Remove a key from a map. Return true if successful.
+func jfileDelKey(L *lua.LState) int {
+	jfile := checkJFile(L) // arg 1
+	jsonpath := L.ToString(2)
+	if jsonpath == "" {
+		L.ArgError(2, "JSON path expected")
+	}
+	err := jfile.DelKey(jsonpath)
+	if err != nil {
+		log.Error(err)
+	}
+	L.Push(lua.LBool(nil == err))
+	return 1 // number of results
+}
+
+// Given a JFile, return the JSON document.
+// May return an empty string.
+func jfileJSON(L *lua.LState) int {
+	jfile := checkJFile(L) // arg 1
+
+	data, err := jfile.JSON()
+	retval := ""
+	if err == nil { // ok
+		retval = string(data)
+	}
+	L.Push(lua.LString(retval))
+	return 1 // number of results
+}
+
+// Create a new JSON file
+func constructJFile(L *lua.LState, filename string, fperm os.FileMode, fs *datablock.FileStat) (*lua.LUserData, error) {
+	fullFilename := filename
+	// Check if the file exists
+	if !fs.Exists(fullFilename) {
+		// Create an empty JSON document/file
+		if err := os.WriteFile(fullFilename, []byte("[]\n"), fperm); err != nil {
+			return nil, err
+		}
+	}
+	// Create a new JFile
+	jfile, err := jpath.NewFile(fullFilename)
+	if err != nil {
+		log.Error(err)
+		return nil, err
+	}
+	// Create a new userdata struct
+	ud := L.NewUserData()
+	ud.Value = jfile
+	L.SetMetatable(ud, L.GetTypeMetatable(lJFileClass))
+	return ud, nil
+}
+
+// The hash map methods that are to be registered
+var jfileMethods = map[string]lua.LGFunction{
+	"__tostring": jfileJSON,
+	"add":        jfileAdd,
+	"getstring":  jfileGetString,
+	"getnode":    jfileGetNode,
+	"get":        jfileGet,
+	"set":        jfileSet,
+	"delkey":     jfileDelKey,
+	"string":     jfileJSON, // undocumented
+}
+
+// LoadJFile makes functions related to building a library of Lua code available
+func (ac *Config) LoadJFile(L *lua.LState, scriptdir string) {
+	// Register the JFile class and the methods that belongs with it.
+	mt := L.NewTypeMetatable(lJFileClass)
+	mt.RawSetH(lua.LString("__index"), mt)
+	L.SetFuncs(mt, jfileMethods)
+
+	// The constructor for new Libraries takes only an optional id
+	L.SetGlobal("JFile", L.NewFunction(func(L *lua.LState) int {
+		// Get the filename and schema
+		filename := L.ToString(1)
+
+		// Construct a new JFile
+		userdata, err := constructJFile(L, filepath.Join(scriptdir, filename), ac.defaultPermissions, ac.fs)
+		if err != nil {
+			log.Error(err)
+			L.Push(lua.LString(err.Error()))
+			return 1 // Number of returned values
+		}
+
+		// Return the Lua JFile object
+		L.Push(userdata)
+		return 1 // number of results
+	}))
+}

+ 397 - 0
engine/lua.go

@@ -0,0 +1,397 @@
+package engine
+
+import (
+	"github.com/xyproto/algernon/lua/mysql"
+	"html/template"
+	"net/http"
+	"path/filepath"
+	"strconv"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/cachemode"
+	"github.com/xyproto/algernon/lua/codelib"
+	"github.com/xyproto/algernon/lua/convert"
+	"github.com/xyproto/algernon/lua/datastruct"
+	"github.com/xyproto/algernon/lua/httpclient"
+	"github.com/xyproto/algernon/lua/jnode"
+	"github.com/xyproto/algernon/lua/mssql"
+	"github.com/xyproto/algernon/lua/onthefly"
+	"github.com/xyproto/algernon/lua/pquery"
+	"github.com/xyproto/algernon/lua/pure"
+	"github.com/xyproto/algernon/lua/upload"
+	"github.com/xyproto/algernon/lua/users"
+	"github.com/xyproto/algernon/utils"
+	"github.com/xyproto/gluamapper"
+	lua "github.com/xyproto/gopher-lua"
+)
+
+// LoadCommonFunctions adds most of the available Lua functions in algernon to
+// the given Lua state struct
+func (ac *Config) LoadCommonFunctions(w http.ResponseWriter, req *http.Request, filename string, L *lua.LState, flushFunc func(), httpStatus *FutureStatus) {
+	// Make basic functions, like print, available to the Lua script.
+	// Only exports functions that can relate to HTTP responses or requests.
+	ac.LoadBasicWeb(w, req, L, filename, flushFunc, httpStatus)
+
+	// Make other basic functions available
+	ac.LoadBasicSystemFunctions(L)
+
+	// Functions for rendering markdown or amber
+	ac.LoadRenderFunctions(w, req, L)
+
+	// If there is a database backend
+	if ac.perm != nil {
+
+		// Retrieve the userstate
+		userstate := ac.perm.UserState()
+
+		// Set the cookie secret, if set
+		if ac.cookieSecret != "" {
+			userstate.SetCookieSecret(ac.cookieSecret)
+		}
+
+		// Functions for serving files in the same directory as a script
+		ac.LoadServeFile(w, req, L, filename)
+
+		// Functions mainly for adding admin prefixes and configuring permissions
+		ac.LoadServerConfigFunctions(L, filename)
+
+		// Make the functions related to userstate available to the Lua script
+		users.Load(w, req, L, userstate)
+
+		creator := userstate.Creator()
+
+		// Simpleredis data structures
+		datastruct.LoadList(L, creator)
+		datastruct.LoadSet(L, creator)
+		datastruct.LoadHash(L, creator)
+		datastruct.LoadKeyValue(L, creator)
+
+		// For saving and loading Lua functions
+		codelib.Load(L, creator)
+
+		// For executing PostgreSQL queries
+		pquery.Load(L)
+
+		// For executing MSSQL queries
+		mssql.Load(L)
+		mysql.Load(L, "mysql")
+
+	}
+
+	// For handling JSON data
+	jnode.LoadJSONFunctions(L)
+	ac.LoadJFile(L, filepath.Dir(filename))
+	jnode.Load(L)
+
+	// Extras
+	pure.Load(L)
+
+	// pprint
+	// exportREPL(L)
+
+	// Plugins
+	ac.LoadPluginFunctions(L, nil)
+
+	// Cache
+	ac.LoadCacheFunctions(L)
+
+	// Pages and Tags
+	onthefly.Load(L)
+
+	// File uploads
+	upload.Load(L, w, req, filepath.Dir(filename))
+
+	// HTTP Client
+	httpclient.Load(L, ac.serverHeaderName)
+}
+
+// RunLua uses a Lua file as the HTTP handler. Also has access to the userstate
+// and permissions. Returns an error if there was a problem with running the lua
+// script, otherwise nil.
+func (ac *Config) RunLua(w http.ResponseWriter, req *http.Request, filename string, flushFunc func(), fust *FutureStatus) error {
+	// Retrieve a Lua state
+	L := ac.luapool.Get()
+	defer ac.luapool.Put(L)
+
+	// Warn if the connection is closed before the script has finished.
+	if ac.verboseMode {
+
+		done := make(chan bool)
+
+		// Stop the background goroutine when this function returns
+		// There must be a receiver for the done channel,
+		// or else this will hang everything!
+		defer func() {
+			done <- true
+		}()
+
+		// Set up a background notifier
+		go func() {
+			ctx := req.Context()
+			for {
+				select {
+				case <-ctx.Done():
+					// Client is done
+					log.Warn("Connection to client closed")
+				case <-done:
+					// We are done
+					return
+				}
+			}
+		}() // Call the goroutine
+	}
+
+	// Export functions to the Lua state
+	// Flush can be an uninitialized channel, it is handled in the function.
+	ac.LoadCommonFunctions(w, req, filename, L, flushFunc, fust)
+
+	// Run the script and return the error value.
+	// Logging and/or HTTP response is handled elsewhere.
+	if filepath.Ext(filename) == ".tl" {
+		return L.DoString(`
+            local fname = [[` + filename + `]]
+            local do_cache = ` + strconv.FormatBool(ac.cacheMode == cachemode.Production) + `
+
+            if  do_cache and tl.cache[fname] then
+            	tl.cache[fname]()
+                return
+            end
+
+        	local result, err = tl.process(fname)
+            if err ~= nil then
+            	throw('Teal failed to process file: '..err)
+            end
+
+            if #result.syntax_errors > 0 then
+            	local err = result.syntax_errors[1]
+                throw(err.filename..':'..err.y..': Teal processing error: '..err.msg, 0)
+            end
+
+            local code, gen_error = tl.pretty_print_ast(result.ast, "5.1")
+            if gen_error ~= nil then
+            	throw('Teal failed to generate Lua: '..err)
+            end
+
+            local chunk = load(code)
+            if do_cache then
+            	tl.cache[fname] = chunk
+            end
+
+            chunk()
+        `)
+	}
+	return L.DoFile(filename)
+}
+
+/* RunConfiguration runs a Lua file as a configuration script. Also has access
+ * to the userstate and permissions. Returns an error if there was a problem
+ * with running the lua script, otherwise nil. perm can be nil, but then several
+ * Lua functions will not be exposed.
+ *
+ * The idea is not to change the Lua struct or the luapool, but to set the
+ * configuration variables with the given Lua configuration script.
+ *
+ * luaHandler is a flag that lets Lua functions like "handle" and "servedir" be available or not.
+ */
+func (ac *Config) RunConfiguration(filename string, mux *http.ServeMux, withHandlerFunctions bool) error {
+	// Retrieve a Lua state
+	L := ac.luapool.Get()
+
+	// Basic system functions, like log()
+	ac.LoadBasicSystemFunctions(L)
+
+	// If there is a database backend
+	if ac.perm != nil {
+
+		// Retrieve the userstate
+		userstate := ac.perm.UserState()
+
+		// Server configuration functions
+		ac.LoadServerConfigFunctions(L, filename)
+
+		creator := userstate.Creator()
+
+		// Simpleredis data structures (could be used for storing server stats)
+		datastruct.LoadList(L, creator)
+		datastruct.LoadSet(L, creator)
+		datastruct.LoadHash(L, creator)
+		datastruct.LoadKeyValue(L, creator)
+
+		// For saving and loading Lua functions
+		codelib.Load(L, creator)
+
+		// For executing PostgreSQL queries
+		pquery.Load(L)
+
+		// For executing MSSQL queries
+		mssql.Load(L)
+	}
+
+	// For handling JSON data
+	jnode.LoadJSONFunctions(L)
+	ac.LoadJFile(L, filepath.Dir(filename))
+	jnode.Load(L)
+
+	// Extras
+	pure.Load(L)
+
+	// Plugins
+	ac.LoadPluginFunctions(L, nil)
+
+	// Cache
+	ac.LoadCacheFunctions(L)
+
+	// Pages and Tags
+	onthefly.Load(L)
+
+	// HTTP Client
+	httpclient.Load(L, ac.serverHeaderName)
+
+	if withHandlerFunctions {
+		// Lua HTTP handlers
+		ac.LoadLuaHandlerFunctions(L, filename, mux, false, nil, ac.defaultTheme)
+	}
+
+	// Run the script
+	if err := L.DoFile(filename); err != nil {
+		// Close the Lua state
+		L.Close()
+
+		// Logging and/or HTTP response is handled elsewhere
+		return err
+	}
+
+	// Only put the Lua state back if there were no errors
+	ac.luapool.Put(L)
+
+	return nil
+}
+
+/* LuaFunctionMap returns the functions available in the given Lua code as
+ * functions in a map that can be used by templates.
+ *
+ * Note that the lua functions must only accept and return strings
+ * and that only the first returned value will be accessible.
+ * The Lua functions may take an optional number of arguments.
+ */
+func (ac *Config) LuaFunctionMap(w http.ResponseWriter, req *http.Request, luadata []byte, filename string) (template.FuncMap, error) {
+	ac.pongomutex.Lock()
+	defer ac.pongomutex.Unlock()
+
+	// Retrieve a Lua state
+	L := ac.luapool.Get()
+	defer ac.luapool.Put(L)
+
+	// Prepare an empty map of functions (and variables)
+	funcs := make(template.FuncMap)
+
+	// Give no filename (an empty string will be handled correctly by the function).
+	ac.LoadCommonFunctions(w, req, filename, L, nil, nil)
+
+	// Run the script
+	if err := L.DoString(string(luadata)); err != nil {
+		// Close the Lua state
+		L.Close()
+
+		// Logging and/or HTTP response is handled elsewhere
+		return funcs, err
+	}
+
+	// Extract the available functions from the Lua state
+	globalTable := L.G.Global
+	globalTable.ForEach(func(key, value lua.LValue) {
+		// Check if the current value is a string variable
+		if luaString, ok := value.(lua.LString); ok {
+			// Store the variable in the same map as the functions (string -> interface)
+			// for ease of use together with templates.
+			funcs[key.String()] = luaString.String()
+		} else if luaTable, ok := value.(*lua.LTable); ok {
+
+			// Convert the table to a map and save it.
+			// Ignore values of a different type.
+			mapinterface, _ := convert.Table2map(luaTable, false)
+			switch m := mapinterface.(type) {
+			case map[string]string:
+				funcs[key.String()] = map[string]string(m)
+			case map[string]int:
+				funcs[key.String()] = map[string]int(m)
+			case map[int]string:
+				funcs[key.String()] = map[int]string(m)
+			case map[int]int:
+				funcs[key.String()] = map[int]int(m)
+			}
+
+			// Check if the current value is a function
+		} else if luaFunc, ok := value.(*lua.LFunction); ok {
+			// Only export the functions defined in the given Lua code,
+			// not all the global functions. IsG is true if the function is global.
+			if !luaFunc.IsG {
+
+				functionName := key.String()
+
+				// Register the function, with a variable number of string arguments
+				// Functions returning (string, error) are supported by html.template
+				funcs[functionName] = func(args ...string) (any, error) {
+					// Create a brand new Lua state
+					L2 := ac.luapool.New()
+					defer L2.Close()
+
+					// Set up a new Lua state with the current http.ResponseWriter and *http.Request
+					ac.LoadCommonFunctions(w, req, filename, L2, nil, nil)
+
+					// Push the Lua function to run
+					L2.Push(luaFunc)
+
+					// Push the given arguments
+					for _, arg := range args {
+						L2.Push(lua.LString(arg))
+					}
+
+					// Run the Lua function
+					err := L2.PCall(len(args), lua.MultRet, nil)
+					if err != nil {
+						// If calling the function did not work out, return the infostring and error
+						return utils.Infostring(functionName, args), err
+					}
+
+					// Empty return value if no values were returned
+					var retval any
+
+					// Return the first of the returned arguments, as a string
+					if L2.GetTop() >= 1 {
+						lv := L2.Get(-1)
+						tbl, isTable := lv.(*lua.LTable)
+						switch {
+						case isTable:
+							// lv was a Lua Table
+							retval = gluamapper.ToGoValue(tbl, gluamapper.Option{
+								NameFunc: func(s string) string {
+									return s
+								},
+							})
+							if ac.debugMode && ac.verboseMode {
+								log.Info(utils.Infostring(functionName, args) + " -> (map)")
+							}
+						case lv.Type() == lua.LTString:
+							// lv is a Lua String
+							retstr := L2.ToString(1)
+							retval = retstr
+							if ac.debugMode && ac.verboseMode {
+								log.Info(utils.Infostring(functionName, args) + " -> \"" + retstr + "\"")
+							}
+						default:
+							retval = ""
+							log.Warn("The return type of " + utils.Infostring(functionName, args) + " can't be converted")
+						}
+					}
+
+					// No return value, return an empty string and nil
+					return retval, nil
+				}
+			}
+		}
+	})
+
+	// Return the map of functions
+	return funcs, nil
+}

+ 69 - 0
engine/luahandler.go

@@ -0,0 +1,69 @@
+package engine
+
+import (
+	"net/http"
+	"path/filepath"
+	"sync"
+
+	"github.com/didip/tollbooth"
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/themes"
+	lua "github.com/xyproto/gopher-lua"
+)
+
+// LoadLuaHandlerFunctions makes functions related to handling HTTP requests
+// available to Lua scripts
+func (ac *Config) LoadLuaHandlerFunctions(L *lua.LState, filename string, mux *http.ServeMux, addDomain bool, httpStatus *FutureStatus, theme string) {
+	luahandlermutex := &sync.RWMutex{}
+
+	L.SetGlobal("handle", L.NewFunction(func(L *lua.LState) int {
+		handlePath := L.ToString(1)
+		handleFunc := L.ToFunction(2)
+
+		// TODO: Set up a channel and function for retrieving a lua "handleFunc" and running it,
+		//       using the common luapool as needed
+
+		wrappedHandleFunc := func(w http.ResponseWriter, req *http.Request) {
+			// Set up a new Lua state with the current http.ResponseWriter and *http.Request
+			luahandlermutex.Lock()
+			ac.LoadCommonFunctions(w, req, filename, L, nil, httpStatus)
+			luahandlermutex.Unlock()
+
+			// Then run the given Lua function
+			L.Push(handleFunc)
+			if err := L.PCall(0, lua.MultRet, nil); err != nil {
+				// Non-fatal error
+				log.Error("Handler for "+handlePath+" failed:", err)
+			}
+
+			// Then exit after the first request, if specified
+			if ac.quitAfterFirstRequest {
+				go ac.quitSoon("Quit after first request", defaultSoonDuration)
+			}
+		}
+
+		// Handle requests differently depending on if rate limiting is enabled or not
+		if ac.disableRateLimiting {
+			mux.HandleFunc(handlePath, wrappedHandleFunc)
+		} 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("text/html;charset=utf-8")
+			mux.Handle(handlePath, tollbooth.LimitFuncHandler(limiter, wrappedHandleFunc))
+		}
+
+		return 0 // number of results
+	}))
+
+	L.SetGlobal("servedir", L.NewFunction(func(L *lua.LState) int {
+		handlePath := L.ToString(1) // serve as (ie. "/")
+		rootdir := L.ToString(2)    // filesystem directory (ie. "./public")
+		if handlePath == "" || rootdir == "" {
+			log.Errorf("servedir needs an URL path to serve, ie. %q and a directory releative to %q, ie. %q", "/", filepath.Dir(filename), "./public")
+			return 0
+		}
+		rootdir = filepath.Join(filepath.Dir(filename), rootdir)
+		ac.RegisterHandlers(mux, handlePath, rootdir, addDomain)
+		return 0 // number of results
+	}))
+}

+ 10 - 0
engine/mime.go

@@ -0,0 +1,10 @@
+package engine
+
+import (
+	"github.com/xyproto/mime"
+)
+
+func (ac *Config) initializeMime() {
+	// Read in the mimetype information from the system. Set UTF-8 when setting Content-Type.
+	ac.mimereader = mime.New("/etc/mime.types", true)
+}

+ 7 - 0
engine/notrace.go

@@ -0,0 +1,7 @@
+//go:build !trace
+
+package engine
+
+func traceStart() {
+	// logf("%s\n", "no trace start")
+}

+ 242 - 0
engine/plugin.go

@@ -0,0 +1,242 @@
+package engine
+
+import (
+	"encoding/json"
+	"net/rpc"
+	"net/rpc/jsonrpc"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+
+	"github.com/natefinch/pie"
+	lua "github.com/xyproto/gopher-lua"
+	"github.com/xyproto/textoutput"
+)
+
+type luaPlugin struct {
+	client *rpc.Client
+}
+
+const namespace = "Lua"
+
+func (lp *luaPlugin) LuaCode(pluginPath string) (luacode string, err error) {
+	return luacode, lp.client.Call(namespace+".Code", pluginPath, &luacode)
+}
+
+func (lp *luaPlugin) LuaHelp() (luahelp string, err error) {
+	return luahelp, lp.client.Call(namespace+".Help", "", &luahelp)
+}
+
+// LoadPluginFunctions takes a Lua state and a TextOutput
+// (the TextOutput struct should be nil if not in a REPL)
+func (ac *Config) LoadPluginFunctions(L *lua.LState, o *textoutput.TextOutput) {
+	// Expose the functionality of a given plugin (executable file).
+	// If on Windows, ".exe" is added to the path.
+	// Returns true of successful.
+	L.SetGlobal("Plugin", L.NewFunction(func(L *lua.LState) int {
+		path := L.ToString(1)
+		givenPath := path
+		if runtime.GOOS == "windows" {
+			path = path + ".exe"
+		}
+		if !ac.fs.Exists(path) {
+			path = filepath.Join(ac.serverDirOrFilename, path)
+		}
+
+		// Keep the plugin running in the background?
+		keepRunning := false
+		if L.GetTop() >= 2 {
+			keepRunning = L.ToBool(2)
+		}
+
+		// Connect with the Plugin
+		client, err := pie.StartProviderCodec(jsonrpc.NewClientCodec, os.Stderr, path)
+		if err != nil {
+			if o != nil {
+				o.Err("[Plugin] Could not run plugin!")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LBool(false)) // Fail
+			return 1                 // number of results
+		}
+
+		if !keepRunning {
+			// Close the client once this function has completed
+			defer client.Close()
+		}
+
+		p := &luaPlugin{client}
+
+		// Retrieve the Lua code
+		luacode, err := p.LuaCode(givenPath)
+		if err != nil {
+			if o != nil {
+				o.Err("[Plugin] Could not call the LuaCode function!")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LBool(false)) // Fail
+			return 1                 // number of results
+		}
+
+		// Retrieve the help text
+		luahelp, err := p.LuaHelp()
+		if err != nil {
+			if o != nil {
+				o.Err("[Plugin] Could not call the LuaHelp function!")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LBool(false)) // Fail
+			return 1                 // number of results
+		}
+
+		// Run luacode on the current LuaState
+		luacode = strings.TrimSpace(luacode)
+		if L.DoString(luacode) != nil {
+			if o != nil {
+				o.Err("[Plugin] Error in Lua code provided by plugin!")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LBool(false)) // Fail
+			return 1                 // number of results
+		}
+
+		// If in a REPL, output the Plugin help text
+		if o != nil {
+			luahelp = strings.TrimSpace(luahelp)
+			// Add syntax highlighting and output the text
+			o.Println(highlight(o, luahelp))
+		}
+
+		L.Push(lua.LBool(true)) // Success
+		return 1                // number of results
+	}))
+
+	// Retrieve the code from the Lua.Code function of the plugin
+	L.SetGlobal("PluginCode", L.NewFunction(func(L *lua.LState) int {
+		path := L.ToString(1)
+		givenPath := path
+		if runtime.GOOS == "windows" {
+			path = path + ".exe"
+		}
+		if !ac.fs.Exists(path) {
+			path = filepath.Join(ac.serverDirOrFilename, path)
+		}
+
+		// Keep the plugin running in the background?
+		keepRunning := false
+		if L.GetTop() >= 2 {
+			keepRunning = L.ToBool(2)
+		}
+
+		// Connect with the Plugin
+		client, err := pie.StartProviderCodec(jsonrpc.NewClientCodec, os.Stderr, path)
+		if err != nil {
+			if o != nil {
+				o.Err("[PluginCode] Could not run plugin!")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LString("")) // Fail
+			return 1                // number of results
+		}
+		if !keepRunning {
+			// Close the client once this function has completed
+			defer client.Close()
+		}
+
+		p := &luaPlugin{client}
+
+		// Retrieve the Lua code
+		luacode, err := p.LuaCode(givenPath)
+		if err != nil {
+			if o != nil {
+				o.Err("[PluginCode] Could not call the LuaCode function!")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LString("")) // Fail
+			return 1                // number of results
+		}
+
+		L.Push(lua.LString(luacode))
+		return 1 // number of results
+	}))
+
+	// Call a function exposed by a plugin (executable file)
+	// Returns either nil (fail) or a string (success)
+	L.SetGlobal("CallPlugin", L.NewFunction(func(L *lua.LState) int {
+		if L.GetTop() < 2 {
+			if o != nil {
+				o.Err("[CallPlugin] Needs at least 2 arguments")
+			}
+			L.Push(lua.LString("")) // Fail
+			return 1                // number of results
+		}
+
+		path := L.ToString(1)
+		if runtime.GOOS == "windows" {
+			path = path + ".exe"
+		}
+		if !ac.fs.Exists(path) {
+			path = filepath.Join(ac.serverDirOrFilename, path)
+		}
+
+		fn := L.ToString(2)
+
+		var args []lua.LValue
+		if L.GetTop() > 2 {
+			for i := 3; i <= L.GetTop(); i++ {
+				args = append(args, L.Get(i))
+			}
+		}
+
+		// Connect with the Plugin
+		logto := os.Stderr
+		if o != nil {
+			logto = os.Stdout
+		}
+
+		// Keep the plugin running in the background?
+		keepRunning := false
+
+		client, err := pie.StartProviderCodec(jsonrpc.NewClientCodec, logto, path)
+		if err != nil {
+			if o != nil {
+				o.Err("[CallPlugin] Could not run plugin!")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LString("")) // Fail
+			return 1                // number of results
+		}
+
+		if !keepRunning {
+			// Close the client once this function has completed
+			defer client.Close()
+		}
+
+		jsonargs, err := json.Marshal(args)
+		if err != nil {
+			if o != nil {
+				o.Err("[CallPlugin] Error when marshalling arguments to JSON")
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LString("")) // Fail
+			return 1                // number of results
+		}
+
+		// Attempt to call the given function name
+		var jsonreply []byte
+		if err := client.Call(namespace+"."+fn, jsonargs, &jsonreply); err != nil {
+			if o != nil {
+				o.Err("[CallPlugin] Error when calling function!")
+				o.Err("Function: " + namespace + "." + fn)
+				o.Err("JSON Arguments: " + string(jsonargs))
+				o.Err("Error: " + err.Error())
+			}
+			L.Push(lua.LString("")) // Fail
+			return 1                // number of results
+		}
+
+		L.Push(lua.LString(jsonreply)) // Resulting string
+		return 1                       // number of results
+	}))
+}

+ 90 - 0
engine/pongo_test.go

@@ -0,0 +1,90 @@
+package engine
+
+import (
+	"html/template"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/xyproto/algernon/lua/pool"
+	"github.com/xyproto/algernon/utils"
+	"github.com/xyproto/datablock"
+)
+
+func pongoPageTest(n int, t *testing.T) {
+	req := httptest.NewRequest("GET", "/", nil)
+	w := httptest.NewRecorder()
+
+	filename := "testdata/index.po2"
+	luafilename := "testdata/data.lua"
+	pongodata, err := os.ReadFile(filename)
+	if err != nil {
+		t.Fatalf("Failed reading file: %s", err)
+	}
+
+	ac, err := New("Algernon 123", "Just a test")
+	if err != nil {
+		t.Fatalf("Failed creating new Algernon instance: %s", err)
+	}
+
+	// Use a FileStat cache with different settings
+	ac.SetFileStatCache(datablock.NewFileStat(true, time.Minute*1))
+
+	ac.cache = datablock.NewFileCache(20000000, true, 64*utils.KiB, true, 0)
+
+	luablock, err := ac.cache.Read(luafilename, ac.shouldCache(".po2"))
+	if err != nil {
+		t.Fatalf("Failed reading Lua file from cache: %s", err)
+	}
+
+	// luablock can be empty if there was an error or if the file was empty
+	if !luablock.HasData() {
+		t.Fatal("Lua block does not have data")
+	}
+
+	// Lua LState pool
+	ac.luapool = pool.New()
+	defer ac.luapool.Shutdown()
+
+	// Make functions from the given Lua data available
+	errChan := make(chan error)
+	funcMapChan := make(chan template.FuncMap)
+	go ac.Lua2funcMap(w, req, filename, luafilename, ".lua", errChan, funcMapChan)
+	funcs := <-funcMapChan
+	err = <-errChan
+	if err != nil {
+		t.Fatalf("Error with Lua2funcMap: %s", err)
+	}
+
+	// Trigger the error (now resolved)
+	for i := 0; i < n; i++ {
+		go ac.PongoPage(w, req, filename, pongodata, funcs)
+	}
+}
+
+func TestPongoPage(t *testing.T) {
+	pongoPageTest(1, t)
+}
+
+//func TestConcurrentPongoPage1(t *testing.T) {
+//	pongoPageTest(10, t)
+//}
+//
+//func TestConcurrentPongoPage2(t *testing.T) {
+//	for i := 0; i < 10; i++ {
+//		go pongoPageTest(1, t)
+//	}
+//}
+//
+//func TestConcurrentPongoPage3(t *testing.T) {
+//	for i := 0; i < 10; i++ {
+//		go pongoPageTest(10, t)
+//	}
+//}
+//
+//func TestConcurrentPongoPage4(t *testing.T) {
+//	for i := 0; i < 1000; i++ {
+//		go pongoPageTest(1000, t)
+//	}
+//}

+ 165 - 0
engine/prettyerror.go

@@ -0,0 +1,165 @@
+package engine
+
+import (
+	"bytes"
+	"net/http"
+	"strconv"
+	"strings"
+)
+
+const (
+	// Highlight of errors in the code
+	preHighlight  = "<font style='color: red !important'>"
+	postHighlight = "</font>"
+)
+
+// Given a lowercase string for the language, return an approprite error page title
+func errorPageTitle(lang string) string {
+	switch lang {
+	case "":
+		return "Error"
+	case "css":
+		return "CSS Error"
+	case "gcss":
+		return "GCSS Error"
+	case "html":
+		return "HTML Error"
+	case "jsx":
+		return "JSX Error"
+	default:
+		// string.Title(lang) was used here before, but staticcheck recommends against it
+		return lang + " Error"
+	}
+}
+
+// PrettyError serves an informative error page to the user
+// Takes a ResponseWriter, title (can be empty), filename, filebytes, errormessage and
+// programming/scripting/template language (i.e. "lua". Can be empty).
+func (ac *Config) PrettyError(w http.ResponseWriter, req *http.Request, filename string, filebytes []byte, errormessage, lang string) {
+	// HTTP status
+	// w.WriteHeader(http.StatusInternalServerError)
+	w.WriteHeader(http.StatusOK)
+
+	// HTTP content type
+	w.Header().Add("Content-Type", "text/html;charset=utf-8")
+
+	var (
+		// If there is code to be displayed
+		code string
+		err  error
+	)
+
+	// The line that the error refers to, for the case of Lua
+	linenr := -1
+
+	if len(filebytes) > 0 {
+		if lang == "lua" {
+			// If the first line of the error message has two colons, see if the second field is a number
+			fields := strings.SplitN(errormessage, ":", 3)
+			if len(fields) > 2 {
+				// Extract the line number from the error message, if possible
+				numberfield := fields[1]
+				if strings.Contains(numberfield, "(") {
+					numberfield = strings.Split(numberfield, "(")[0]
+				}
+				linenr, err = strconv.Atoi(numberfield)
+				// Subtract one to make it a slice index instead of human-friendly line number
+				linenr--
+				// Set linenumber to -1 if the conversion failed
+				if err != nil {
+					linenr = -1
+				}
+			}
+		} else if lang == "amber" {
+			// If the error contains "- Line: ", extract the line number
+			if strings.Contains(errormessage, "- Line: ") {
+				fields := strings.SplitN(errormessage, "- Line: ", 2)
+				if strings.Contains(fields[1], ",") {
+					numberfields := strings.SplitN(fields[1], ",", 2)
+					linenr, err = strconv.Atoi(strings.TrimSpace(numberfields[0]))
+					// Subtract one to make it a slice index instead of human-friendly line number
+					linenr--
+					// Set linenumber to -1 if the conversion failed
+					if err != nil {
+						linenr = -1
+					}
+				}
+			}
+		}
+
+		// Escape any HTML in the code, so that the pretty printer is not confused
+		filebytes = bytes.ReplaceAll(filebytes, []byte("<"), []byte("&lt;"))
+
+		// Modify the line that is to be highlighted
+		bytelines := bytes.Split(filebytes, []byte("\n"))
+		if (linenr >= 0) && (linenr < len(bytelines)) {
+			bytelines[linenr] = []byte(preHighlight + string(bytelines[linenr]) + postHighlight)
+		}
+
+		// Build a string from the bytelines slice
+		code = string(bytes.Join(bytelines, []byte("\n")))
+	}
+
+	// Set an appropriate title
+	title := errorPageTitle(lang)
+
+	// Set the highlight class
+	langclass := lang
+
+	// Turn off highlighting for some languages
+	switch lang {
+	case "", "amber", "gcss":
+		langclass = "nohighlight"
+	}
+
+	// Highlighting for the error message
+	errorclass := "json" // "nohighlight"
+
+	// Inform the user of the error
+	htmldata := []byte(`<!doctype html>
+<html>
+  <head>
+    <title>` + title + `</title>
+    <style>
+      body {
+        background-color: #f0f0f0;
+        color: #0b0b0b;
+        font-family: Lato,'Trebuchet MS',Helvetica,sans-serif;
+        font-weight: 300;
+        margin: 3.5em;
+        font-size: 1.3em;
+      }
+      h1 {
+        color: #101010;
+      }
+      div {
+        margin-bottom: 35pt;
+      }
+      #right {
+        text-align: right;
+      }
+      #wrap {
+        white-space: pre-wrap;
+      }
+    </style>
+  </head>
+  <body>
+    <div style="font-size: 3em; font-weight: bold;">` + title + `</div>
+    Contents of ` + filename + `:
+    <div>
+      <pre><code class="` + langclass + `">` + code + `</code></pre>
+    </div>
+    Error message:
+    <div>
+      <pre id="wrap"><code style="color: #A00000;" class="` + errorclass + `">` + strings.TrimSpace(errormessage) + `</code></pre>
+    </div>
+    <div id="right">` + ac.versionString + `</div>
+`)
+
+	if ac.autoRefresh {
+		// Insert JavaScript for refreshing the page into the generated HTML
+		htmldata = ac.InsertAutoRefresh(req, htmldata)
+	}
+
+	w.Write(htmldata)
+}

+ 18 - 0
engine/quic_disabled.go

@@ -0,0 +1,18 @@
+//go:build plan9 || solaris || openbsd
+// +build plan9 solaris openbsd
+
+package engine
+
+import (
+	"net/http"
+	"sync"
+
+	log "github.com/sirupsen/logrus"
+)
+
+var quicEnabled = false
+
+// ListanAndServeQUIC is just a placeholder for platforms with QUIC disabled
+func (ac *Config) ListenAndServeQUIC(_ http.Handler, _ *sync.Mutex, _ chan bool, _ *bool) {
+	log.Error("Not serving QUIC. This Algernon executable was built without QUIC-support.")
+}

+ 35 - 0
engine/quic_enabled.go

@@ -0,0 +1,35 @@
+//go:build linux || freebsd || windows || netbsd || darwin
+// +build linux freebsd windows netbsd darwin
+
+package engine
+
+import (
+	"net/http"
+	"sync"
+
+	"github.com/quic-go/quic-go/http3"
+	log "github.com/sirupsen/logrus"
+)
+
+var quicEnabled = true
+
+// ListenAndServeQUIC attempts to serve the given http.Handler over QUIC/HTTP3,
+// then reports back any errors when done serving.
+func (ac *Config) ListenAndServeQUIC(mux http.Handler, mut *sync.Mutex, justServeRegularHTTP chan bool, servingHTTPS *bool) {
+	// TODO: Handle ctrl-c by fetching the quicServer struct and passing it to GenerateShutdownFunction.
+	//       This can be done once CloseGracefully in h2quic has been implemented:
+	//       https://github.com/lucas-clemente/quic-go/blob/master/h2quic/server.go#L257
+	// TODO: As far as I can tell, this was never implemented. Look into implementing this for github.com/xyproto/quic
+	//
+	// gracefulServer.ShutdownInitiated = ac.GenerateShutdownFunction(nil, quicServer)
+	if err := http3.ListenAndServe(ac.serverAddr, ac.serverCert, ac.serverKey, mux); err != nil {
+		log.Error("Not serving QUIC after all. Error: ", err)
+		log.Info("Use the -t flag for serving regular HTTP instead")
+		// If QUIC failed (perhaps the key + cert are missing),
+		// serve plain HTTP instead
+		justServeRegularHTTP <- true
+		mut.Lock()
+		*servingHTTPS = false
+		mut.Unlock()
+	}
+}

+ 929 - 0
engine/rendering.go

@@ -0,0 +1,929 @@
+package engine
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"github.com/eknkc/amber"
+	"github.com/evanw/esbuild/pkg/api"
+	"github.com/gomarkdown/markdown"
+	"github.com/gomarkdown/markdown/parser"
+	log "github.com/sirupsen/logrus"
+	"github.com/wellington/sass/compiler"
+	"github.com/xyproto/algernon/console"
+	"github.com/xyproto/algernon/lua/convert"
+	"github.com/xyproto/algernon/themes"
+	"github.com/xyproto/algernon/utils"
+	lua "github.com/xyproto/gopher-lua"
+	"github.com/xyproto/pongo2"
+	"github.com/xyproto/splash"
+	"github.com/yosssi/gcss"
+)
+
+// ValidGCSS checks if the given data is valid GCSS.
+// The error value is returned on the channel.
+func ValidGCSS(gcssdata []byte, errorReturn chan error) {
+	buf := bytes.NewBuffer(gcssdata)
+	var w bytes.Buffer
+	_, err := gcss.Compile(&w, buf)
+	errorReturn <- err
+}
+
+// LoadRenderFunctions adds functions related to rendering text to the given
+// Lua state struct
+func (ac *Config) LoadRenderFunctions(w http.ResponseWriter, _ *http.Request, L *lua.LState) {
+
+	// Output Markdown as HTML
+	L.SetGlobal("mprint", 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
+		extensions := parser.CommonExtensions | parser.AutoHeadingIDs
+		mdParser := parser.NewWithExtensions(extensions)
+		// Convert the buffer from Markdown to HTML
+		htmlData := markdown.ToHTML(buf.Bytes(), mdParser, nil)
+
+		// Apply syntax highlighting
+		codeStyle := "base16-snazzy"
+		if highlightedHTML, err := splash.Splash(htmlData, codeStyle); err == nil { // success
+			htmlData = highlightedHTML
+		}
+
+		w.Write(htmlData)
+		return 0 // number of results
+	}))
+
+	// Output text as rendered amber.
+	L.SetGlobal("aprint", L.NewFunction(func(L *lua.LState) int {
+		// Retrieve all the function arguments as a bytes.Buffer
+		buf := convert.Arguments2buffer(L, true)
+		// Use the buffer as a template.
+		// Options are "Pretty printing, but without line numbers."
+		tpl, err := amber.Compile(buf.String(), amber.Options{PrettyPrint: true, LineNumbers: false})
+		if err != nil {
+			if ac.debugMode {
+				fmt.Fprint(w, "Could not compile Amber template:\n\t"+err.Error()+"\n\n"+buf.String())
+			} else {
+				log.Errorf("Could not compile Amber template:\n%s\n%s", err, buf.String())
+			}
+			return 0 // number of results
+		}
+		// Using "MISSING" instead of nil for a slightly better error message
+		// if the values in the template should not be found.
+		tpl.Execute(w, "MISSING")
+		return 0 // number of results
+	}))
+
+	// Output text as rendered Pongo2
+	L.SetGlobal("poprint", L.NewFunction(func(L *lua.LState) int {
+		pongoMap := make(pongo2.Context)
+
+		// Use the first argument as the template and the second argument as the data map
+		templateString := L.CheckString(1)
+
+		// If a table is given as the second argument, fill pongoMap with keys and values
+		if L.GetTop() >= 2 {
+			mapSS, mapSI, _, _ := convert.Table2maps(L.CheckTable(2))
+			for k, v := range mapSI {
+				pongoMap[k] = v
+			}
+			for k, v := range mapSS {
+				pongoMap[k] = v
+			}
+		}
+
+		// Retrieve all the function arguments as a bytes.Buffer
+		buf := convert.Arguments2buffer(L, true)
+		// Use the buffer as a template.
+		// Options are "Pretty printing, but without line numbers."
+		tpl, err := pongo2.FromString(templateString)
+		if err != nil {
+			if ac.debugMode {
+				fmt.Fprint(w, "Could not compile Pongo2 template:\n\t"+err.Error()+"\n\n"+buf.String())
+			} else {
+				log.Errorf("Could not compile Pongo2 template:\n%s\n%s", err, buf.String())
+			}
+			return 0 // number of results
+		}
+		// nil is the template context (variables etc in a map)
+		if err := tpl.ExecuteWriter(pongoMap, w); err != nil {
+			if ac.debugMode {
+				fmt.Fprint(w, "Could not compile Pongo2:\n\t"+err.Error()+"\n\n"+buf.String())
+			} else {
+				log.Errorf("Could not compile Pongo2:\n%s\n%s", err, buf.String())
+			}
+		}
+		return 0 // number of results
+	}))
+
+	// Output text as rendered GCSS
+	L.SetGlobal("gprint", L.NewFunction(func(L *lua.LState) int {
+		// Retrieve all the function arguments as a bytes.Buffer
+		buf := convert.Arguments2buffer(L, true)
+		// Transform GCSS to CSS and output the result.
+		// Ignoring the number of bytes written.
+		if _, err := gcss.Compile(w, &buf); err != nil {
+			if ac.debugMode {
+				fmt.Fprint(w, "Could not compile GCSS:\n\t"+err.Error()+"\n\n"+buf.String())
+			} else {
+				log.Errorf("Could not compile GCSS:\n%s\n%s", err, buf.String())
+			}
+
+			// return 0 // number of results
+		}
+		return 0 // number of results
+	}))
+
+	// Output text as rendered JSX for React
+	L.SetGlobal("jprint", L.NewFunction(func(L *lua.LState) int {
+		// Retrieve all the function arguments as a bytes.Buffer
+		buf := convert.Arguments2buffer(L, true)
+		// Transform JSX to JavaScript and output the result.
+		result := api.Transform(buf.String(), ac.jsxOptions)
+		if len(result.Errors) > 0 {
+			if ac.debugMode {
+				// TODO: Use a similar error page as for Lua
+				for _, errMsg := range result.Errors {
+					fmt.Fprintf(w, "error: %s %d:%d\n", errMsg.Text, errMsg.Location.Line, errMsg.Location.Column)
+				}
+				for _, warnMsg := range result.Warnings {
+					fmt.Fprintf(w, "warning: %s %d:%d\n", warnMsg.Text, warnMsg.Location.Line, warnMsg.Location.Column)
+				}
+			} else {
+				// TODO: Use a similar error page as for Lua
+				for _, errMsg := range result.Errors {
+					log.Errorf("error: %s %d:%d\n", errMsg.Text, errMsg.Location.Line, errMsg.Location.Column)
+				}
+				for _, warnMsg := range result.Warnings {
+					log.Errorf("warning: %s %d:%d\n", warnMsg.Text, warnMsg.Location.Line, warnMsg.Location.Column)
+				}
+			}
+			return 0 // number of results
+
+		}
+		n, err := w.Write(result.Code)
+		if err != nil || n == 0 {
+			if ac.debugMode {
+				fmt.Fprint(w, "Result from generated JavaScript is empty\n")
+			} else {
+				log.Error("Result from generated JavaScript is empty\n")
+			}
+			return 0 // number of results
+		}
+
+		return 0 // number of results
+	}))
+
+	// Output text as rendered JSX for HyperApp
+	L.SetGlobal("hprint", L.NewFunction(func(L *lua.LState) int {
+		// Retrieve all the function arguments as a bytes.Buffer
+		buf := convert.Arguments2buffer(L, true)
+		// Transform JSX to JavaScript and output the result.
+		result := api.Transform(buf.String(), ac.jsxOptions)
+		if len(result.Errors) > 0 {
+			if ac.debugMode {
+				// TODO: Use a similar error page as for Lua
+				for _, errMsg := range result.Errors {
+					fmt.Fprintf(w, "error: %s %d:%d\n", errMsg.Text, errMsg.Location.Line, errMsg.Location.Column)
+				}
+				for _, warnMsg := range result.Warnings {
+					fmt.Fprintf(w, "warning: %s %d:%d\n", warnMsg.Text, warnMsg.Location.Line, warnMsg.Location.Column)
+				}
+			} else {
+				// TODO: Use a similar error page as for Lua
+				for _, errMsg := range result.Errors {
+					log.Errorf("error: %s %d:%d\n", errMsg.Text, errMsg.Location.Line, errMsg.Location.Column)
+				}
+				for _, warnMsg := range result.Warnings {
+					log.Errorf("warning: %s %d:%d\n", warnMsg.Text, warnMsg.Location.Line, warnMsg.Location.Column)
+				}
+			}
+			return 0 // number of results
+		}
+		data := result.Code
+		// Use "h" instead of "React.createElement" for hyperApp apps
+		data = bytes.ReplaceAll(data, []byte("React.createElement("), []byte("h("))
+		n, err := w.Write(data)
+		if err != nil || n == 0 {
+			if ac.debugMode {
+				fmt.Fprint(w, "Result from generated JavaScript is empty\n")
+			} else {
+				log.Error("Result from generated JavaScript is empty\n")
+			}
+			return 0 // number of results
+		}
+		return 0 // number of results
+	}))
+
+	// Output a simple message HTML page.
+	// The first argument is the message (ends up in the <body>).
+	// The seconds argument is an optional title.
+	// The third argument is an optional page style.
+	L.SetGlobal("msgpage", L.NewFunction(func(L *lua.LState) int {
+		title := ""
+		body := ""
+		if L.GetTop() < 2 {
+			// Uses an empty string if no first argument is given
+			body = L.ToString(1)
+		} else {
+			title = L.ToString(1)
+			body = L.ToString(2)
+		}
+
+		// The default theme for single page messages
+		theme := "redbox"
+		if L.GetTop() >= 3 {
+			theme = L.ToString(3)
+		}
+
+		// Write a simple HTML page to the client
+		w.Write([]byte(themes.MessagePage(title, body, theme)))
+
+		return 0 // number of results
+	}))
+}
+
+// MarkdownPage write the given source bytes as markdown wrapped in HTML to a writer, with a title
+func (ac *Config) MarkdownPage(w http.ResponseWriter, req *http.Request, data []byte, filename string) {
+	// Prepare for receiving title and codeStyle information
+	searchKeywords := []string{"title", "codestyle", "theme", "replace_with_theme", "css", "favicon"}
+
+	// Also prepare for receiving meta tag information
+	searchKeywords = append(searchKeywords, themes.MetaKeywords...)
+
+	// Extract keywords from the given data, and remove the lines with keywords,
+	// but only the first time that keyword occurs.
+	var kwmap map[string][]byte
+
+	data, kwmap = utils.ExtractKeywords(data, searchKeywords)
+
+	// Create a Markdown parser with the desired extensions
+	extensions := parser.CommonExtensions | parser.AutoHeadingIDs
+	mdParser := parser.NewWithExtensions(extensions)
+	// Convert from Markdown to HTML
+	htmlbody := markdown.ToHTML(data, mdParser, nil)
+
+	// TODO: Check if handling "# title <tags" on the first line is valid
+	// Markdown or not. Submit a patch to gomarkdown/markdown if it is.
+
+	var h1title []byte
+	if bytes.HasPrefix(htmlbody, []byte("<p>#")) {
+		fields := bytes.Split(htmlbody, []byte("<"))
+		if len(fields) > 2 {
+			h1title = bytes.TrimPrefix(fields[1][2:], []byte("#"))
+			htmlbody = htmlbody[3+len(h1title):] // 3 is the length of <p>
+		}
+	}
+
+	// Checkboxes
+	htmlbody = bytes.ReplaceAll(htmlbody, []byte("<li>[ ] "), []byte("<li><input type=\"checkbox\" disabled> "))
+	htmlbody = bytes.ReplaceAll(htmlbody, []byte("<li><p>[ ] "), []byte("<li><p><input type=\"checkbox\" disabled> "))
+	htmlbody = bytes.ReplaceAll(htmlbody, []byte("<li>[x] "), []byte("<li><input type=\"checkbox\" disabled checked> "))
+	htmlbody = bytes.ReplaceAll(htmlbody, []byte("<li>[X] "), []byte("<li><input type=\"checkbox\" disabled checked> "))
+	htmlbody = bytes.ReplaceAll(htmlbody, []byte("<li><p>[x] "), []byte("<li><p><input type=\"checkbox\" disabled checked> "))
+
+	// These should work by default, but does not.
+	// TODO: Look into how gomarkdown/markdown handles this.
+	htmlbody = bytes.ReplaceAll(htmlbody, []byte("&amp;gt;"), []byte("&gt;"))
+	htmlbody = bytes.ReplaceAll(htmlbody, []byte("&amp;lt;"), []byte("&lt;"))
+
+	// If there is no given title, use the h1title
+	title := kwmap["title"]
+	if len(title) == 0 {
+		if len(h1title) != 0 {
+			title = h1title
+		} else {
+			// If no title has been provided, use the filename
+			title = []byte(filepath.Base(filename))
+		}
+	}
+
+	// Find the theme that should be used
+	theme := kwmap["theme"]
+	if len(theme) == 0 {
+		theme = []byte(ac.defaultTheme)
+	}
+
+	// Theme aliases. Use a map if there are more than 2 aliases in the future.
+	if string(theme) == "default" {
+		// Use the "material" theme by default for Markdown
+		theme = []byte("material")
+	}
+
+	// Check if a specific string should be replaced with the current theme
+	replaceWithTheme := kwmap["replace_with_theme"]
+	if len(replaceWithTheme) != 0 {
+		// Replace all instances of the value given with "replace_with_theme: ..." with the current theme name
+		htmlbody = bytes.ReplaceAll(htmlbody, replaceWithTheme, []byte(theme))
+	}
+
+	// If the theme is a filename, create a custom theme where the file is imported from the CSS
+	if bytes.Contains(theme, []byte(".")) {
+		st := string(theme)
+		themes.NewTheme(st, []byte("@import url("+st+");"), themes.DefaultCustomCodeStyle)
+	}
+
+	var head strings.Builder
+
+	// If a favicon is specified, use that
+	favicon := kwmap["favicon"]
+	if len(favicon) > 0 {
+		head.WriteString(`<link rel="shortcut icon" type="image/`)
+		// Only support the most common mime formats for favicons
+		switch {
+		case bytes.HasSuffix(favicon, []byte(".ico")):
+			head.WriteString("x-icon")
+		case bytes.HasSuffix(favicon, []byte(".bmp")):
+			head.WriteString("bmp")
+		case bytes.HasSuffix(favicon, []byte(".gif")):
+			head.WriteString("gif")
+		case bytes.HasSuffix(favicon, []byte(".jpg")):
+			head.WriteString("jpeg")
+		default:
+			head.WriteString("png")
+		}
+		head.WriteString(`" href="`)
+		head.Write(favicon)
+		head.WriteString(`"/>`)
+	}
+
+	// If style.gcss is present, use that style in <head>
+	CSSFilename := filepath.Join(filepath.Dir(filename), themes.DefaultCSSFilename)
+	GCSSFilename := filepath.Join(filepath.Dir(filename), themes.DefaultGCSSFilename)
+	switch {
+	case ac.fs.Exists(CSSFilename):
+		// Link to stylesheet (without checking if the CSS file is valid first)
+		head.WriteString(`<link href="`)
+		head.WriteString(themes.DefaultCSSFilename)
+		head.WriteString(`" rel="stylesheet" type="text/css">`)
+	case ac.fs.Exists(GCSSFilename):
+		if ac.debugMode {
+			gcssblock, err := ac.cache.Read(GCSSFilename, ac.shouldCache(".gcss"))
+			if err != nil {
+				fmt.Fprintf(w, "Unable to read %s: %s", filename, err)
+				return
+			}
+			gcssdata := gcssblock.Bytes()
+
+			// Try compiling the GCSS file first
+			errChan := make(chan error)
+			go ValidGCSS(gcssdata, errChan)
+			err = <-errChan
+			if err != nil {
+				// Invalid GCSS, return an error page
+				ac.PrettyError(w, req, GCSSFilename, gcssdata, err.Error(), "gcss")
+				return
+			}
+		}
+		// Link to stylesheet (without checking if the GCSS file is valid first)
+		head.WriteString(`<link href="`)
+		head.WriteString(themes.DefaultGCSSFilename)
+		head.WriteString(`" rel="stylesheet" type="text/css">`)
+	default:
+		// If not, use the theme by inserting the CSS style directly
+		head.Write(themes.StyleHead(string(theme)))
+	}
+
+	// Additional CSS file
+	additionalCSSfile := string(kwmap["css"])
+	if additionalCSSfile != "" {
+		// If serving a single Markdown file, include the CSS file inline in a style tag
+		if ac.markdownMode && ac.fs.Exists(additionalCSSfile) {
+			// Cache the CSS only if Markdown should be cached
+			cssblock, err := ac.cache.Read(additionalCSSfile, ac.shouldCache(".md"))
+			if err != nil {
+				fmt.Fprintf(w, "Unable to read %s: %s", filename, err)
+				return
+			}
+			cssdata := cssblock.Bytes()
+			head.WriteString("<style>" + string(cssdata) + "</style>")
+		} else {
+			head.WriteString(`<link href="`)
+			head.WriteString(additionalCSSfile)
+			head.WriteString(`" rel="stylesheet" type="text/css">`)
+		}
+	}
+
+	codeStyle := string(kwmap["codestyle"])
+
+	// Add meta tags, if metadata information has been declared
+	for _, keyword := range themes.MetaKeywords {
+		if len(kwmap[keyword]) != 0 {
+			// Add the meta tag
+			head.WriteString(`<meta name="`)
+			head.WriteString(keyword)
+			head.WriteString(`" content="`)
+			head.Write(kwmap[keyword])
+			head.WriteString(`" />`)
+		}
+	}
+
+	// Embed the style and rendered markdown into a simple HTML 5 page
+	htmldata := themes.SimpleHTMLPage(title, h1title, []byte(head.String()), htmlbody)
+
+	// Add syntax highlighting to the header, but only if "<pre" is present
+	if bytes.Contains(htmlbody, []byte("<pre")) {
+		// If codeStyle is not "none", highlight the current htmldata
+		if codeStyle == "" {
+			// Use the highlight style from the current theme
+			highlighted, err := splash.UnescapeSplash(htmldata, themes.ThemeToCodeStyle(string(theme)))
+			if err != nil {
+				log.Error(err)
+			} else {
+				// Only use the new and highlighted HTML if there were no errors
+				htmldata = highlighted
+			}
+		} else if codeStyle != "none" {
+			// Use the highlight style from codeStyle
+			highlighted, err := splash.UnescapeSplash(htmldata, codeStyle)
+			if err != nil {
+				log.Error(err)
+			} else {
+				// Only use the new HTML if there were no errors
+				htmldata = highlighted
+			}
+		}
+	}
+
+	// If the auto-refresh feature has been enabled
+	if ac.autoRefresh {
+		// Insert JavaScript for refreshing the page into the generated HTML
+		htmldata = ac.InsertAutoRefresh(req, htmldata)
+	}
+
+	// Write the rendered Markdown page to the client
+	ac.DataToClient(w, req, filename, htmldata)
+}
+
+// PongoPage write the given source bytes (ina Pongo2) converted to HTML, to a writer.
+// The filename is only used in error messages, if any.
+func (ac *Config) PongoPage(w http.ResponseWriter, req *http.Request, filename string, pongodata []byte, funcs template.FuncMap) {
+	var (
+		buf                   bytes.Buffer
+		linkInGCSS, linkInCSS bool
+		dirName               = filepath.Dir(filename)
+		GCSSFilename          = filepath.Join(dirName, themes.DefaultGCSSFilename)
+		CSSFilename           = filepath.Join(dirName, themes.DefaultCSSFilename)
+	)
+
+	// If style.gcss is present, and a header is present, and it has not already been linked in, link it in
+	if ac.fs.Exists(CSSFilename) {
+		linkInCSS = true
+	} else if ac.fs.Exists(GCSSFilename) {
+		if ac.debugMode {
+			gcssblock, err := ac.cache.Read(GCSSFilename, ac.shouldCache(".gcss"))
+			if err != nil {
+				fmt.Fprintf(w, "Unable to read %s: %s", filename, err)
+				return
+			}
+			gcssdata := gcssblock.Bytes()
+
+			// Try compiling the GCSS file before the Pongo2 file
+			errChan := make(chan error)
+			go ValidGCSS(gcssdata, errChan)
+			err = <-errChan
+			if err != nil {
+				// Invalid GCSS, return an error page
+				ac.PrettyError(w, req, GCSSFilename, gcssdata, err.Error(), "gcss")
+				return
+			}
+		}
+		linkInGCSS = true
+	}
+
+	// Set the base directory for Pongo2 to the one where the given filename is
+	if err := pongo2.DefaultLoader.SetBaseDir(dirName); err != nil {
+		if ac.debugMode {
+			ac.PrettyError(w, req, filename, pongodata, err.Error(), "pongo2")
+		} else {
+			log.Errorf("Could not set base directory for Pongo2 to %s:\n%s", dirName, err)
+		}
+		return
+	}
+
+	// Prepare a Pongo2 template
+	tpl, err := pongo2.DefaultSet.FromBytes(pongodata)
+	if err != nil {
+		if ac.debugMode {
+			ac.PrettyError(w, req, filename, pongodata, err.Error(), "pongo2")
+		} else {
+			log.Errorf("Could not compile Pongo2 template:\n%s\n%s", err, string(pongodata))
+		}
+		return
+	}
+
+	okfuncs := make(pongo2.Context)
+
+	// Go through the global Lua scope
+	for k, v := range funcs {
+		// Skip the ones starting with an underscore
+		//if strings.HasPrefix(k, "_") {
+		//	continue
+		//}
+
+		// Check if the name in question is a function
+		if f, ok := v.(func(...string) (any, error)); ok {
+
+			// For the closure to correctly wrap the key value
+			k := k
+
+			// Wrap the Lua functions as Pongo2 functions
+			wrapfunc := func(vals ...*pongo2.Value) *pongo2.Value {
+				// Convert the Pongo2 arguments to string arguments
+				strs := make([]string, len(vals))
+				for i, sv := range vals {
+					strs[i] = sv.String()
+				}
+
+				// Call the Lua function
+				retval, err := f(strs...)
+				// Return the error if things go wrong
+				if err != nil {
+					return pongo2.AsValue(err)
+				}
+				// Return the returned value if things went well
+				return pongo2.AsValue(retval)
+			}
+			// Save the wrapped function for the pongo2 template execution
+			okfuncs[k] = wrapfunc
+
+		} else if s, ok := v.(string); ok {
+			// String variables
+			okfuncs[k] = s
+		} else {
+			// Exposing variable as it is.
+			// TODO: Add more tests for this codepath
+			okfuncs[k] = v
+		}
+	}
+
+	// Make the Lua functions available to Pongo
+	pongo2.Globals.Update(okfuncs)
+
+	defer func() {
+		if r := recover(); r != nil {
+			errmsg := fmt.Sprintf("Pongo2 error: %s", r)
+			if ac.debugMode {
+				ac.PrettyError(w, req, filename, pongodata, errmsg, "pongo2")
+			} else {
+				log.Errorf("Could not execute Pongo2 template:\n%s", errmsg)
+			}
+		}
+	}()
+
+	// Render the Pongo2 template to the buffer
+	err = tpl.ExecuteWriter(pongo2.Globals, &buf)
+	if err != nil {
+		// if err := tpl.ExecuteWriterUnbuffered(pongo2.Globals, &buf); err != nil {
+		if ac.debugMode {
+			ac.PrettyError(w, req, filename, pongodata, err.Error(), "pongo2")
+		} else {
+			log.Errorf("Could not execute Pongo2 template:\n%s", err)
+		}
+		return
+	}
+
+	// Check if we are dealing with HTML
+	if strings.Contains(buf.String(), "<html>") {
+
+		if linkInCSS || linkInGCSS {
+			// Link in stylesheet
+			htmldata := buf.Bytes()
+			if linkInCSS {
+				htmldata = themes.StyleHTML(htmldata, themes.DefaultCSSFilename)
+			} else if linkInGCSS {
+				htmldata = themes.StyleHTML(htmldata, themes.DefaultGCSSFilename)
+			}
+			buf.Reset()
+			_, err := buf.Write(htmldata)
+			if err != nil {
+				if ac.debugMode {
+					ac.PrettyError(w, req, filename, pongodata, err.Error(), "pongo2")
+				} else {
+					log.Errorf("Can not write bytes to a buffer! Out of memory?\n%s", err)
+				}
+				return
+			}
+		}
+
+		// If the auto-refresh feature has been enabled
+		if ac.autoRefresh {
+			// Insert JavaScript for refreshing the page into the generated HTML
+			changedBytes := ac.InsertAutoRefresh(req, buf.Bytes())
+
+			buf.Reset()
+			_, err := buf.Write(changedBytes)
+			if err != nil {
+				if ac.debugMode {
+					ac.PrettyError(w, req, filename, pongodata, err.Error(), "pongo2")
+				} else {
+					log.Errorf("Can not write bytes to a buffer! Out of memory?\n%s", err)
+				}
+				return
+			}
+		}
+
+		// If doctype is missing, add doctype for HTML5 at the top
+		changedBytes := themes.InsertDoctype(buf.Bytes())
+		buf.Reset()
+		buf.Write(changedBytes)
+	}
+
+	// Write the rendered template to the client
+	ac.DataToClient(w, req, filename, buf.Bytes())
+}
+
+// AmberPage the given source bytes (in Amber) converted to HTML, to a writer.
+// The filename is only used in error messages, if any.
+func (ac *Config) AmberPage(w http.ResponseWriter, req *http.Request, filename string, amberdata []byte, funcs template.FuncMap) {
+	var (
+		buf bytes.Buffer
+		// If style.gcss is present, and a header is present, and it has not already been linked in, link it in
+		dirName      = filepath.Dir(filename)
+		GCSSFilename = filepath.Join(dirName, themes.DefaultGCSSFilename)
+		CSSFilename  = filepath.Join(dirName, themes.DefaultCSSFilename)
+	)
+
+	if ac.fs.Exists(CSSFilename) {
+		// Link to stylesheet (without checking if the GCSS file is valid first)
+		amberdata = themes.StyleAmber(amberdata, themes.DefaultCSSFilename)
+	} else if ac.fs.Exists(GCSSFilename) {
+		if ac.debugMode {
+			gcssblock, err := ac.cache.Read(GCSSFilename, ac.shouldCache(".gcss"))
+			if err != nil {
+				fmt.Fprintf(w, "Unable to read %s: %s", filename, err)
+				return
+			}
+			gcssdata := gcssblock.Bytes()
+
+			// Try compiling the GCSS file before the Amber file
+			errChan := make(chan error)
+			go ValidGCSS(gcssdata, errChan)
+			err = <-errChan
+			if err != nil {
+				// Invalid GCSS, return an error page
+				ac.PrettyError(w, req, GCSSFilename, gcssdata, err.Error(), "gcss")
+				return
+			}
+		}
+		// Link to stylesheet (without checking if the GCSS file is valid first)
+		amberdata = themes.StyleAmber(amberdata, themes.DefaultGCSSFilename)
+	}
+
+	// Compile the given amber template
+	tpl, err := amber.CompileData(amberdata, filename, amber.Options{PrettyPrint: true, LineNumbers: false})
+	if err != nil {
+		if ac.debugMode {
+			ac.PrettyError(w, req, filename, amberdata, err.Error(), "amber")
+		} else {
+			log.Errorf("Could not compile Amber template:\n%s\n%s", err, string(amberdata))
+		}
+		return
+	}
+
+	// Render the Amber template to the buffer
+	if err := tpl.Execute(&buf, funcs); err != nil {
+
+		// If it was one particular error, where the template can not find the
+		// function or variable name that is used, give the user a friendlier
+		// message.
+		if strings.TrimSpace(err.Error()) == "reflect: call of reflect.Value.Type on zero Value" {
+			errortext := "Could not execute Amber template!<br>One of the functions called by the template is not available."
+			if ac.debugMode {
+				ac.PrettyError(w, req, filename, amberdata, errortext, "amber")
+			} else {
+				errortext = strings.Replace(errortext, "<br>", "\n", 1)
+				log.Errorf("Could not execute Amber template:\n%s", errortext)
+			}
+		} else {
+			if ac.debugMode {
+				ac.PrettyError(w, req, filename, amberdata, err.Error(), "amber")
+			} else {
+				log.Errorf("Could not execute Amber template:\n%s", err)
+			}
+		}
+		return
+	}
+
+	// If the auto-refresh feature has been enabled
+	if ac.autoRefresh {
+		// Insert JavaScript for refreshing the page into the generated HTML
+		changedBytes := ac.InsertAutoRefresh(req, buf.Bytes())
+
+		buf.Reset()
+		_, err := buf.Write(changedBytes)
+		if err != nil {
+			if ac.debugMode {
+				ac.PrettyError(w, req, filename, amberdata, err.Error(), "amber")
+			} else {
+				log.Errorf("Can not write bytes to a buffer! Out of memory?\n%s", err)
+			}
+			return
+		}
+	}
+
+	// If doctype is missing, add doctype for HTML5 at the top
+	changedBuf := bytes.NewBuffer(themes.InsertDoctype(buf.Bytes()))
+	buf = *changedBuf
+
+	// Write the rendered template to the client
+	ac.DataToClient(w, req, filename, buf.Bytes())
+}
+
+// GCSSPage writes the given source bytes (in GCSS) converted to CSS, to a writer.
+// The filename is only used in the error message, if any.
+func (ac *Config) GCSSPage(w http.ResponseWriter, req *http.Request, filename string, gcssdata []byte) {
+	var buf bytes.Buffer
+	if _, err := gcss.Compile(&buf, bytes.NewReader(gcssdata)); err != nil {
+		if ac.debugMode {
+			fmt.Fprintf(w, "Could not compile GCSS:\n\n%s\n%s", err, string(gcssdata))
+		} else {
+			log.Errorf("Could not compile GCSS:\n%s\n%s", err, string(gcssdata))
+		}
+		return
+	}
+	// Write the resulting CSS to the client
+	ac.DataToClient(w, req, filename, buf.Bytes())
+}
+
+// JSXPage writes the given source bytes (in JSX) converted to JS, to a writer.
+// The filename is only used in the error message, if any.
+func (ac *Config) JSXPage(w http.ResponseWriter, req *http.Request, filename string, jsxdata []byte) {
+	var buf bytes.Buffer
+	buf.Write(jsxdata)
+
+	// Convert JSX to JS
+	result := api.Transform(buf.String(), ac.jsxOptions)
+	if len(result.Errors) > 0 {
+		if ac.debugMode {
+			var sb strings.Builder
+			for _, errMsg := range result.Errors {
+				sb.WriteString(fmt.Sprintf("error: %s %s:%d:%d\n", errMsg.Text, filename, errMsg.Location.Line, errMsg.Location.Column))
+			}
+			for _, warnMsg := range result.Warnings {
+				sb.WriteString(fmt.Sprintf("warning: %s %s:%d:%d\n", warnMsg.Text, filename, warnMsg.Location.Line, warnMsg.Location.Column))
+			}
+			ac.PrettyError(w, req, filename, jsxdata, sb.String(), "jsx")
+		} else {
+			// TODO: Use a similar error page as for Lua
+			for _, errMsg := range result.Errors {
+				log.Errorf("error: %s %s:%d:%d\n", errMsg.Text, filename, errMsg.Location.Line, errMsg.Location.Column)
+			}
+			for _, warnMsg := range result.Warnings {
+				log.Errorf("warning: %s %s:%d:%d\n", warnMsg.Text, filename, warnMsg.Location.Line, warnMsg.Location.Column)
+			}
+		}
+		return
+	}
+	data := result.Code
+
+	// Use "h" instead of "React.createElement" for hyperApp apps
+	if ac.hyperApp {
+		data = bytes.ReplaceAll(data, []byte("React.createElement("), []byte("h("))
+	}
+
+	ac.DataToClient(w, req, filename, data)
+}
+
+// HyperAppPage writes the given source bytes (in JSX for HyperApp) converted to JS, to a writer.
+// The filename is only used in the error message, if any.
+func (ac *Config) HyperAppPage(w http.ResponseWriter, req *http.Request, filename string, jsxdata []byte) {
+	var (
+		htmlbuf strings.Builder
+		jsxbuf  bytes.Buffer
+	)
+
+	// Wrap the rendered HyperApp JSX in some HTML
+	htmlbuf.WriteString("<!doctype html><html><head>")
+
+	// If style.gcss is present, use that style in <head>
+	CSSFilename := filepath.Join(filepath.Dir(filename), themes.DefaultCSSFilename)
+	GCSSFilename := filepath.Join(filepath.Dir(filename), themes.DefaultGCSSFilename)
+	switch {
+	case ac.fs.Exists(CSSFilename):
+		// Link to stylesheet (without checking if the GCSS file is valid first)
+		htmlbuf.WriteString(`<link href="`)
+		htmlbuf.WriteString(themes.DefaultCSSFilename)
+		htmlbuf.WriteString(`" rel="stylesheet" type="text/css">`)
+	case ac.fs.Exists(GCSSFilename):
+		if ac.debugMode {
+			gcssblock, err := ac.cache.Read(GCSSFilename, ac.shouldCache(".gcss"))
+			if err != nil {
+				fmt.Fprintf(w, "Unable to read %s: %s", filename, err)
+				return
+			}
+			gcssdata := gcssblock.Bytes()
+
+			// Try compiling the GCSS file first
+			errChan := make(chan error)
+			go ValidGCSS(gcssdata, errChan)
+			err = <-errChan
+			if err != nil {
+				// Invalid GCSS, return an error page
+				ac.PrettyError(w, req, GCSSFilename, gcssdata, err.Error(), "gcss")
+				return
+			}
+		}
+		// Link to stylesheet (without checking if the GCSS file is valid first)
+		htmlbuf.WriteString(`<link href="`)
+		htmlbuf.WriteString(themes.DefaultGCSSFilename)
+		htmlbuf.WriteString(`" rel="stylesheet" type="text/css">`)
+	default:
+		// If not, use the default hyperapp theme by inserting the CSS style directly
+		theme := ac.defaultTheme
+
+		// Use the "neon" theme by default for HyperApp
+		if theme == "default" {
+			theme = "neon"
+		}
+
+		htmlbuf.Write(themes.StyleHead(theme))
+	}
+
+	// Convert JSX to JS
+	jsxbuf.Write(jsxdata)
+	jsxResult := api.Transform(jsxbuf.String(), ac.jsxOptions)
+
+	if len(jsxResult.Errors) > 0 {
+		if ac.debugMode {
+			var sb strings.Builder
+			for _, errMsg := range jsxResult.Errors {
+				sb.WriteString(fmt.Sprintf("error: %s %s:%d:%d\n", errMsg.Text, filename, errMsg.Location.Line, errMsg.Location.Column))
+			}
+			for _, warnMsg := range jsxResult.Warnings {
+				sb.WriteString(fmt.Sprintf("warning: %s %s:%d:%d\n", warnMsg.Text, filename, warnMsg.Location.Line, warnMsg.Location.Column))
+			}
+			ac.PrettyError(w, req, filename, jsxdata, sb.String(), "jsx")
+		} else {
+			// TODO: Use a similar error page as for Lua
+			for _, errMsg := range jsxResult.Errors {
+				log.Errorf("error: %s %s:%d:%d\n", errMsg.Text, filename, errMsg.Location.Line, errMsg.Location.Column)
+			}
+			for _, warnMsg := range jsxResult.Warnings {
+				log.Errorf("warning: %s %s:%d:%d\n", warnMsg.Text, filename, warnMsg.Location.Line, warnMsg.Location.Column)
+			}
+		}
+		return
+	}
+
+	// Include the hyperapp javascript from unpkg.com
+	// htmlbuf.WriteString("</head><body><script src=\"https://unpkg.com/hyperapp\"></script><script>")
+
+	// Embed the hyperapp script directly, for speed
+	htmlbuf.WriteString("</head><body><script>")
+	htmlbuf.Write(hyperAppJSBytes)
+
+	// The HyperApp library + compiled JSX can live in the same script tag. No need for this:
+	// htmlbuf.WriteString("</script><script>")
+
+	jsxData := jsxResult.Code
+
+	// Use "h" instead of "React.createElement"
+	jsxData = bytes.ReplaceAll(jsxData, []byte("React.createElement("), []byte("h("))
+
+	// If the file does not seem to contain the hyper app import: add it to the top of the script
+	// TODO: Consider making a more robust (and slower) check that splits the data into words first
+	if !bytes.Contains(jsxData, []byte("import { h,")) {
+		htmlbuf.WriteString("const { h, app } = hyperapp;")
+	}
+
+	// Insert the JS data
+	htmlbuf.Write(jsxData)
+
+	// Tail of the HTML wrapper page
+	htmlbuf.WriteString("</script></body>")
+
+	// Output HTML + JS to browser
+	ac.DataToClient(w, req, filename, []byte(htmlbuf.String()))
+}
+
+// SCSSPage writes the given source bytes (in SCSS) converted to CSS, to a writer.
+// The filename is only used in the error message, if any.
+func (ac *Config) SCSSPage(w http.ResponseWriter, req *http.Request, filename string, scssdata []byte) {
+	// TODO: Gather stderr and print with log.Errorf if needed
+	o := console.Output{}
+	// Silence the compiler output
+	if !ac.debugMode {
+		o.Disable()
+	}
+	// Compile the given filename. Sass might want to import other file, which is probably
+	// why the Sass compiler doesn't support just taking in a slice of bytes.
+	cssString, err := compiler.Run(filename)
+	if !ac.debugMode {
+		o.Enable()
+	}
+	if err != nil {
+		if ac.debugMode {
+			fmt.Fprintf(w, "Could not compile SCSS:\n\n%s\n%s", err, string(scssdata))
+		} else {
+			log.Errorf("Could not compile SCSS:\n%s\n%s", err, string(scssdata))
+		}
+		return
+	}
+	// Write the resulting CSS to the client
+	ac.DataToClient(w, req, filename, []byte(cssString))
+}

+ 407 - 0
engine/repl.go

@@ -0,0 +1,407 @@
+package engine
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+
+	"github.com/chzyer/readline"
+	"github.com/mitchellh/go-homedir"
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/lua/codelib"
+	"github.com/xyproto/algernon/lua/convert"
+	"github.com/xyproto/algernon/lua/datastruct"
+	"github.com/xyproto/algernon/lua/jnode"
+	"github.com/xyproto/algernon/lua/pure"
+	"github.com/xyproto/ask"
+	"github.com/xyproto/env/v2"
+	lua "github.com/xyproto/gopher-lua"
+	"github.com/xyproto/textoutput"
+)
+
+const exitMessage = "bye"
+
+// Export Lua functions specific to the REPL
+func exportREPLSpecific(L *lua.LState) {
+	// Attempt to return a more informative text than the memory location.
+	// Can take several arguments, just like print().
+	L.SetGlobal("pprint", 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")
+			}
+		}
+
+		// Output the combined text
+		fmt.Println(buf.String())
+
+		return 0 // number of results
+	}))
+
+	// Get the current directory since this is probably in the REPL
+	L.SetGlobal("scriptdir", L.NewFunction(func(L *lua.LState) int {
+		scriptpath, err := os.Getwd()
+		if err != nil {
+			log.Error(err)
+			L.Push(lua.LString("."))
+			return 1 // number of results
+		}
+		top := L.GetTop()
+		if top == 1 {
+			// Also include a separator and a filename
+			fn := L.ToString(1)
+			scriptpath = filepath.Join(scriptpath, fn)
+		}
+		// Now have the correct absolute scriptpath
+		L.Push(lua.LString(scriptpath))
+		return 1 // number of results
+	}))
+}
+
+// Split the given line in three parts, and color the parts
+func colorSplit(line, sep string, colorFunc1, colorFuncSep, colorFunc2 func(string) string, reverse bool) (string, string) {
+	if strings.Contains(line, sep) {
+		fields := strings.SplitN(line, sep, 2)
+		s1 := ""
+		if colorFunc1 != nil {
+			s1 += colorFunc1(fields[0])
+		} else {
+			s1 += fields[0]
+		}
+		s2 := ""
+		if colorFunc2 != nil {
+			s2 += colorFuncSep(sep) + colorFunc2(fields[1])
+		} else {
+			s2 += sep + fields[1]
+		}
+		return s1, s2
+	}
+	if reverse {
+		return "", line
+	}
+	return line, ""
+}
+
+// Syntax highlight the given line
+func highlight(o *textoutput.TextOutput, line string) string {
+	unprocessed := line
+	unprocessed, comment := colorSplit(unprocessed, "//", nil, o.DarkGray, o.DarkGray, false)
+	module, unprocessed := colorSplit(unprocessed, ":", o.LightGreen, o.DarkRed, nil, true)
+	function := ""
+	if unprocessed != "" {
+		// Green function names
+		if strings.Contains(unprocessed, "(") {
+			fields := strings.SplitN(unprocessed, "(", 2)
+			function = o.LightGreen(fields[0])
+			unprocessed = "(" + fields[1]
+		} else if strings.Contains(unprocessed, "|") {
+			unprocessed = "<magenta>" + strings.ReplaceAll(unprocessed, "|", "<white>|</white><magenta>") + "</magenta>"
+		}
+	}
+	unprocessed, typed := colorSplit(unprocessed, "->", nil, o.LightBlue, o.DarkRed, false)
+	unprocessed = strings.ReplaceAll(unprocessed, "string", o.LightBlue("string"))
+	unprocessed = strings.ReplaceAll(unprocessed, "number", o.LightYellow("number"))
+	unprocessed = strings.ReplaceAll(unprocessed, "function", o.LightCyan("function"))
+	return module + function + unprocessed + typed + comment
+}
+
+// Output syntax highlighted help text, with an additional usage message
+func outputHelp(o *textoutput.TextOutput, helpText string) {
+	for _, line := range strings.Split(helpText, "\n") {
+		o.Println(highlight(o, line))
+	}
+	o.Println(usageMessage)
+}
+
+// Output syntax highlighted help about a specific topic or function
+func outputHelpAbout(o *textoutput.TextOutput, helpText, topic string) {
+	switch topic {
+	case "help":
+		o.Println(o.DarkGray("Output general help or help about a specific topic."))
+		return
+	case "webhelp":
+		o.Println(o.DarkGray("Output help about web-related functions."))
+		return
+	case "confighelp":
+		o.Println(o.DarkGray("Output help about configuration-related functions."))
+		return
+	case "quit", "exit", "shutdown", "halt":
+		o.Println(o.DarkGray("Quit Algernon."))
+		return
+	}
+	comment := ""
+	for _, line := range strings.Split(helpText, "\n") {
+		if strings.HasPrefix(line, topic) {
+			// Output help text, with some surrounding blank lines
+			o.Println("\n" + highlight(o, line))
+			o.Println("\n" + o.DarkGray(strings.TrimSpace(comment)) + "\n")
+			return
+		}
+		// Gather comments until a non-comment is encountered
+		if strings.HasPrefix(line, "//") {
+			comment += strings.TrimSpace(line[2:]) + "\n"
+		} else {
+			comment = ""
+		}
+	}
+	o.Println(o.DarkGray("Found no help for: ") + o.White(topic))
+}
+
+// Take all functions mentioned in the given help text string and add them to the readline completer
+func addFunctionsFromHelptextToCompleter(helpText string, completer *readline.PrefixCompleter) {
+	for _, line := range strings.Split(helpText, "\n") {
+		if !strings.HasPrefix(line, "//") && strings.Contains(line, "(") {
+			parts := strings.Split(line, "(")
+			if strings.Contains(line, "()") {
+				completer.Children = append(completer.Children, &readline.PrefixCompleter{Name: []rune(parts[0] + "()")})
+			} else {
+				completer.Children = append(completer.Children, &readline.PrefixCompleter{Name: []rune(parts[0] + "(")})
+			}
+		}
+	}
+}
+
+// LoadLuaFunctionsForREPL exports the various Lua functions that might be needed in the REPL
+func (ac *Config) LoadLuaFunctionsForREPL(L *lua.LState, o *textoutput.TextOutput) {
+	// Server configuration functions
+	ac.LoadServerConfigFunctions(L, "")
+
+	// Other basic system functions, like log()
+	ac.LoadBasicSystemFunctions(L)
+
+	// If there is a database backend
+	if ac.perm != nil {
+
+		// Retrieve the creator struct
+		creator := ac.perm.UserState().Creator()
+
+		// Simpleredis data structures
+		datastruct.LoadList(L, creator)
+		datastruct.LoadSet(L, creator)
+		datastruct.LoadHash(L, creator)
+		datastruct.LoadKeyValue(L, creator)
+
+		// For saving and loading Lua functions
+		codelib.Load(L, creator)
+	}
+
+	// For handling JSON data
+	jnode.LoadJSONFunctions(L)
+	ac.LoadJFile(L, ac.serverDirOrFilename)
+	jnode.Load(L)
+
+	// Extras
+	pure.Load(L)
+
+	// Export pprint and scriptdir
+	exportREPLSpecific(L)
+
+	// Plugin functionality
+	ac.LoadPluginFunctions(L, o)
+
+	// Cache
+	ac.LoadCacheFunctions(L)
+}
+
+// REPL provides a "Read Eval Print" loop for interacting with Lua.
+// A variety of functions are exposed to the Lua state.
+func (ac *Config) REPL(ready, done chan bool) error {
+	var (
+		historyFilename string
+		err             error
+	)
+
+	historydir, err := homedir.Dir()
+	if err != nil {
+		log.Error("Could not find a user directory to store the REPL history.")
+		historydir = "."
+	}
+
+	// Retrieve a Lua state
+	L := ac.luapool.Get()
+	// Don't re-use the Lua state
+	defer L.Close()
+
+	// Colors and input
+	windows := (runtime.GOOS == "windows")
+	mingw := windows && strings.HasPrefix(env.Str("TERM"), "xterm")
+	enableColors := !windows || mingw
+	o := textoutput.NewTextOutput(enableColors, true)
+
+	// Command history file
+	if windows {
+		historyFilename = filepath.Join(historydir, "algernon_history.txt")
+	} else {
+		historyFilename = filepath.Join(historydir, ".algernon_history")
+	}
+
+	// Export a selection of functions to the Lua state
+	ac.LoadLuaFunctionsForREPL(L, o)
+
+	<-ready // Wait for the server to be ready
+
+	// Tell the user that the server is ready
+	o.Println(o.LightGreen("Ready"))
+
+	// Start the read, eval, print loop
+	var (
+		line     string
+		prompt   = o.LightCyan("lua> ")
+		EOF      bool
+		EOFcount int
+	)
+
+	var initialPrefixCompleters []readline.PrefixCompleterInterface
+	for _, word := range []string{"bye", "confighelp", "cwd", "dir", "exit", "help", "pwd", "quit", "serverdir", "serverfile", "webhelp", "zalgo"} {
+		initialPrefixCompleters = append(initialPrefixCompleters, &readline.PrefixCompleter{Name: []rune(word)})
+	}
+
+	prefixCompleter := readline.NewPrefixCompleter(initialPrefixCompleters...)
+
+	addFunctionsFromHelptextToCompleter(generalHelpText, prefixCompleter)
+
+	l, err := readline.NewEx(&readline.Config{
+		Prompt:            prompt,
+		HistoryFile:       historyFilename,
+		AutoComplete:      prefixCompleter,
+		InterruptPrompt:   "^C",
+		EOFPrompt:         "exit",
+		HistorySearchFold: true,
+	})
+	if err != nil {
+		log.Error("Could not initiate github.com/chzyer/readline: " + err.Error())
+	}
+
+	// To be run at server shutdown
+	AtShutdown(func() {
+		// Verbose mode has different log output at shutdown
+		if !ac.verboseMode {
+			o.Println(o.LightBlue(exitMessage))
+		}
+	})
+	for {
+		// Retrieve user input
+		EOF = false
+		if mingw {
+			// No support for EOF
+			line = ask.Ask(prompt)
+		} else {
+			if line, err = l.Readline(); err != nil {
+				switch {
+				case err == io.EOF:
+					if ac.debugMode {
+						o.Println(o.LightPurple(err.Error()))
+					}
+					EOF = true
+				case err == readline.ErrInterrupt:
+					log.Warn("Interrupted")
+					done <- true
+					return nil
+				default:
+					log.Error("Error reading line(" + err.Error() + ").")
+					continue
+				}
+			}
+		}
+		if EOF {
+			if ac.ctrldTwice {
+				switch EOFcount {
+				case 0:
+					o.Err("Press ctrl-d again to exit.")
+					EOFcount++
+					continue
+				default:
+					done <- true
+					return nil
+				}
+			} else {
+				done <- true
+				return nil
+			}
+		}
+
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+
+		switch line {
+		case "help":
+			outputHelp(o, generalHelpText)
+			continue
+		case "webhelp":
+			outputHelp(o, webHelpText)
+			continue
+		case "confighelp":
+			outputHelp(o, configHelpText)
+			continue
+		case "dir":
+			// Be more helpful than listing the Lua bytecode contents of the dir function. Call "dir()".
+			line = "dir()"
+		case "cwd", "pwd":
+			if cwd, err := os.Getwd(); err != nil {
+				// Might work if Getwd should fail. Should work on Windows, Linux and macOS
+				line = "os.getenv'CD' or os.getenv'PWD'"
+			} else {
+				fmt.Println(cwd)
+				continue
+			}
+		case "serverfile", "serverdir":
+			if absdir, err := filepath.Abs(ac.serverDirOrFilename); err != nil {
+				fmt.Println(ac.serverDirOrFilename)
+			} else {
+				fmt.Println(absdir)
+			}
+			continue
+		case "quit", "exit", "shutdown", "halt":
+			done <- true
+			return nil
+		case "zalgo":
+			// Easter egg
+			o.ErrExit("Ḫ̷̲̫̰̯̭̀̂̑~ͅĚ̥̖̩̘̱͔͈͈ͬ̚ ̦̦͖̲̀ͦ͂C̜͓̲̹͐̔ͭ̏Oͭ͛͂̋ͭͬͬ͆͏̺͓̰͚͠ͅM̢͉̼̖͍̊̕Ḛ̭̭͗̉̀̆ͬ̐ͪ̒S͉̪͂͌̄")
+		default:
+			topic := ""
+			if len(line) > 5 && (strings.HasPrefix(line, "help(") || strings.HasPrefix(line, "help ")) {
+				topic = line[5:]
+			} else if len(line) > 8 && (strings.HasPrefix(line, "webhelp(") || strings.HasPrefix(line, "webhelp ")) {
+				topic = line[8:]
+			}
+			if len(topic) > 0 {
+				topic = strings.TrimSuffix(topic, ")")
+				outputHelpAbout(o, generalHelpText+webHelpText+configHelpText, topic)
+				continue
+			}
+
+		}
+
+		// If the line starts with print, don't touch it
+		if strings.HasPrefix(line, "print(") {
+			if err = L.DoString(line); err != nil {
+				// Output the error message
+				o.Err(err.Error())
+			}
+		} else {
+			// Wrap the line in "pprint"
+			if err = L.DoString("pprint(" + line + ")"); err != nil {
+				// If there was a syntax error, try again without pprint
+				if strings.Contains(err.Error(), "syntax error") {
+					if err = L.DoString(line); err != nil {
+						// Output the error message
+						o.Err(err.Error())
+					}
+					// For other kinds of errors, output the error
+				} else {
+					// Output the error message
+					o.Err(err.Error())
+				}
+			}
+		}
+	}
+}

+ 81 - 0
engine/revproxy.go

@@ -0,0 +1,81 @@
+package engine
+
+import (
+	"net/http"
+	"net/url"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/utils"
+)
+
+// ReverseProxy holds which path prefix (like "/api") should be sent where (like "http://localhost:8080")
+type ReverseProxy struct {
+	PathPrefix string
+	Endpoint   url.URL
+}
+
+// ReverseProxyConfig holds several "path prefix --> URL" ReverseProxy structs,
+// together with structures that speeds up the prefix matching.
+type ReverseProxyConfig struct {
+	proxyMatcher   utils.PrefixMatch
+	prefix2rproxy  map[string]int
+	ReverseProxies []ReverseProxy
+}
+
+// NewReverseProxyConfig creates a new and empty ReverseProxyConfig struct
+func NewReverseProxyConfig() *ReverseProxyConfig {
+	return &ReverseProxyConfig{}
+}
+
+// Add can add a ReverseProxy and will also (re-)initialize the internal proxy matcher
+func (rc *ReverseProxyConfig) Add(rp *ReverseProxy) {
+	rc.ReverseProxies = append(rc.ReverseProxies, *rp)
+	rc.Init()
+}
+
+// DoProxyPass tries to proxy the given http.Request to where the ReverseProxy points
+func (rp *ReverseProxy) DoProxyPass(req http.Request) (*http.Response, error) {
+	client := &http.Client{}
+	endpoint := rp.Endpoint
+	req.RequestURI = ""
+	req.URL.Path = req.URL.Path[len(rp.PathPrefix):]
+	req.URL.Scheme = endpoint.Scheme
+	req.URL.Host = endpoint.Host
+	res, err := client.Do(&req)
+	if err != nil {
+		log.Errorf("reverse proxy error: %v\nPlease check your server config for AddReverseProxy calls.\n", err)
+		return nil, err
+	}
+	return res, nil
+}
+
+// Init prepares the proxyMatcher and prefix2rproxy fields according to the ReverseProxy structs
+func (rc *ReverseProxyConfig) Init() {
+	keys := make([]string, 0, len(rc.ReverseProxies))
+	rc.prefix2rproxy = make(map[string]int)
+	for i, rp := range rc.ReverseProxies {
+		keys = append(keys, rp.PathPrefix)
+		rc.prefix2rproxy[rp.PathPrefix] = i
+	}
+	rc.proxyMatcher.Build(keys)
+}
+
+// FindMatchingReverseProxy checks if the given URL path should be proxied
+func (rc *ReverseProxyConfig) FindMatchingReverseProxy(path string) *ReverseProxy {
+	matches := rc.proxyMatcher.Match(path)
+	if len(matches) == 0 {
+		return nil
+	}
+	if len(matches) > 1 {
+		log.Warnf("found more than one reverse proxy for `%s`: %+v. returning the longest", matches, path)
+	}
+	var match *ReverseProxy
+	maxlen := 0
+	for _, prefix := range matches {
+		if len(prefix) > maxlen {
+			maxlen = len(prefix)
+			match = &rc.ReverseProxies[rc.prefix2rproxy[prefix]]
+		}
+	}
+	return match
+}

+ 328 - 0
engine/serve.go

@@ -0,0 +1,328 @@
+package engine
+
+import (
+	"errors"
+	"net/http"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/caddyserver/certmagic"
+	log "github.com/sirupsen/logrus"
+	"github.com/tylerb/graceful"
+	"github.com/xyproto/env/v2"
+	"golang.org/x/net/http2"
+)
+
+// List of functions to run at shutdown
+var (
+	shutdownFunctions []func()
+	mut               sync.Mutex
+	completed         bool
+)
+
+// AtShutdown adds a function to the list of functions that will be ran at shutdown
+func AtShutdown(shutdownFunction func()) {
+	mut.Lock()
+	defer mut.Unlock()
+	shutdownFunctions = append(shutdownFunctions, shutdownFunction)
+}
+
+// NewGracefulServer creates a new graceful server configuration
+func (ac *Config) NewGracefulServer(mux *http.ServeMux, http2support bool, addr string) *graceful.Server {
+	// Server configuration
+	s := &http.Server{
+		Addr:    addr,
+		Handler: mux,
+
+		// The timeout values is also the maximum time it can take
+		// for a complete page of Server-Sent Events (SSE).
+		ReadTimeout:  10 * time.Second,
+		WriteTimeout: time.Duration(ac.writeTimeout) * time.Second,
+
+		MaxHeaderBytes: 1 << 20,
+	}
+	if http2support {
+		// Enable HTTP/2 support
+		http2.ConfigureServer(s, nil)
+	}
+	gracefulServer := &graceful.Server{
+		Server:  s,
+		Timeout: ac.shutdownTimeout,
+	}
+	// Handle ctrl-c
+	gracefulServer.ShutdownInitiated = ac.GenerateShutdownFunction(gracefulServer) // for investigating gracefulServer.Interrupted
+	return gracefulServer
+}
+
+// GenerateShutdownFunction generates a function that will run the postponed
+// shutdown functions.  Note that gracefulServer can be nil. It's only used for
+// finding out if the server was interrupted (ctrl-c or killed, SIGINT/SIGTERM)
+func (ac *Config) GenerateShutdownFunction(gracefulServer *graceful.Server) func() {
+	return func() {
+		mut.Lock()
+		defer mut.Unlock()
+
+		if completed {
+			// The shutdown functions have already been called
+			return
+		}
+
+		if ac.verboseMode {
+			log.Info("Initiating shutdown")
+		}
+
+		// Call the shutdown functions in chronological order (FIFO)
+		for _, shutdownFunction := range shutdownFunctions {
+			shutdownFunction()
+		}
+
+		completed = true
+
+		if ac.verboseMode {
+			log.Info("Shutdown complete")
+		}
+
+		// Forced shutdown
+		if gracefulServer != nil {
+			if gracefulServer.Interrupted {
+				// gracefulServer.Stop(forcedShutdownTimeout)
+				ac.fatalExit(errors.New("Interrupted"))
+			}
+		}
+		// TODO: To implement
+		//if quicServer != nil {
+		//fmt.Println("DEBUG: Has QUIC server at shutdown!")
+		//}
+
+		// One final flush
+		os.Stdout.Sync()
+	}
+}
+
+// Serve HTTP, HTTP/2 and/or HTTPS. Returns an error if unable to serve, or nil when done serving.
+func (ac *Config) Serve(mux *http.ServeMux, done, ready chan bool) error {
+	// If we are not writing internal logs to a file, reduce the verbosity
+	http2.VerboseLogs = (ac.internalLogFilename != os.DevNull)
+
+	if ac.onlyLuaMode {
+		ready <- true // Send a "ready" message to the REPL
+		<-done        // Wait for a "done" message from the REPL (or just keep waiting)
+		// Serve nothing
+		return nil // Done
+	}
+
+	// Channel to wait and see if we should just serve regular HTTP instead
+	justServeRegularHTTP := make(chan bool)
+
+	servingHTTPS := false
+	servingHTTP := false
+
+	// Goroutine that wait for a message to just serve regular HTTP, if needed
+	go func() {
+		<-justServeRegularHTTP // Wait for a message to just serve regular HTTP
+		if strings.HasPrefix(ac.serverAddr, ":") {
+			log.Info("Serving HTTP on http://localhost" + ac.serverAddr + "/")
+		} else {
+			log.Info("Serving HTTP on http://" + ac.serverAddr + "/")
+		}
+		mut.Lock()
+		servingHTTP = true
+		mut.Unlock()
+		HTTPserver := ac.NewGracefulServer(mux, false, ac.serverAddr)
+		// Open the URL before the serving has started, in a short delay
+		if ac.openURLAfterServing && ac.luaServerFilename != "" {
+			go func() {
+				time.Sleep(delayBeforeLaunchingBrowser)
+				ac.OpenURL(ac.serverHost, ac.serverAddr, false)
+			}()
+		}
+		// Start serving. Shut down gracefully at exit.
+		if err := HTTPserver.ListenAndServe(); err != nil {
+			mut.Lock()
+			servingHTTP = false
+			mut.Unlock()
+			// If we can't serve regular HTTP on port 80, give up
+			ac.fatalExit(err)
+		}
+	}()
+
+	// Decide which protocol to listen to
+	switch {
+	case ac.useCertMagic:
+		if len(ac.certMagicDomains) == 0 {
+			log.Warnln("Found no directories looking like domains in the given directory.")
+		} else if len(ac.certMagicDomains) == 1 {
+			log.Infof("Serving one domain with CertMagic: %s", ac.certMagicDomains[0])
+		} else {
+			log.Infof("Serving %d domains with CertMagic: %s", len(ac.certMagicDomains), strings.Join(ac.certMagicDomains, ", "))
+		}
+		mut.Lock()
+		servingHTTPS = true
+		mut.Unlock()
+		// TODO: Look at "Advanced use" at https://github.com/caddyserver/certmagic#examples
+		// Listen for HTTP and HTTPS requests, for specific domain(s)
+		go func() {
+			// If $XDG_CONFIG_DIR is not set, use $HOME.
+			// If $HOME is not set, use $TMPDIR.
+			// If $TMPDIR is not set, use /tmp.
+			certStorageDir := env.StrAlt("XDG_CONFIG_DIR", "HOME", env.Str("TMPDIR", "/tmp"))
+
+			defaultEmail := env.Str("LOGNAME", "root") + "@localhost"
+			if len(ac.certMagicDomains) > 0 {
+				defaultEmail = "webmaster@" + ac.certMagicDomains[0]
+			}
+
+			certmagic.DefaultACME.Email = env.Str("EMAIL", defaultEmail)
+			// TODO: Find a way for Algernon users to agree on this manually
+			certmagic.DefaultACME.Agreed = true
+			certmagic.Default.Storage = &certmagic.FileStorage{Path: certStorageDir}
+			if err := certmagic.HTTPS(ac.certMagicDomains, mux); err != nil {
+				mut.Lock()
+				servingHTTPS = false
+				mut.Unlock()
+				log.Error(err)
+				// Don't serve HTTP if CertMagic fails, just quit
+				// justServeRegularHTTP <- true
+			}
+		}()
+	case ac.serveJustQUIC: // Just serve QUIC, but fallback to HTTP
+		if strings.HasPrefix(ac.serverAddr, ":") {
+			log.Info("Serving QUIC on https://localhost" + ac.serverAddr + "/")
+		} else {
+			log.Info("Serving QUIC on https://" + ac.serverAddr + "/")
+		}
+		mut.Lock()
+		servingHTTPS = true
+		mut.Unlock()
+		// Start serving over QUIC
+		go ac.ListenAndServeQUIC(mux, &mut, justServeRegularHTTP, &servingHTTPS)
+	case ac.productionMode:
+		// Listen for both HTTPS+HTTP/2 and HTTP requests, on different ports
+		if len(ac.serverHost) == 0 {
+			log.Info("Serving HTTP/2 on https://localhost/")
+		} else {
+			log.Info("Serving HTTP/2 on https://" + ac.serverHost + "/")
+		}
+		mut.Lock()
+		servingHTTPS = true
+		mut.Unlock()
+		go func() {
+			// Start serving. Shut down gracefully at exit.
+			// Listen for HTTPS + HTTP/2 requests
+			HTTPS2server := ac.NewGracefulServer(mux, true, ac.serverHost+":443")
+			// Start serving. Shut down gracefully at exit.
+			if err := HTTPS2server.ListenAndServeTLS(ac.serverCert, ac.serverKey); err != nil {
+				mut.Lock()
+				servingHTTPS = false
+				mut.Unlock()
+				log.Error(err)
+			}
+		}()
+		if len(ac.serverHost) == 0 {
+			log.Info("Serving HTTP on http://localhost/")
+		} else {
+			log.Info("Serving HTTP on http://" + ac.serverHost + "/")
+		}
+		mut.Lock()
+		servingHTTP = true
+		mut.Unlock()
+		go func() {
+			if ac.redirectHTTP {
+				// Redirect HTTPS to HTTP
+				redirectFunc := func(w http.ResponseWriter, req *http.Request) {
+					http.Redirect(w, req, "https://"+req.Host+req.URL.String(), http.StatusMovedPermanently)
+				}
+				if err := http.ListenAndServe(ac.serverHost+":80", http.HandlerFunc(redirectFunc)); err != nil {
+					mut.Lock()
+					servingHTTP = false
+					mut.Unlock()
+					// If we can't serve regular HTTP on port 80, give up
+					ac.fatalExit(err)
+				}
+			} else {
+				// Don't redirect, but serve the same contents as the HTTPS server as HTTP on port 80
+				HTTPserver := ac.NewGracefulServer(mux, false, ac.serverHost+":80")
+				if err := HTTPserver.ListenAndServe(); err != nil {
+					mut.Lock()
+					servingHTTP = false
+					mut.Unlock()
+					// If we can't serve regular HTTP on port 80, give up
+					ac.fatalExit(err)
+				}
+			}
+		}()
+	case ac.serveJustHTTP2: // It's unusual to serve HTTP/2 without HTTPS
+		if strings.HasPrefix(ac.serverAddr, ":") {
+			log.Warn("Serving HTTP/2 without HTTPS (not recommended!) on http://localhost" + ac.serverAddr + "/")
+		} else {
+			log.Warn("Serving HTTP/2 without HTTPS (not recommended!) on http://" + ac.serverAddr + "/")
+		}
+		mut.Lock()
+		servingHTTPS = true
+		mut.Unlock()
+		go func() {
+			// Listen for HTTP/2 requests
+			HTTP2server := ac.NewGracefulServer(mux, true, ac.serverAddr)
+			// Start serving. Shut down gracefully at exit.
+			if err := HTTP2server.ListenAndServe(); err != nil {
+				mut.Lock()
+				servingHTTPS = false
+				mut.Unlock()
+				justServeRegularHTTP <- true
+				log.Error(err)
+			}
+		}()
+	case !ac.serveJustHTTP2 && !ac.serveJustHTTP:
+		if strings.HasPrefix(ac.serverAddr, ":") {
+			log.Info("Serving HTTP/2 on https://localhost" + ac.serverAddr + "/")
+		} else {
+			log.Info("Serving HTTP/2 on https://" + ac.serverAddr + "/")
+		}
+		mut.Lock()
+		servingHTTPS = true
+		mut.Unlock()
+		// Listen for HTTPS + HTTP/2 requests
+		HTTPS2server := ac.NewGracefulServer(mux, true, ac.serverAddr)
+		// Start serving. Shut down gracefully at exit.
+		go func() {
+			if err := HTTPS2server.ListenAndServeTLS(ac.serverCert, ac.serverKey); err != nil {
+				log.Errorf("%s. Not serving HTTP/2.", err)
+				log.Info("Use the -t flag for serving regular HTTP.")
+				mut.Lock()
+				servingHTTPS = false
+				mut.Unlock()
+				// If HTTPS failed (perhaps the key + cert are missing),
+				// serve plain HTTP instead
+				justServeRegularHTTP <- true
+			}
+		}()
+	default:
+		mut.Lock()
+		servingHTTP = true
+		mut.Unlock()
+		justServeRegularHTTP <- true
+	}
+
+	// Wait just a tiny bit
+	time.Sleep(20 * time.Millisecond)
+
+	ready <- true // Send a "ready" message to the REPL
+
+	// Open the URL, if specified
+	if ac.openURLAfterServing {
+		// Open the https:// URL if both http:// and https:// are being served
+		mut.Lock()
+		if (!servingHTTP) && (!servingHTTPS) {
+			ac.fatalExit(errors.New("serving neither over https:// nor over https://"))
+		}
+		httpsProtocol := servingHTTPS
+		mut.Unlock()
+		ac.OpenURL(ac.serverHost, ac.serverAddr, httpsProtocol)
+	}
+
+	<-done // Wait for a "done" message from the REPL (or just keep waiting)
+
+	return nil // Done serving
+}

+ 140 - 0
engine/servelua.go

@@ -0,0 +1,140 @@
+package engine
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+
+	"github.com/xyproto/algernon/lua/convert"
+	"github.com/xyproto/algernon/utils"
+	"github.com/xyproto/gluamapper"
+	lua "github.com/xyproto/gopher-lua"
+	"github.com/xyproto/pongo2"
+
+	log "github.com/sirupsen/logrus"
+)
+
+// LoadServeFile exposes functions for serving other files to Lua
+func (ac *Config) LoadServeFile(w http.ResponseWriter, req *http.Request, L *lua.LState, filename string) {
+	// Serve a file in the scriptdir
+	L.SetGlobal("serve", L.NewFunction(func(L *lua.LState) int {
+		scriptdir := filepath.Dir(filename)
+		serveFilename := filepath.Join(scriptdir, L.ToString(1))
+		dataFilename := filepath.Join(scriptdir, ac.defaultLuaDataFilename)
+		if L.GetTop() >= 2 {
+			// Optional argument for using a different file than "data.lua"
+			dataFilename = filepath.Join(scriptdir, L.ToString(2))
+		}
+		if !ac.fs.Exists(serveFilename) {
+			log.Error("Could not serve " + serveFilename + ". File not found.")
+			return 0 // Number of results
+		}
+		if ac.fs.IsDir(serveFilename) {
+			log.Error("Could not serve " + serveFilename + ". Not a file.")
+			return 0 // Number of results
+		}
+		ac.FilePage(w, req, serveFilename, dataFilename)
+		return 0 // Number of results
+	}))
+
+	// Output text as rendered Pongo2, using a po2 file and an optional table
+	L.SetGlobal("serve2", L.NewFunction(func(L *lua.LState) int {
+		scriptdir := filepath.Dir(filename)
+
+		// Use the first argument as the template and the second argument as the data map
+		templateFilename := filepath.Join(scriptdir, L.CheckString(1))
+		ext := filepath.Ext(strings.ToLower(templateFilename))
+
+		templateData, err := ac.cache.Read(templateFilename, ac.shouldCache(ext))
+		if err != nil {
+			if ac.debugMode {
+				fmt.Fprintf(w, "Unable to read %s: %s", templateFilename, err)
+			} else {
+				log.Errorf("Unable to read %s: %s", templateFilename, err)
+			}
+			return 0 // number of restuls
+		}
+		templateString := templateData.String()
+
+		// If a table is given as the second argument, fill pongoMap with keys and values
+		pongoMap := make(pongo2.Context)
+
+		if L.GetTop() == 2 {
+			luaTable := L.CheckTable(2)
+
+			goMap := gluamapper.ToGoValue(luaTable, gluamapper.Option{
+				NameFunc: func(s string) string {
+					return s
+				},
+			})
+
+			if interfaceMap, ok := goMap.(map[any]any); ok {
+				// Try to convert from map[any]any to map[string]any
+				convertedMap := make(map[string]any)
+				for k, v := range interfaceMap {
+					convertedMap[k.(string)] = v
+				}
+				pongoMap = pongo2.Context(convertedMap)
+			} else if m, ok := goMap.(map[string]any); ok {
+				pongoMap = pongo2.Context(m)
+			}
+
+			// fmt.Println("PONGOMAP", pongoMap, "LUA TABLE", luaTable)
+		} else if L.GetTop() > 2 {
+			log.Error("Too many arguments given to the serve2 function")
+			return 0 // number of restuls
+		}
+
+		// Retrieve all the function arguments as a bytes.Buffer
+		buf := convert.Arguments2buffer(L, true)
+		// Use the buffer as a template.
+		// Options are "Pretty printing, but without line numbers."
+		tpl, err := pongo2.FromString(templateString)
+		if err != nil {
+			if ac.debugMode {
+				fmt.Fprint(w, "Could not compile Pongo2 template:\n\t"+err.Error()+"\n\n"+buf.String())
+			} else {
+				log.Errorf("Could not compile Pongo2 template:\n%s\n%s", err, buf.String())
+			}
+			return 0 // number of results
+		}
+		// nil is the template context (variables etc in a map)
+		if err := tpl.ExecuteWriter(pongoMap, w); err != nil {
+			if ac.debugMode {
+				fmt.Fprint(w, "Could not compile Pongo2:\n\t"+err.Error()+"\n\n"+buf.String())
+			} else {
+				log.Errorf("Could not compile Pongo2:\n%s\n%s", err, buf.String())
+			}
+		}
+		return 0 // number of results
+	}))
+
+	// Get the rendered contents of a file in the scriptdir. Discards HTTP headers.
+	L.SetGlobal("render", L.NewFunction(func(L *lua.LState) int {
+		scriptdir := filepath.Dir(filename)
+		serveFilename := filepath.Join(scriptdir, L.ToString(1))
+		dataFilename := filepath.Join(scriptdir, ac.defaultLuaDataFilename)
+		if L.GetTop() >= 2 {
+			// Optional argument for using a different file than "data.lua"
+			dataFilename = filepath.Join(scriptdir, L.ToString(2))
+		}
+		if !ac.fs.Exists(serveFilename) {
+			log.Error("Could not render " + serveFilename + ". File not found.")
+			return 0 // Number of results
+		}
+		if ac.fs.IsDir(serveFilename) {
+			log.Error("Could not render " + serveFilename + ". Not a file.")
+			return 0 // Number of results
+		}
+
+		// Render the filename to a httptest.Recorder
+		recorder := httptest.NewRecorder()
+		ac.FilePage(recorder, req, serveFilename, dataFilename)
+
+		// Return the recorder as a string
+		L.Push(lua.LString(utils.RecorderToString(recorder)))
+		return 1 // Number of results
+	}))
+}

+ 429 - 0
engine/serverconf.go

@@ -0,0 +1,429 @@
+package engine
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/utils"
+	lua "github.com/xyproto/gopher-lua"
+	bolt "github.com/xyproto/permissionbolt/v2"
+	redis "github.com/xyproto/permissions2/v2"
+	mariadb "github.com/xyproto/permissionsql/v2"
+	"github.com/xyproto/pinterface"
+	postgres "github.com/xyproto/pstore"
+	"github.com/xyproto/simpleredis/v2"
+)
+
+// Info returns a string with various info about the current configuration
+func (ac *Config) Info() string {
+	var sb strings.Builder
+
+	if !ac.singleFileMode {
+		sb.WriteString("Server directory:\t" + ac.serverDirOrFilename + "\n")
+	} else {
+		sb.WriteString("Filename:\t\t" + ac.serverDirOrFilename + "\n")
+	}
+	if !ac.productionMode {
+		sb.WriteString("Server address:\t\t" + ac.serverAddr + "\n")
+	} // else port 80 and 443
+	if ac.dbName == "" {
+		sb.WriteString("Database:\t\tDisabled\n")
+	} else {
+		sb.WriteString("Database:\t\t" + ac.dbName + "\n")
+	}
+	if ac.luaServerFilename != "" {
+		sb.WriteString("Server filename:\t" + ac.luaServerFilename + "\n")
+	}
+
+	// Write the status of flags that can be toggled
+	utils.WriteStatus(&sb, "Options", map[string]bool{
+		"Debug":        ac.debugMode,
+		"Production":   ac.productionMode,
+		"Auto-refresh": ac.autoRefresh,
+		"Dev":          ac.devMode,
+		"Server":       ac.serverMode,
+		"StatCache":    ac.cacheFileStat,
+	})
+
+	sb.WriteString("Cache mode:\t\t" + ac.cacheMode.String() + "\n")
+	if ac.cacheSize != 0 {
+		sb.WriteString(fmt.Sprintf("Cache size:\t\t%d bytes\n", ac.cacheSize))
+	}
+
+	if ac.serverLogFile != "" {
+		sb.WriteString("Log file:\t\t" + ac.serverLogFile + "\n")
+	}
+	if !(ac.serveJustHTTP2 || ac.serveJustHTTP) {
+		sb.WriteString("TLS certificate:\t" + ac.serverCert + "\n")
+		sb.WriteString("TLS key:\t\t" + ac.serverKey + "\n")
+	}
+	if ac.autoRefresh {
+		sb.WriteString("Event server:\t\t" + ac.eventAddr + "\n")
+	}
+	if ac.autoRefreshDir != "" {
+		sb.WriteString("Only watching:\t\t" + ac.autoRefreshDir + "\n")
+	}
+	if ac.redisAddr != ac.defaultRedisColonPort {
+		sb.WriteString("Redis address:\t\t" + ac.redisAddr + "\n")
+	}
+	if ac.disableRateLimiting {
+		sb.WriteString("Request limit:\t\tOff\n")
+	} else {
+		sb.WriteString(fmt.Sprintf("Request limit:\t\t%d/sec per visitor\n", ac.limitRequests))
+	}
+	if ac.redisDBindex != 0 {
+		sb.WriteString(fmt.Sprintf("Redis database index:\t%d\n", ac.redisDBindex))
+	}
+	if ac.largeFileSize > 0 {
+		sb.WriteString(fmt.Sprintf("Large file threshold:\t%v bytes\n", ac.largeFileSize))
+	}
+	if ac.writeTimeout > 0 {
+		sb.WriteString(fmt.Sprintf("Large file timeout:\t%v sec\n", ac.writeTimeout))
+	}
+	if len(ac.serverConfigurationFilenames) > 0 {
+		sb.WriteString(fmt.Sprintf("Server configuration:\t%v\n", ac.serverConfigurationFilenames))
+	}
+	if ac.internalLogFilename != os.DevNull {
+		sb.WriteString("Internal log file:\t" + ac.internalLogFilename + "\n")
+	}
+	return strings.TrimSpace(sb.String())
+}
+
+// LoadServerConfigFunctions makes functions related to server configuration and
+// permissions available to the given Lua struct.
+func (ac *Config) LoadServerConfigFunctions(L *lua.LState, filename string) error {
+	if ac.perm == nil {
+		return errors.New("perm is nil when loading server config functions")
+	}
+
+	// Set a default host and port. Maybe useful for alg applications.
+	L.SetGlobal("SetAddr", L.NewFunction(func(L *lua.LState) int {
+		ac.serverAddrLua = L.ToString(1)
+		return 0 // number of results
+	}))
+
+	// Set the default cookie secret. This is for the server config, before
+	// the userstate has been instanciated.
+	L.SetGlobal("SetCookieSecret", L.NewFunction(func(L *lua.LState) int {
+		ac.cookieSecret = L.ToString(1)
+		return 0 // number of results
+	}))
+
+	// Get the default cookie secret. THis is for the server config, before
+	// the userstate has been instanciated.
+	L.SetGlobal("CookieSecret", L.NewFunction(func(L *lua.LState) int {
+		L.Push(lua.LString(ac.cookieSecret))
+		return 1 // number of results
+	}))
+
+	// Clear the default path prefixes. This makes everything public.
+	L.SetGlobal("ClearPermissions", L.NewFunction(func(L *lua.LState) int {
+		ac.perm.Clear()
+		return 0 // number of results
+	}))
+
+	// Registers a path prefix, for instance "/secret",
+	// as having *user* rights.
+	L.SetGlobal("AddUserPrefix", L.NewFunction(func(L *lua.LState) int {
+		path := L.ToString(1)
+		ac.perm.AddUserPath(path)
+		return 0 // number of results
+	}))
+
+	// Registers a path prefix, for instance "/secret",
+	// as having *admin* rights.
+	L.SetGlobal("AddAdminPrefix", L.NewFunction(func(L *lua.LState) int {
+		path := L.ToString(1)
+		ac.perm.AddAdminPath(path)
+		return 0 // number of results
+	}))
+
+	// Add a new reverse proxy given a: path prefix, endpoint and endpoint URL
+	L.SetGlobal("AddReverseProxy", L.NewFunction(func(L *lua.LState) int {
+		var rp ReverseProxy
+
+		rp.PathPrefix = L.ToString(1)
+		endpointURLString := L.ToString(2)
+
+		parsedURL, err := url.Parse(endpointURLString)
+		if err != nil {
+			log.Errorf("could not parse endpoint URL: %s: %v", endpointURLString, err)
+		}
+		rp.Endpoint = *parsedURL
+
+		if ac.reverseProxyConfig == nil {
+			ac.reverseProxyConfig = NewReverseProxyConfig()
+		}
+		ac.reverseProxyConfig.Add(&rp)
+
+		return 0 // number of results
+	}))
+
+	// Sets a Lua function as a custom "permissions denied" page handler.
+	L.SetGlobal("DenyHandler", L.NewFunction(func(L *lua.LState) int {
+		luaDenyFunc := L.ToFunction(1)
+
+		// Custom handler for when permissions are denied
+		ac.perm.SetDenyFunction(func(w http.ResponseWriter, req *http.Request) {
+			// Set up a new Lua state with the current http.ResponseWriter and *http.Request, without caching
+			ac.LoadCommonFunctions(w, req, filename, L, nil, nil)
+
+			// Then run the given Lua function
+			L.Push(luaDenyFunc)
+			if err := L.PCall(0, lua.MultRet, nil); err != nil {
+				// Non-fatal error
+				log.Error("Permission denied handler failed:", err)
+				// Use the default permission handler from now on if the lua function fails
+				ac.perm.SetDenyFunction(redis.PermissionDenied)
+				ac.perm.DenyFunction()(w, req)
+			}
+		})
+		return 0 // number of results
+	}))
+
+	// Sets a Lua function to be run once the server is done parsing configuration and arguments.
+	L.SetGlobal("OnReady", L.NewFunction(func(L *lua.LState) int {
+		luaReadyFunc := L.ToFunction(1)
+
+		// Custom handler for when permissions are denied.
+		// Put the *lua.LState in a closure.
+		ac.serverReadyFunctionLua = func() {
+			// Run the given Lua function
+			L.Push(luaReadyFunc)
+			if err := L.PCall(0, lua.MultRet, nil); err != nil {
+				// Non-fatal error
+				log.Error("The OnReady function failed:", err)
+			}
+		}
+		return 0 // number of results
+	}))
+
+	// Set a access log filename. If blank, the log will go to the console (or browser, if debug mode is set).
+	L.SetGlobal("LogTo", L.NewFunction(func(L *lua.LState) int {
+		filename := L.ToString(1)
+		ac.serverLogFile = filename
+		// Log as JSON by default
+		log.SetFormatter(&log.JSONFormatter{})
+		// Log to stderr if an empty filename is given
+		if filename == "" {
+			log.SetOutput(os.Stderr)
+			L.Push(lua.LBool(true))
+			return 1 // number of results
+		}
+		// Try opening/creating the given filename, for appending
+		f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, ac.defaultPermissions)
+		if err != nil {
+			log.Error(err)
+			L.Push(lua.LBool(false))
+			return 1 // number of results
+		}
+		// Set the file to log to and return
+		log.SetOutput(f)
+		L.Push(lua.LBool(true))
+		return 1 // number of results
+	}))
+
+	// Use a single Lua file as the server, instead of directory structure
+	L.SetGlobal("ServerFile", L.NewFunction(func(L *lua.LState) int {
+		givenFilename := L.ToString(1)
+		serverFilename := filepath.Join(filepath.Dir(filename), givenFilename)
+		if !ac.fs.Exists(serverFilename) {
+			log.Error("Could not find", serverFilename)
+			L.Push(lua.LBool(false))
+			return 1 // number of results
+		}
+		ac.luaServerFilename = serverFilename
+		L.Push(lua.LBool(true))
+		return 1 // number of results
+	}))
+
+	// Set the server directory
+	L.SetGlobal("ServerDir", L.NewFunction(func(L *lua.LState) int {
+		givenDirectory := L.ToString(1)
+		if !ac.fs.Exists(givenDirectory) {
+			log.Error("Could not find", givenDirectory)
+			L.Push(lua.LBool(false))
+			return 1 // number of results
+		}
+		ac.serverDirOrFilename = filepath.Clean(givenDirectory)
+		L.Push(lua.LBool(true))
+		return 1 // number of results
+	}))
+
+	L.SetGlobal("ServerInfo", L.NewFunction(func(L *lua.LState) int {
+		// Return the string, but drop the final newline
+		L.Push(lua.LString(ac.Info()))
+		return 1 // number of results
+	}))
+
+	return nil
+}
+
+// DatabaseBackend tries to retrieve a database backend, using one of the
+// available permission middleware packages. It assign a name to dbName
+// (used for the status output) and returns a IPermissions struct.
+func (ac *Config) DatabaseBackend() (pinterface.IPermissions, error) {
+	var (
+		err  error
+		perm pinterface.IPermissions
+	)
+
+	// If Bolt is to be used and no filename is given
+	if ac.useBolt && (ac.boltFilename == "") {
+		ac.boltFilename = ac.defaultBoltFilename
+	}
+
+	if ac.boltFilename != "" {
+		// New permissions middleware, using a Bolt database
+		perm, err = bolt.NewWithConf(ac.boltFilename)
+		if err != nil {
+			if err.Error() == "timeout" {
+				tempFile, errTemp := os.CreateTemp("", "algernon")
+				if errTemp != nil {
+					log.Fatal("Unable to find a temporary file to use:", errTemp)
+				}
+				ac.boltFilename = tempFile.Name() + ".db"
+			} else {
+				log.Errorf("Could not use Bolt as database backend: %s", err)
+			}
+		} else {
+			ac.dbName = "Bolt (" + ac.boltFilename + ")"
+		}
+		// Try the new database filename if there was a timeout
+		if ac.dbName == "" && ac.boltFilename != ac.defaultBoltFilename {
+			perm, err = bolt.NewWithConf(ac.boltFilename)
+			if err != nil {
+				if err.Error() == "timeout" {
+					log.Error("The Bolt database timed out!")
+				} else {
+					log.Errorf("Could not use Bolt as database backend: %s", err)
+				}
+			} else {
+				ac.dbName = "Bolt, temporary"
+			}
+		}
+	}
+	if ac.dbName == "" && ac.mariadbDSN != "" {
+		// New permissions middleware, using a MariaDB/MySQL database
+		perm, err = mariadb.NewWithDSN(ac.mariadbDSN, ac.mariaDatabase)
+		if err != nil {
+			log.Errorf("Could not use MariaDB/MySQL as database backend: %s", err)
+		} else {
+			// The connection string may contain a password, so don't include it in the dbName
+			ac.dbName = "MariaDB/MySQL"
+		}
+	}
+	if ac.dbName == "" && ac.mariaDatabase != "" {
+		// Given a database, but not a host, connect to localhost
+		// New permissions middleware, using a MariaDB/MySQL database
+		perm, err = mariadb.NewWithConf("test:@127.0.0.1/" + ac.mariaDatabase)
+		if err != nil {
+			if ac.mariaDatabase != "" {
+				log.Errorf("Could not use MariaDB/MySQL as database backend: %s", err)
+			} else {
+				log.Warnf("Could not use MariaDB/MySQL as database backend: %s", err)
+			}
+		} else {
+			// The connection string may contain a password, so don't include it in the dbName
+			ac.dbName = "MariaDB/MySQL"
+		}
+	}
+	if ac.dbName == "" && ac.postgresDSN != "" {
+		// New permissions middleware, using a PostgreSQL database
+		perm, err = postgres.NewWithDSN(ac.postgresDSN, ac.postgresDatabase)
+		if err != nil {
+			log.Errorf("Could not use PostgreSQL as database backend: %s", err)
+		} else {
+			// The connection string may contain a password, so don't include it in the dbName
+			ac.dbName = "PostgreSQL"
+		}
+	}
+	if ac.dbName == "" && ac.postgresDatabase != "" {
+		// Given a database, but not a host, connect to localhost
+		// New permissions middleware, using a PostgreSQL database
+		perm, err = postgres.NewWithConf("postgres:@127.0.0.1/" + ac.postgresDatabase)
+		if err != nil {
+			if ac.postgresDatabase != "" {
+				log.Errorf("Could not use PostgreSQL as database backend: %s", err)
+			} else {
+				log.Warnf("Could not use PostgreSQL as database backend: %s", err)
+			}
+		} else {
+			// The connection string may contain a password, so don't include it in the dbName
+			ac.dbName = "PostgreSQL"
+		}
+	}
+	if ac.dbName == "" && ac.redisAddrSpecified {
+		// New permissions middleware, using a Redis database
+		log.Info("Testing redis connection")
+		if err := simpleredis.TestConnectionHost(ac.redisAddr); err != nil {
+			log.Info("Redis connection failed")
+			// Only output an error when a Redis host other than the default host+port was specified
+			if ac.singleFileMode {
+				log.Warnf("Could not use Redis as database backend: %s", err)
+			} else {
+				log.Errorf("Could not use Redis as database backend: %s", err)
+			}
+		} else {
+			log.Info("Redis connection worked out")
+			var err error
+			log.Info("Connecting to Redis...")
+			perm, err = redis.NewWithRedisConf2(ac.redisDBindex, ac.redisAddr)
+			if err != nil {
+				log.Warnf("Could not use Redis as database backend: %s", err)
+			} else {
+				ac.dbName = "Redis"
+			}
+		}
+	}
+	if ac.dbName == "" && ac.boltFilename == "" {
+		ac.boltFilename = ac.defaultBoltFilename
+		perm, err = bolt.NewWithConf(ac.boltFilename)
+		if err != nil {
+			if err.Error() == "timeout" {
+				tempFile, errTemp := os.CreateTemp("", "algernon")
+				if errTemp != nil {
+					log.Fatal("Unable to find a temporary file to use:", errTemp)
+				}
+				ac.boltFilename = tempFile.Name() + ".db"
+			} else {
+				log.Errorf("Could not use Bolt as database backend: %s", err)
+			}
+		} else {
+			ac.dbName = "Bolt (" + ac.boltFilename + ")"
+		}
+		// Try the new database filename if there was a timeout
+		if ac.boltFilename != ac.defaultBoltFilename {
+			perm, err = bolt.NewWithConf(ac.boltFilename)
+			if err != nil {
+				if err.Error() == "timeout" {
+					log.Error("The Bolt database timed out!")
+				} else {
+					log.Errorf("Could not use Bolt as database backend: %s", err)
+				}
+			} else {
+				ac.dbName = "Bolt, temporary"
+			}
+		}
+	}
+	if ac.dbName == "" {
+		// This may typically happen if Algernon is already running
+		return nil, errors.New("could not find a usable database backend")
+	}
+
+	if ac.verboseMode {
+		log.Info("Database backend success: " + ac.dbName)
+	}
+
+	if perm != nil && ac.clearDefaultPathPrefixes {
+		perm.Clear()
+	}
+
+	return perm, nil
+}

+ 64 - 0
engine/sse.go

@@ -0,0 +1,64 @@
+package engine
+
+import (
+	"bytes"
+	"net/http"
+	"strings"
+
+	"github.com/xyproto/algernon/utils"
+)
+
+// InsertAutoRefresh inserts JavaScript code to the page that makes the page
+// refresh itself when the source files changes.
+// The JavaScript depends on the event server being available.
+// If JavaScript can not be inserted, return the original data.
+// Assumes that the given htmldata is actually HTML
+// (looks for body/head/html tags when inserting a script tag)
+func (ac *Config) InsertAutoRefresh(req *http.Request, htmldata []byte) []byte {
+	fullHost := ac.eventAddr
+	// If the host+port starts with ":", assume it's only the port number
+	if strings.HasPrefix(fullHost, ":") {
+		// Add the hostname in front
+		if ac.serverHost != "" {
+			fullHost = ac.serverHost + ac.eventAddr
+		} else {
+			fullHost = utils.GetDomain(req) + ac.eventAddr
+		}
+	}
+	// Wait 70% of an event duration before starting to listen for events
+	multiplier := 0.7
+	js := `
+    <script>
+    if (!!window.EventSource) {
+	  window.setTimeout(function() {
+        var source = new EventSource(window.location.protocol + '//` + fullHost + ac.defaultEventPath + `');
+        source.addEventListener('message', function(e) {
+          const path = '/' + e.data;
+          if (path.indexOf(window.location.pathname) >= 0) {
+            location.reload()
+          }
+        }, false);
+	  }, ` + utils.DurationToMS(ac.refreshDuration, multiplier) + `);
+	}
+    </script>`
+
+	// Reduce the size slightly
+	js = strings.TrimSpace(strings.ReplaceAll(js, "\n", ""))
+	// Remove all whitespace that is more than one space
+	for strings.Contains(js, "  ") {
+		js = strings.ReplaceAll(js, "  ", " ")
+	}
+	// Place the script at the end of the body, if there is a body
+	switch {
+	case bytes.Contains(htmldata, []byte("</body>")):
+		return bytes.Replace(htmldata, []byte("</body>"), []byte(js+"</body>"), 1)
+	case bytes.Contains(htmldata, []byte("<head>")):
+		// If not, place the script in the <head>, if there is a head
+		return bytes.Replace(htmldata, []byte("<head>"), []byte("<head>"+js), 1)
+	case bytes.Contains(htmldata, []byte("<html>")):
+		// If not, place the script in the <html> as a new <head>
+		return bytes.Replace(htmldata, []byte("<html>"), []byte("<html><head>"+js+"</head>"), 1)
+	}
+	// In the unlikely event that no place to insert the JavaScript was found
+	return htmldata
+}

+ 141 - 0
engine/static.go

@@ -0,0 +1,141 @@
+package engine
+
+// This source file is for the special case of serving a single file.
+
+import (
+	"errors"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gomarkdown/markdown"
+	"github.com/gomarkdown/markdown/parser"
+	log "github.com/sirupsen/logrus"
+	"github.com/xyproto/algernon/utils"
+	"github.com/xyproto/datablock"
+)
+
+const (
+	defaultStaticCacheSize            = 128 * utils.MiB
+	maxAttemptsAtIncreasingPortNumber = 128
+	delayBeforeLaunchingBrowser       = time.Millisecond * 200
+)
+
+// nextPort increases the port number by 1
+func nextPort(colonPort string) (string, error) {
+	if !strings.HasPrefix(colonPort, ":") {
+		return colonPort, errors.New("colonPort does not start with a colon! \"" + colonPort + "\"")
+	}
+	num, err := strconv.Atoi(colonPort[1:])
+	if err != nil {
+		return colonPort, errors.New("Could not convert port number to string: \"" + colonPort[1:] + "\"")
+	}
+	// Increase the port number by 1, add a colon, convert to string and return
+	return ":" + strconv.Itoa(num+1), nil
+}
+
+// This is a bit hacky, but it's only used when serving a single static file
+func (ac *Config) openAfter(wait time.Duration, hostname, colonPort string, https bool, cancelChannel chan bool) {
+	// Wait a bit
+	time.Sleep(wait)
+	select {
+	case <-cancelChannel:
+		// Got a message on the cancelChannel:
+		// don't open the URL with an external application.
+		return
+	case <-time.After(delayBeforeLaunchingBrowser):
+		// Got timeout, assume the port was not busy
+		ac.OpenURL(hostname, colonPort, https)
+	}
+}
+
+// shortInfo outputs a short string about which file is served where
+func (ac *Config) shortInfoAndOpen(filename, colonPort string, cancelChannel chan bool) {
+	hostname := "localhost"
+	if ac.serverHost != "" {
+		hostname = ac.serverHost
+	}
+	log.Info("Serving " + filename + " on http://" + hostname + colonPort)
+
+	if ac.openURLAfterServing {
+		go ac.openAfter(delayBeforeLaunchingBrowser, hostname, colonPort, false, cancelChannel)
+	}
+}
+
+// ServeStaticFile is a convenience function for serving only a single file.
+// It can be used as a quick and easy way to view a README.md file.
+// Will also serve local images if the resulting HTML contains them.
+func (ac *Config) ServeStaticFile(filename, colonPort string) error {
+	log.Info("Single file mode. Not using the regular parameters.")
+
+	cancelChannel := make(chan bool, 1)
+
+	ac.shortInfoAndOpen(filename, colonPort, cancelChannel)
+
+	mux := http.NewServeMux()
+
+	// 64 MiB cache, use cache compression, no per-file size limit, use best gzip compression, compress for size not for speed
+	ac.cache = datablock.NewFileCache(defaultStaticCacheSize, true, 0, false, 0)
+
+	if ac.markdownMode {
+		// Discover all local images mentioned in the Markdown document
+		var localImages []string
+		if markdownData, err := ac.cache.Read(filename, true); err == nil { // success
+			// Create a Markdown parser with the desired extensions
+			extensions := parser.CommonExtensions | parser.AutoHeadingIDs
+			mdParser := parser.NewWithExtensions(extensions)
+			// Convert from Markdown to HTML
+			htmlbody := markdown.ToHTML(markdownData.Bytes(), mdParser, nil)
+			localImages = utils.ExtractLocalImagePaths(string(htmlbody))
+		}
+
+		// Serve all local images mentioned in the Markdown document.
+		// If a file is not found, then the FilePage function will handle it.
+		for _, localImage := range localImages {
+			mux.HandleFunc("/"+localImage, func(w http.ResponseWriter, req *http.Request) {
+				w.Header().Set("Server", ac.versionString)
+				ac.FilePage(w, req, localImage, ac.defaultLuaDataFilename)
+			})
+		}
+	}
+
+	// Prepare to serve the given filename
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
+		w.Header().Set("Server", ac.versionString)
+		ac.FilePage(w, req, filename, ac.defaultLuaDataFilename)
+	})
+
+	HTTPserver := ac.NewGracefulServer(mux, false, ac.serverHost+colonPort)
+
+	// Attempt to serve the handler functions above
+	if errServe := HTTPserver.ListenAndServe(); errServe != nil {
+		// If it fails, try several times, increasing the port by 1 each time
+		for i := 0; i < maxAttemptsAtIncreasingPortNumber; i++ {
+			if errServe = HTTPserver.ListenAndServe(); errServe != nil {
+				cancelChannel <- true
+				if !strings.HasSuffix(errServe.Error(), "already in use") {
+					// Not a problem with address already being in use
+					ac.fatalExit(errServe)
+				}
+				log.Warn("Address already in use. Using next port number.")
+				if newPort, errNext := nextPort(colonPort); errNext != nil {
+					ac.fatalExit(errNext)
+				} else {
+					colonPort = newPort
+				}
+
+				// Make a new cancel channel, and use the new URL
+				cancelChannel = make(chan bool, 1)
+				ac.shortInfoAndOpen(filename, colonPort, cancelChannel)
+
+				HTTPserver = ac.NewGracefulServer(mux, false, ac.serverHost+colonPort)
+			}
+		}
+		// Several attempts failed
+		return errServe
+	}
+
+	return nil
+}

+ 13 - 0
engine/testdata/data.lua

@@ -0,0 +1,13 @@
+title = "This is the title"
+
+function combine(a, b)
+  return a .. "&" .. b
+end
+
+function times(a, b)
+  return tostring(a * b)
+end
+
+function moose()
+  return "MOOSE"
+end

+ 14 - 0
engine/testdata/index.po2

@@ -0,0 +1,14 @@
+<!doctype html>
+<html>
+  <head>
+    <title>{{ title }}</title>
+  </head>
+  <body>
+    <p>Welcome to pongo2, version: {{ pongo2.version }}</p>
+    <p>2*3*7 is {{ 2*3*7 }}</p>
+    <p>{{ combine("bob", "hob") }}</p>
+    <p>{{ times(3, 47) }}</p>
+    <p>{{ moose() }}</p>
+    <p>done</p>
+  </body>
+</html>

+ 5 - 0
engine/testdata/style.gcss

@@ -0,0 +1,5 @@
+body
+  margin: 4em
+  font-family: sans-serif
+  font-weight: bold
+  font-size: 0.7em

+ 84 - 0
engine/trace.go

@@ -0,0 +1,84 @@
+//go:build trace
+
+package engine
+
+import (
+	"flag"
+	"os"
+	"runtime/pprof"
+	"runtime/trace"
+
+	"github.com/felixge/fgtrace"
+	log "github.com/sirupsen/logrus"
+)
+
+var (
+	cpuProfileFilename *string
+	memProfileFilename *string
+	traceFilename      *string
+	fgtraceFilename    *string
+)
+
+func init() {
+	cpuProfileFilename = flag.String("cpuprofile", "", "write CPU profile to `file`")
+	memProfileFilename = flag.String("memprofile", "", "write memory profile to `file`")
+	traceFilename = flag.String("tracefile", "", "write trace to `file`")
+	fgtraceFilename = flag.String("fgtrace", "", "write fgtrace to `file`")
+}
+
+func traceStart() {
+	// Output CPU profile information, if a filename is given
+	if *cpuProfileFilename != "" {
+		f, err := os.Create(*cpuProfileFilename)
+		if err != nil {
+			log.Fatal("could not create CPU profile: ", err)
+		}
+		log.Info("Profiling CPU usage")
+		if err := pprof.StartCPUProfile(f); err != nil {
+			log.Fatal("could not start CPU profile: ", err)
+		}
+		AtShutdown(func() {
+			pprof.StopCPUProfile()
+			log.Info("Done profiling CPU usage")
+			f.Close()
+		})
+	}
+	// Profile memory at server shutdown, if a filename is given
+	if *memProfileFilename != "" {
+		AtShutdown(func() {
+			f, errProfile := os.Create(*memProfileFilename)
+			if errProfile != nil {
+				// Fatal is okay here, since it's inside the anonymous shutdown function
+				log.Fatal("could not create memory profile: ", errProfile)
+			}
+			defer f.Close()
+			log.Info("Saving heap profile to ", *memProfileFilename)
+			if err := pprof.WriteHeapProfile(f); err != nil {
+				log.Fatal("could not write memory profile: ", err)
+			}
+		})
+	}
+	if *traceFilename != "" {
+		f, errTrace := os.Create(*traceFilename)
+		if errTrace != nil {
+			panic(errTrace)
+		}
+		go func() {
+			log.Info("Tracing")
+			if err := trace.Start(f); err != nil {
+				panic(err)
+			}
+		}()
+		AtShutdown(func() {
+			pprof.StopCPUProfile()
+			trace.Stop()
+			log.Info("Done tracing")
+			f.Close()
+		})
+	}
+	if *fgtraceFilename != "" {
+		AtShutdown(func() {
+			fgtrace.Config{Dst: fgtrace.File(*fgtraceFilename)}.Trace().Stop()
+		})
+	}
+}

+ 31 - 0
engine/url.go

@@ -0,0 +1,31 @@
+package engine
+
+import (
+	"os/exec"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+)
+
+// OpenURL tries to open an URL with the system browser
+func (ac *Config) OpenURL(host, cPort string, httpsPrefix bool) {
+	// Build the URL
+	var sb strings.Builder
+	if httpsPrefix {
+		sb.WriteString("https://")
+	} else {
+		sb.WriteString("http://")
+	}
+	if host == "" {
+		sb.WriteString("localhost")
+	} else {
+		sb.WriteString(host)
+	}
+	sb.WriteString(cPort)
+	url := sb.String()
+
+	// Open the URL
+	log.Info("Running: " + ac.openExecutable + " " + url)
+	cmd := exec.Command(ac.openExecutable, url)
+	cmd.Run()
+}

+ 3 - 0
form_example.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+# serving the registration form sample
+./algernon --dev --conf serverconf.lua --dir samples/regform --httponly --debug --autorefresh --bolt --server

+ 4 - 0
gencert.sh

@@ -0,0 +1,4 @@
+#!/bin/sh
+# For generating SSL certs, for testing.
+# Just press return at all the prompts, but enter "localhost" at Common Name.
+openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 3000 -nodes

+ 117 - 0
go.mod

@@ -0,0 +1,117 @@
+module github.com/xyproto/algernon
+
+go 1.20
+
+require (
+	github.com/caddyserver/certmagic v0.19.2
+	github.com/chzyer/readline v1.5.1
+	github.com/ddliu/go-httpclient v0.7.1
+	github.com/denisenkom/go-mssqldb v0.12.3
+	github.com/didip/tollbooth v4.0.2+incompatible
+	github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385
+	github.com/evanw/esbuild v0.19.5
+	github.com/felixge/fgtrace v0.2.0
+	github.com/go-gcfg/gcfg v1.2.3
+	github.com/go-sql-driver/mysql v1.7.1
+	github.com/gogf/gf/v2 v2.5.6
+	github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386
+	github.com/lib/pq v1.10.9
+	github.com/mitchellh/go-homedir v1.1.0
+	github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007
+	github.com/orsinium-labs/enum v1.3.0
+	github.com/quic-go/quic-go v0.39.1
+	github.com/sirupsen/logrus v1.9.3
+	github.com/tylerb/graceful v1.2.15
+	github.com/wellington/sass v0.0.0-20160911051022-cab90b3986d6
+	github.com/xyproto/ask v1.0.2
+	github.com/xyproto/datablock v1.2.0
+	github.com/xyproto/env/v2 v2.2.4
+	github.com/xyproto/files v1.4.1
+	github.com/xyproto/gluamapper v1.2.1
+	github.com/xyproto/gopher-lua v1.0.2
+	github.com/xyproto/jpath v0.6.1
+	github.com/xyproto/mime v0.0.0-20210817202956-28bafd7b06b4
+	github.com/xyproto/onthefly v1.2.3
+	github.com/xyproto/permissionbolt/v2 v2.6.3
+	github.com/xyproto/permissions2/v2 v2.6.9
+	github.com/xyproto/permissionsql/v2 v2.1.1
+	github.com/xyproto/pinterface v1.5.3
+	github.com/xyproto/pongo2 v0.0.0-20191214182037-d75cc3537773
+	github.com/xyproto/pstore v1.3.2
+	github.com/xyproto/recwatch v1.1.0
+	github.com/xyproto/sheepcounter v1.6.1
+	github.com/xyproto/simplebolt v1.5.2
+	github.com/xyproto/simpleform v0.2.0
+	github.com/xyproto/simplejwt v1.2.0
+	github.com/xyproto/simpleredis/v2 v2.6.5
+	github.com/xyproto/splash v1.1.7-0.20230420131850-c892c94b4a02
+	github.com/xyproto/textoutput v1.15.10
+	github.com/xyproto/tinysvg v1.1.0
+	github.com/xyproto/unzip v0.0.0-20150601123358-823950573952
+	github.com/yosssi/gcss v0.1.0
+	golang.org/x/net v0.17.0
+)
+
+require (
+	github.com/BurntSushi/toml v1.2.0 // indirect
+	github.com/DataDog/gostackparse v0.7.0 // indirect
+	github.com/alecthomas/chroma/v2 v2.9.1 // indirect
+	github.com/clbanning/mxj/v2 v2.7.0 // indirect
+	github.com/dlclark/regexp2 v1.10.0 // indirect
+	github.com/fatih/color v1.15.0 // indirect
+	github.com/fsnotify/fsnotify v1.6.0 // indirect
+	github.com/go-logr/logr v1.2.4 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
+	github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+	github.com/golang-sql/sqlexp v0.1.0 // indirect
+	github.com/gomodule/redigo v1.8.9 // indirect
+	github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
+	github.com/gorilla/websocket v1.5.0 // indirect
+	github.com/grokify/html-strip-tags-go v0.0.1 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.5 // indirect
+	github.com/libdns/libdns v0.2.1 // indirect
+	github.com/magiconair/properties v1.8.6 // indirect
+	github.com/mattetti/filebuffer v1.0.1 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.15 // indirect
+	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
+	github.com/mholt/acmez v1.2.0 // indirect
+	github.com/miekg/dns v1.1.56 // indirect
+	github.com/mitchellh/mapstructure v1.5.0 // indirect
+	github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect
+	github.com/olekukonko/tablewriter v0.0.5 // indirect
+	github.com/onsi/ginkgo/v2 v2.13.0 // indirect
+	github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
+	github.com/pkg/term v1.2.0-beta.2.0.20210419004637-f749b98bd0ba // indirect
+	github.com/quic-go/qpack v0.4.0 // indirect
+	github.com/quic-go/qtls-go1-20 v0.3.4 // indirect
+	github.com/rivo/uniseg v0.4.4 // indirect
+	github.com/shopspring/decimal v1.3.1 // indirect
+	github.com/xyproto/binary v1.3.0 // indirect
+	github.com/xyproto/cookie/v2 v2.2.4 // indirect
+	github.com/xyproto/randomstring v1.0.5 // indirect
+	github.com/xyproto/simplehstore v1.8.2 // indirect
+	github.com/xyproto/simplemaria v1.3.2 // indirect
+	github.com/xyproto/symwalk v1.1.1 // indirect
+	github.com/xyproto/vt100 v1.12.7 // indirect
+	github.com/zeebo/blake3 v0.2.3 // indirect
+	go.etcd.io/bbolt v1.3.7 // indirect
+	go.opentelemetry.io/otel v1.14.0 // indirect
+	go.opentelemetry.io/otel/sdk v1.14.0 // indirect
+	go.opentelemetry.io/otel/trace v1.14.0 // indirect
+	go.uber.org/mock v0.3.0 // indirect
+	go.uber.org/multierr v1.11.0 // indirect
+	go.uber.org/zap v1.26.0 // indirect
+	golang.org/x/crypto v0.14.0 // indirect
+	golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
+	golang.org/x/mod v0.13.0 // indirect
+	golang.org/x/sys v0.13.0 // indirect
+	golang.org/x/text v0.13.0 // indirect
+	golang.org/x/time v0.3.0 // indirect
+	golang.org/x/tools v0.14.0 // indirect
+	gopkg.in/gcfg.v1 v1.2.3 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 369 - 0
go.sum

@@ -0,0 +1,369 @@
+github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
+github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
+github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4=
+github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM=
+github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink=
+github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ=
+github.com/alecthomas/chroma/v2 v2.7.1-0.20230409061740-3c219428245c/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw=
+github.com/alecthomas/chroma/v2 v2.9.1 h1:0O3lTQh9FxazJ4BYE/MOi/vDGuHn7B+6Bu902N2UZvU=
+github.com/alecthomas/chroma/v2 v2.9.1/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
+github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
+github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
+github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/caddyserver/certmagic v0.19.2 h1:HZd1AKLx4592MalEGQS39DKs2ZOAJCEM/xYPMQ2/ui0=
+github.com/caddyserver/certmagic v0.19.2/go.mod h1:fsL01NomQ6N+kE2j37ZCnig2MFosG+MIO4ztnmG/zz8=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
+github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
+github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
+github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
+github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
+github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
+github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
+github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ddliu/go-httpclient v0.7.1 h1:COWYBalfbaFNe6e0eQU38++vCD5kzLh1H1RFs3xcn9g=
+github.com/ddliu/go-httpclient v0.7.1/go.mod h1:uwipe9x9SYGk4JhBemO7+dD87QbiY224y0DLB9OY0Ik=
+github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw=
+github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo=
+github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7zjmGO20HCZtF4M=
+github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.9.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
+github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
+github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o=
+github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
+github.com/evanw/esbuild v0.19.5 h1:9ildZqajUJzDAwNf9MyQsLh2RdDRKTq3kcyyzhE39us=
+github.com/evanw/esbuild v0.19.5/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/felixge/fgtrace v0.2.0 h1:lq7RO6ELjR+S74+eD+ai/vhYvsjno7Vb84yzU6RPSeU=
+github.com/felixge/fgtrace v0.2.0/go.mod h1:q9vMuItthu3CRfNhirTCTwzBcJ8atUFkrJUhgQbjg8c=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
+github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
+github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
+github.com/go-gcfg/gcfg v1.2.3 h1:8X/LtK7rOSSGqvh4gHqR9wpWOIsq3tGCH2t0R2mgEUU=
+github.com/go-gcfg/gcfg v1.2.3/go.mod h1:D+YdKk714qkU4V0pntcxhDsrHgQDmI91IEbXXqwRZqA=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
+github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
+github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/gogf/gf/v2 v2.5.6 h1:a1UK1yUP3s+l+vPxmV91+8gTarAP9b1IEOw0W7LNl6E=
+github.com/gogf/gf/v2 v2.5.6/go.mod h1:17K/gBYrp0bHGC3XYC7bSPoywmZ6MrZHrZakTfh4eIQ=
+github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386 h1:EcQR3gusLHN46TAD+G+EbaaqJArt5vHhNpXAa12PQf4=
+github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
+github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
+github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ=
+github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grokify/html-strip-tags-go v0.0.1 h1:0fThFwLbW7P/kOiTBs03FsJSV9RM2M/Q/MOnCQxKMo0=
+github.com/grokify/html-strip-tags-go v0.0.1/go.mod h1:2Su6romC5/1VXOQMaWL2yb618ARB8iVo6/DR99A6d78=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
+github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
+github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
+github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
+github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis=
+github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
+github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
+github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM=
+github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
+github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
+github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30=
+github.com/mholt/acmez v1.2.0/go.mod h1:VT9YwH1xgNX1kmYY89gY8xPJC84BFAisjo8Egigt4kE=
+github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE=
+github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
+github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 h1:Ohgj9L0EYOgXxkDp+bczlMBiulwmqYzQpvQNUdtt3oc=
+github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007/go.mod h1:wKCOWMb6iNlvKiOToY2cNuaovSXvIiv1zDi9QDR7aGQ=
+github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q=
+github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
+github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
+github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
+github.com/orsinium-labs/enum v1.3.0 h1:OsIMdDbY06X4N4urfk/ysMATuByK3I8troJ754XphDM=
+github.com/orsinium-labs/enum v1.3.0/go.mod h1:Qj5IK2pnElZtkZbGDxZMjpt7SUsn4tqE5vRelmWaBbc=
+github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
+github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
+github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
+github.com/pkg/term v1.2.0-beta.2.0.20210419004637-f749b98bd0ba h1:KVTuKXe/NMcKMIlgVuOq9cWogO8LkolqY0ienhEEYlY=
+github.com/pkg/term v1.2.0-beta.2.0.20210419004637-f749b98bd0ba/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
+github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
+github.com/quic-go/qtls-go1-20 v0.3.4 h1:MfFAPULvst4yoMgY9QmtpYmfij/em7O8UUi+bNVm7Cg=
+github.com/quic-go/qtls-go1-20 v0.3.4/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
+github.com/quic-go/quic-go v0.39.1 h1:d/m3oaN/SD2c+f7/yEjZxe2zEVotXprnrCCJ2y/ZZFE=
+github.com/quic-go/quic-go v0.39.1/go.mod h1:T09QsDQWjLiQ74ZmacDfqZmhY/NLnw5BC40MANNNZ1Q=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
+github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/russross/blackfriday v1.5.3-0.20190616195246-a925a152c144 h1:DSnT5th1+S65UVOwp617oI2lNQ01UFeoArsU8c2b6h0=
+github.com/russross/blackfriday v1.5.3-0.20190616195246-a925a152c144/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
+github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
+github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/tylerb/graceful v1.2.15 h1:B0x01Y8fsJpogzZTkDg6BDi6eMf03s01lEKGdrv83oA=
+github.com/tylerb/graceful v1.2.15/go.mod h1:LPYTbOYmUTdabwRt0TGhLllQ0MUNbs0Y5q1WXJOI9II=
+github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
+github.com/wellington/sass v0.0.0-20160911051022-cab90b3986d6 h1:qPS12y9iMXyKr2flmOG7RgiyUGkQxQibp1hx7uug9IQ=
+github.com/wellington/sass v0.0.0-20160911051022-cab90b3986d6/go.mod h1:ncYBwTYUjmb7N+sZbf8WJYynLivoqFL+U2f8uOX2Yzk=
+github.com/xyproto/ask v1.0.0/go.mod h1:GTF2UyZg4J/JFVrY8jrfbk00yljvpDWra9gHiSuwI90=
+github.com/xyproto/ask v1.0.2 h1:8LKeu8fE9bCcTnDrXVIU/MkNmI5NyNaa/ZLpIDgUspM=
+github.com/xyproto/ask v1.0.2/go.mod h1:fHOS3OqThzPYaJUNOH4/ZZDJZhu71bzCMUFnBiq8ELI=
+github.com/xyproto/binary v1.3.0 h1:RCICHqn81T+vxNdEBQvQ8iZ3Qt44AgMHag4IZgeFXbU=
+github.com/xyproto/binary v1.3.0/go.mod h1:0bRi3JImoPWOFfM7yPYzNVyLZXYGQTBwdNRPs4m3+qQ=
+github.com/xyproto/cookie v0.0.0-20181220103240-f4de411f45ff/go.mod h1:+c0/g8lVJKAi+uZ/kPHqSzf2UsSI2If03smY6xITgtM=
+github.com/xyproto/cookie v0.0.0-20210319112338-2e0ffd4b75a9 h1:9CPJq26PTbHm92sSBjcEpshEsJfBiamdEyXKlM5lVt0=
+github.com/xyproto/cookie/v2 v2.2.3/go.mod h1:GLwQP/zGrMRVJ++iavGE7jEhSyF8DY2RW3xmlYhOVhA=
+github.com/xyproto/cookie/v2 v2.2.4 h1:McHpNU+5r1jpMxGwCoUCoHQHElrW2wr7cXomBauuJ9I=
+github.com/xyproto/cookie/v2 v2.2.4/go.mod h1:SkeeteOip8UEegYTF4H+WIzrgKVrqh7o0t97i3vymjw=
+github.com/xyproto/datablock v1.2.0 h1:BRHolvVGIbrbILKecRCGBWt1pp/bEsOC+m2I3A32tig=
+github.com/xyproto/datablock v1.2.0/go.mod h1:hQGlZYTpt2QOXcjPKri4bjwU1mebDvrY9Nlx2hUgQe0=
+github.com/xyproto/env/v2 v2.0.0/go.mod h1:n0AhHu2mZjNMK2auKEF6eUAU6LJ/1PQfss8UuT7Jhzc=
+github.com/xyproto/env/v2 v2.2.4 h1:Eysqz8rlZRBjKwH0a+oxyFj4WMDBaezKNHLx2S5f8go=
+github.com/xyproto/env/v2 v2.2.4/go.mod h1:F81ZEzu15s3TWUZJ1uzBl9iNeq9zcfHvxMkQJaLZUl0=
+github.com/xyproto/files v1.4.1 h1:KlZDU5dRNuQlY1moAP2g1XjMJmHwtQrLIQQJUDvP0zg=
+github.com/xyproto/files v1.4.1/go.mod h1:DqMSkP4/vYiF3ORArHW2WyqJ6EAj3mmVYmc18DVD+/8=
+github.com/xyproto/gluamapper v1.2.1 h1:7pDNxzX4P1QndBFkZEN7JmixrH0HSc7jKw2xyTZ0OjY=
+github.com/xyproto/gluamapper v1.2.1/go.mod h1:uN8tJzpgFmctChbuKGSlLGea/8p5q2v2+5WCnqcUS+8=
+github.com/xyproto/gopher-lua v1.0.0/go.mod h1:VCAgqVjLOz4AzuaxCORQNg4/0C3piilmVLcbMrJ9AJw=
+github.com/xyproto/gopher-lua v1.0.2 h1:tOtkVygD46OWICSEov9Yf9XheBy8bCSnmhVzLGVAwvo=
+github.com/xyproto/gopher-lua v1.0.2/go.mod h1:aoM62BSDxywYkGaMufChYLqnYEmp0PKcPcw0jtxU/x0=
+github.com/xyproto/jpath v0.6.1 h1:CmpbtuRgHZKW5fM9tYgdH6wIMpvnSOhnt4UwAYSacpM=
+github.com/xyproto/jpath v0.6.1/go.mod h1:bm5t4NVGsu+09lxa0qgsyjpd3kd9Sy2uBpszwAbgeB4=
+github.com/xyproto/mime v0.0.0-20210817202956-28bafd7b06b4 h1:fpHb9zxYkydDS3VBjvH6VWCvCrttqzVzj2MjyNCgoko=
+github.com/xyproto/mime v0.0.0-20210817202956-28bafd7b06b4/go.mod h1:Vb1NBbNUHAKQGBERkD/s0+W/wW7ZygautwBonIxchuk=
+github.com/xyproto/onthefly v1.2.3 h1:91QuXkfqP7l4yFsGTGbN3S+iqYZLLJfVEWo4BFpewnA=
+github.com/xyproto/onthefly v1.2.3/go.mod h1:FQNxGnPtw8YSfZMHGbNKMGqtoI5ZZS+66Sw+eDTcTRE=
+github.com/xyproto/permissionbolt/v2 v2.6.3 h1:nmdk7fZZ2yBEM/eBqDDRcy34n2UTxbqbeutW4xyOqMM=
+github.com/xyproto/permissionbolt/v2 v2.6.3/go.mod h1:Rk1W/BJlq04RT7wl9f1r3AJsHDFGZB89WSwVo3ibuOo=
+github.com/xyproto/permissions2/v2 v2.6.9 h1:gnbBvS6T71cYzaWsDE8IpDL0k1Gv5kbcLs6VuvQS+wA=
+github.com/xyproto/permissions2/v2 v2.6.9/go.mod h1:Igavl4bu1/6ZTuoJx3AVA1w1QbBnVUDOZSKgGxrS3z0=
+github.com/xyproto/permissionsql/v2 v2.1.1 h1:2XEga+C23esAFQhPevev/yZ45+INLMYT2Rx4khXzw5Q=
+github.com/xyproto/permissionsql/v2 v2.1.1/go.mod h1:AwcoOEyvkFReo7D9ELGq4N5fcb1Rb5VfvmVr5Xh4Zs8=
+github.com/xyproto/pinterface v1.5.3 h1:RKkNT88cwrSqD9hU4cYAO5yeo8srg4TG+74Pcj88iz0=
+github.com/xyproto/pinterface v1.5.3/go.mod h1:X5B5pKE49ak7SpyDh5QvJvLH9cC9XuZNDcl5hEyYc34=
+github.com/xyproto/pongo2 v0.0.0-20191214182037-d75cc3537773 h1:LaSYhw+KnhyEyLUn5hyMDU7JOBkkZWInmaEqi5rYfLg=
+github.com/xyproto/pongo2 v0.0.0-20191214182037-d75cc3537773/go.mod h1:atcOVMBC2KA78Eocs7Do+D/HvBIoRStZ//M/0PK6uZc=
+github.com/xyproto/pstore v1.3.2 h1:eD4S4KI6184FNXUM2rsVxfiWaFg67hA6xiwfoYNoevU=
+github.com/xyproto/pstore v1.3.2/go.mod h1:BJGQQFPvSCMWNRp9v7nTDHG9uoZF2N4hfn1uXTL2YZ4=
+github.com/xyproto/randomstring v0.0.0-20181220103026-e5e8317e5d67/go.mod h1:HcK1ojGYWgNJz1Rp9UouvxVGIWsMFAtkftDoHZ6DE9k=
+github.com/xyproto/randomstring v0.0.0-20181222003104-0f764aabc45a/go.mod h1:HcK1ojGYWgNJz1Rp9UouvxVGIWsMFAtkftDoHZ6DE9k=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/xyproto/recwatch v1.1.0 h1:M8raxpfOb1HChmMxE+72PmhugC+/LlJ3tPDs2dQjpzc=
+github.com/xyproto/recwatch v1.1.0/go.mod h1:L5vSDqREe0dO3awl3+gESUo/5jQTTxPe1TmhFMpBa2w=
+github.com/xyproto/sheepcounter v1.6.1 h1:tYK/S54fpLuJpIQk4TLa64EbZpVT6FAO9qJJZcrChpI=
+github.com/xyproto/sheepcounter v1.6.1/go.mod h1:o2/EM3ibNDo4LjW2S3rG0O+eZ1PQnKRZ5YboGlBJhGM=
+github.com/xyproto/simplebolt v1.5.2 h1:Q576fLNHnfzn0HFSlyZyaHmXfdzGC8RAlZLM/0mfDQ0=
+github.com/xyproto/simplebolt v1.5.2/go.mod h1:19ub+nPXHfvhde8eqk/5g56zGZNnaYIYLikrkZe+OF0=
+github.com/xyproto/simpleform v0.2.0 h1:01YuPkzSUabU8dq3tk+hunoHdA3wYsfXVImimmXX0tU=
+github.com/xyproto/simpleform v0.2.0/go.mod h1:Fn69YFlYOIDsfXl/eA1bBCjWGjdTfAEm/0FcuifklFU=
+github.com/xyproto/simplehstore v1.8.2 h1:Gjnqox54EIiKrD8ZkZmpX0aKuCRLmKHvbtyq35SAvVw=
+github.com/xyproto/simplehstore v1.8.2/go.mod h1:y+2dmwAAfb6836QAbXMQwjJgoNiSHtXFyG2VS0FjOls=
+github.com/xyproto/simplejwt v1.2.0 h1:Oy0xgBhYh47Nfc9Ij9hW9eHfP6QXq0prrM9C+5inqEI=
+github.com/xyproto/simplejwt v1.2.0/go.mod h1:qv4jgtIx6X5G+DLQac9NS4BTcSxxtDVhjhp8jasI4ao=
+github.com/xyproto/simplemaria v1.3.2 h1:mzvS8fZXQ955vaGrjpbyJqISbEPUrY+RotjSDOaqLwk=
+github.com/xyproto/simplemaria v1.3.2/go.mod h1:fyWLd+6+AAae+Wk2BgRDujDF2IZqJOxLWeaGl0roIS8=
+github.com/xyproto/simpleredis/v2 v2.6.5 h1:PtAz5j2UUACNHx5LetI60dbCsVMhIv1H878bND4VQK4=
+github.com/xyproto/simpleredis/v2 v2.6.5/go.mod h1:OrWVubZGr0SbpAjkAQkFn/iiex2AsblBm80uhYxbjQY=
+github.com/xyproto/splash v1.1.7-0.20230420131850-c892c94b4a02 h1:BlakcSL7sSg9mdvG6i8+edFflZv5tHBAKhbKVjr1sek=
+github.com/xyproto/splash v1.1.7-0.20230420131850-c892c94b4a02/go.mod h1:4d8bLaJnZz7Fd8V5V2Z6PDTFkhiBPl5nL4ezyhedoDU=
+github.com/xyproto/symwalk v1.1.1 h1:icxMUiRAOqw8x9q9UWtpNz3rN7fngSYNMRguWzsyiWI=
+github.com/xyproto/symwalk v1.1.1/go.mod h1:u40/s1ER3LYv1ibNLeyH1PAR9oX62BPHNRt295/9zQc=
+github.com/xyproto/textoutput v1.15.10 h1:c6SkM2DHqxiaXio3/39XsFahrcLsSeakaQZkgA+HnK0=
+github.com/xyproto/textoutput v1.15.10/go.mod h1:riYjG+23ZDhmn3lp9VcbAXCdC61eENo8PJh9jq0q6E0=
+github.com/xyproto/tinysvg v1.0.1/go.mod h1:DKgmaYuFIvJab9ug4nH4ZG356VtUaKXG2mUU07GIurs=
+github.com/xyproto/tinysvg v1.1.0 h1:g8NBSGR90npPhNnbmEIPbkxHdyiFXSQrm5mVVoFFRNA=
+github.com/xyproto/tinysvg v1.1.0/go.mod h1:DKgmaYuFIvJab9ug4nH4ZG356VtUaKXG2mUU07GIurs=
+github.com/xyproto/unzip v0.0.0-20150601123358-823950573952 h1:pCgYs8AOzRpEmdM55hrSnd+TZOr+1/m9Y9KkFIZPwiU=
+github.com/xyproto/unzip v0.0.0-20150601123358-823950573952/go.mod h1:ygEdojOpwX5uG71ECHji5sod2rpZ+9hqNeAZD50S84Q=
+github.com/xyproto/vt100 v1.12.7 h1:CKltar1kiJZvKcKMHfz50Jvh2C1SKC+I+Pyu2YANdNw=
+github.com/xyproto/vt100 v1.12.7/go.mod h1:pS1w4w/mRXKXL0DJDZ1tdZZ6aML9/7sT9sD8V6l0Rc4=
+github.com/yosssi/gcss v0.1.0 h1:jRuino7qq7kqntBIhT+0xSUI5/sBgCA/zCQ1Tuzd6Gg=
+github.com/yosssi/gcss v0.1.0/go.mod h1:M3mTPOWZWjVROkXKZ2AiDzOBOXu2MqQeDXF/nKO44sI=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
+github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
+github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ=
+github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
+github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
+github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
+go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
+go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
+go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
+go.opentelemetry.io/otel v1.14.0 h1:/79Huy8wbf5DnIPhemGB+zEPVwnN6fuQybr/SRXa6hM=
+go.opentelemetry.io/otel v1.14.0/go.mod h1:o4buv+dJzx8rohcUeRmWUZhqupFvzWis188WlggnNeU=
+go.opentelemetry.io/otel/sdk v1.14.0 h1:PDCppFRDq8A1jL9v6KMI6dYesaq+DFcDZvjsoGvxGzY=
+go.opentelemetry.io/otel/sdk v1.14.0/go.mod h1:bwIC5TjrNG6QDCHNWvW4HLHtUQ4I+VQDsnjhvyZCALM=
+go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyKcFq/M=
+go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8=
+go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
+go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
+go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
+go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
+golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
+golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190219092855-153ac476189d/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
+golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/gcfg.v1 v1.2.3 h1:m8OOJ4ccYHnx2f4gQwpno8nAX5OGOh7RLaaz0pj3Ogs=
+gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
+gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 12 - 0
hello.lua

@@ -0,0 +1,12 @@
+local algernon = require "algernon"
+
+algernon.get("/hello", function(w, req)
+  w:write("Hello, Algernon!")
+end)
+
+algernon.post("/login", function(w, req)
+  -- 处理登录逻辑
+  w:write("Login successful!")
+end)
+
+algernon.start()

二進制
img/algernon-social.jpg


二進制
img/algernon.gif


二進制
img/algernon_128x128.png


二進制
img/algernon_gopher.png


二進制
img/algernon_large.png


二進制
img/algernon_logo.png


二進制
img/algernon_logo4.png


文件差異過大導致無法顯示
+ 320 - 0
img/algernon_logo_CC0.svg


二進制
img/algernon_logo_dark.png


二進制
img/algernon_logo_default_theme.png


二進制
img/algernon_lua_error.png


部分文件因文件數量過多而無法顯示