aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrian Picciano <me@mediocregopher.com>2024-11-01 13:00:18 +0100
committerBrian Picciano <me@mediocregopher.com>2024-11-01 13:00:18 +0100
commitcf6af6def1cac3fc6cd044c82282208b7073eb64 (patch)
tree64d6186b5e30e1125c1eff6fa1d86a379e3d3693
parent246a99c28980e985bb4cff99042459bd5729cde1 (diff)
Implement gemtext HTTP middleware
-rw-r--r--README.md70
-rw-r--r--example/Caddyfile23
-rw-r--r--example/tpl/render_gemtext.html9
-rw-r--r--example/tpl/render_gemtext_link.html5
-rw-r--r--example/tpl/render_gemtext_with_templates.html15
-rw-r--r--http/handlers/gemtext.go277
-rw-r--r--http/handlers/handlers.go2
-rw-r--r--http/handlers/templates/functions/gemtext.go4
-rw-r--r--internal/toolkit/toolkit.go23
-rw-r--r--plugins.go1
10 files changed, 412 insertions, 17 deletions
diff --git a/README.md b/README.md
index 010bca1..a17c5d8 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,68 @@ It's also possible to build Caddy manually using a custom `main.go` file, see
The following plugins are implemented in this module.
-### http.handlers.templates.functions.gemtext
+### http.handlers.gemtext
+
+This HTTP handler will translate [gemtext][gemtext] documents into HTML
+documents. It requires at least one argument, `template`, to which is passed an
+HTML template file that gemtext documents will be rendered into.
+
+Only responses with a `Content-Type` of `text/gemini` will be modified by this
+module.
+
+Example usage:
+
+```
+http://gemtext.localhost {
+ root example/static
+ gemtext {
+ root example/tpl
+ template render_gemtext.html
+ }
+ file_server
+}
+```
+
+#### Parameters
+
+**template**
+
+Path to the template which will be used to render the HTML page, relative to the
+`root`.
+
+The template will be rendered with these extra data fields:
+
+* `.Title`: The Title of the gemini document, determined based on the first
+ primary header (single `#` prefix) found. This will be an empty string if no
+ primary header is found.
+
+* `.Body`: A string containing all rendered HTML DOM elements.
+
+**link_template**
+
+Path to a template which will be used for rendering links. If not given then
+links will be rendered using an anchor tag wrapped in a paragraph tag.
+
+The template will be rendered with these extra data fields:
+
+* `.URL`: The URL the link points to.
+* `.Label`: The label attached to the link. If the original link had no label
+ then this will be equivalent to `.URL`.
+
+**root**
+
+The root path from which to load templaet files. Default is `{http.vars.root}`
+if set, or current working directory otherwise.
+
+**delimiters**
+
+The template action delimiters. Defaults to:
+
+```
+delimiters "{{" "}}"
+```
+
+### http.handlers.templates.functions.gemtext_function
This extension to `templates` allows for rendering a [gemtext][gemtext] string
as a roughly equivalent set of HTML tags. It is similar to the [markdown template
@@ -38,7 +99,7 @@ function][mdfunc] in its usage. It can be enabled by being included in the
```text
templates {
extensions {
- gemtext {
+ gemtext_function {
# All parameters are optional
gateway_url "https://some.gateway/x/"
}
@@ -50,12 +111,11 @@ See the `template.localhost` virtual host in `./example/Caddyfile`, and the
associated `./example/tpl/render_gemtext.html` template file, for an example of
how to use this directive.
-[gemtext]: https://geminiprotocol.net/docs/gemtext.gmi
[mdfunc]: https://caddyserver.com/docs/modules/http.handlers.templates#markdown
#### Parameters
-Optional parameters to the gemtext extension include:
+Optional parameters to the `gemtext_function` extension include:
**gateway_url**
@@ -88,6 +148,8 @@ fields:
* `Title`: A suggested title, based on the first `# Header` line found in the
gemtext input.
+[gemtext]: https://geminiprotocol.net/docs/gemtext.gmi
+
## Development
A nix-based development environment is provided with the correct versions of all
diff --git a/example/Caddyfile b/example/Caddyfile
index 4a4879f..6c6609d 100644
--- a/example/Caddyfile
+++ b/example/Caddyfile
@@ -5,7 +5,22 @@
http_port 8000
}
-http://template.localhost {
+http://gemtext.localhost {
+ root example/static
+
+ # Allow for either index.html or index.gmi files when serving directories
+ try_files {path} {path}/index.html {path}/index.gmi
+
+ gemtext {
+ root example/tpl
+ template render_gemtext.html
+ link_template render_gemtext_link.html
+ }
+
+ file_server
+}
+
+http://templates.localhost {
root example/static
# If a directory has an index.gmi file, then that file will be served when
@@ -20,13 +35,13 @@ http://template.localhost {
templates {
# The templates directive is given a different root, so that other
# template snippets within the tpl directory could theoretically be
- # used within render_gemtext.html.
+ # used within render_gemtext_with_templates.html.
root example
# Include the gemtext extention to make the gemtext function
# available within the template.
extensions {
- gemtext {
+ gemtext_function {
gateway_url "https://gemini.tildeverse.org/?gemini://"
}
}
@@ -37,7 +52,7 @@ http://template.localhost {
# actually want. Setting Content-Type is required because there's no
# actual file for Caddy to determine the value from.
header Content-Type "text/html; charset=utf-8"
- respond `{{ include "tpl/render_gemtext.html" }}`
+ respond `{{ include "tpl/render_gemtext_with_templates.html" }}`
}
# All other files are handled directly by the file_server.
diff --git a/example/tpl/render_gemtext.html b/example/tpl/render_gemtext.html
index 72e35a4..82d3c02 100644
--- a/example/tpl/render_gemtext.html
+++ b/example/tpl/render_gemtext.html
@@ -1,15 +1,10 @@
-{{ $pathSplit := splitList "/" .Req.URL.Path }}
-{{ $base := last $pathSplit | default "index.gmi" }}
-{{ $filePath := append (initial $pathSplit) $base | join "/" | printf "static%s" }}
-{{ if not (fileExists $filePath) }}{{ httpError 404 }}{{ end }}
-{{ $gemtextRes := gemtext (include $filePath) }}
<!DOCTYPE html>
<html>
<head>
- <title>{{ $gemtextRes.Title | default "Example Gemtext File" }}</title>
+ <title>{{ .Title | default "Example Gemtext File" }}</title>
<link rel="stylesheet" type="text/css" href="/bamboo.css" />
</head>
<body>
- {{ $gemtextRes.Body }}
+ {{ .Body }}
</body>
</html>
diff --git a/example/tpl/render_gemtext_link.html b/example/tpl/render_gemtext_link.html
new file mode 100644
index 0000000..b0d1251
--- /dev/null
+++ b/example/tpl/render_gemtext_link.html
@@ -0,0 +1,5 @@
+{{- $url := .URL }}
+{{- if (hasPrefix "gemini://" $url) }}
+ {{- $url = printf "https://gemini.tildeverse.org/?%s" $url }}
+{{- end }}
+<p><a href="{{ $url }}">{{ .Label }}</a></p>
diff --git a/example/tpl/render_gemtext_with_templates.html b/example/tpl/render_gemtext_with_templates.html
new file mode 100644
index 0000000..72e35a4
--- /dev/null
+++ b/example/tpl/render_gemtext_with_templates.html
@@ -0,0 +1,15 @@
+{{ $pathSplit := splitList "/" .Req.URL.Path }}
+{{ $base := last $pathSplit | default "index.gmi" }}
+{{ $filePath := append (initial $pathSplit) $base | join "/" | printf "static%s" }}
+{{ if not (fileExists $filePath) }}{{ httpError 404 }}{{ end }}
+{{ $gemtextRes := gemtext (include $filePath) }}
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>{{ $gemtextRes.Title | default "Example Gemtext File" }}</title>
+ <link rel="stylesheet" type="text/css" href="/bamboo.css" />
+ </head>
+ <body>
+ {{ $gemtextRes.Body }}
+ </body>
+</html>
diff --git a/http/handlers/gemtext.go b/http/handlers/gemtext.go
new file mode 100644
index 0000000..bc617f4
--- /dev/null
+++ b/http/handlers/gemtext.go
@@ -0,0 +1,277 @@
+package handlers
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "mime"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+
+ "dev.mediocregopher.com/mediocre-caddy-plugins.git/internal/gemtext"
+ "dev.mediocregopher.com/mediocre-caddy-plugins.git/internal/toolkit"
+ "github.com/caddyserver/caddy/v2"
+ "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates"
+ "go.uber.org/zap"
+)
+
+// The implementation here is heavily based on the implementation of the
+// `templates` module:
+// https://github.com/caddyserver/caddy/blob/350ad38f63f7a49ceb3821c58d689b85a27ec4e5/modules/caddyhttp/templates/templates.go
+
+const gemtextMIME = "text/gemini"
+
+func init() {
+ caddy.RegisterModule(Gemtext{})
+ httpcaddyfile.RegisterHandlerDirective("gemtext", gemtextParseCaddyfile)
+ httpcaddyfile.RegisterDirectiveOrder(
+ "gemtext", httpcaddyfile.Before, "templates",
+ )
+
+ // Since this module relies on Content-Type, but text/gemtext is not a
+ // standard type, we add it if it's missing
+ if mime.TypeByExtension(".gmi") == "" {
+ mime.AddExtensionType(".gmi", gemtextMIME)
+ }
+}
+
+// Gemtext is an HTTP middleware module which will render gemtext documents as
+// HTML documents, using user-provided templates to do so.
+//
+// Only responses with a Content-Type of `text/gemini` will be modified by this
+// module.
+type Gemtext struct {
+
+ // Path to the template which will be used to render the HTML page, relative
+ // to the `file_root`.
+ //
+ // The template will be rendered with these extra data fields:
+ //
+ // ##### `.Title`
+ //
+ // The Title of the gemini document, determined based on the first primary
+ // header (single `#` prefix) found. This will be an empty string if no
+ // primary header is found.
+ //
+ // ##### `.Body`
+ //
+ // A string containing all rendered HTML DOM elements.
+ //
+ TemplatePath string `json:"template"`
+
+ // Path to a template which will be used for rendering links. If not given
+ // then links will be rendered using an anchor tag wrapped in a paragraph
+ // tag.
+ //
+ // The template will be rendered with these extra data fields:
+ //
+ // ##### `.URL`
+ //
+ // The URL the link points to.
+ //
+ // ##### `.Label`
+ //
+ // The label attached to the link. If the original link had no label then
+ // this will be equivalent to `.URL`.
+ LinkTemplatePath string `json:"link_template"`
+
+ // The root path from which to load files. Default is `{http.vars.root}` if
+ // set, or current working directory otherwise.
+ FileRoot string `json:"file_root,omitempty"`
+
+ // The template action delimiters. If set, must be precisely two elements:
+ // the opening and closing delimiters. Default: `["{{", "}}"]`
+ Delimiters []string `json:"delimiters,omitempty"`
+
+ logger *zap.Logger
+}
+
+var _ caddyhttp.MiddlewareHandler = (*Gemtext)(nil)
+
+func (Gemtext) CaddyModule() caddy.ModuleInfo {
+ return caddy.ModuleInfo{
+ ID: "http.handlers.gemtext",
+ New: func() caddy.Module { return new(Gemtext) },
+ }
+}
+
+func (g *Gemtext) Provision(ctx caddy.Context) error {
+ g.logger = ctx.Logger()
+
+ if g.FileRoot == "" {
+ g.FileRoot = "{http.vars.root}"
+ }
+
+ if len(g.Delimiters) == 0 {
+ g.Delimiters = []string{"{{", "}}"}
+ }
+
+ return nil
+}
+
+// Validate ensures t has a valid configuration.
+func (g *Gemtext) Validate() error {
+ if g.TemplatePath == "" {
+ return errors.New("TemplatePath is required")
+ }
+
+ if len(g.Delimiters) != 0 && len(g.Delimiters) != 2 {
+ return fmt.Errorf("delimiters must consist of exactly two elements: opening and closing")
+ }
+ return nil
+}
+
+func (g *Gemtext) render(
+ into io.Writer,
+ ctx *templates.TemplateContext,
+ osFS fs.FS,
+ tplPath string,
+ payload any,
+) error {
+ tplStr, err := fs.ReadFile(osFS, tplPath)
+ if err != nil {
+ return fmt.Errorf("loading template: %w", err)
+ }
+
+ tpl := ctx.NewTemplate(tplPath)
+ if _, err := tpl.Parse(string(tplStr)); err != nil {
+ return fmt.Errorf("parsing template: %w", err)
+ }
+
+ tpl.Delims(g.Delimiters[0], g.Delimiters[1])
+
+ if err := tpl.Execute(into, payload); err != nil {
+ return fmt.Errorf("executing template: %w", err)
+ }
+
+ return nil
+}
+
+func (g *Gemtext) ServeHTTP(
+ rw http.ResponseWriter, r *http.Request, next caddyhttp.Handler,
+) error {
+ buf, bufDone := toolkit.GetBuffer()
+ defer bufDone()
+
+ // We only want to buffer and work on responses which are gemtext files.
+ shouldBuf := func(status int, header http.Header) bool {
+ ct := header.Get("Content-Type")
+ return strings.HasPrefix(ct, gemtextMIME)
+ }
+
+ rec := caddyhttp.NewResponseRecorder(rw, buf, shouldBuf)
+ if err := next.ServeHTTP(rec, r); err != nil || !rec.Buffered() {
+ return err
+ }
+
+ buf = rec.Buffer() // probably redundant, but just in case
+
+ var (
+ repl = r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
+ rootDir = repl.ReplaceAll(g.FileRoot, ".")
+ osFS = os.DirFS(rootDir)
+ httpFS = http.Dir(rootDir)
+ ctx = &templates.TemplateContext{
+ Root: httpFS,
+ Req: r,
+ RespHeader: templates.WrappedHeader{Header: rec.Header()},
+ }
+ )
+
+ parser := gemtext.HTMLTranslator{}
+
+ if g.LinkTemplatePath != "" {
+ parser.RenderLink = func(w io.Writer, url, label string) error {
+ payload := struct {
+ *templates.TemplateContext
+ URL string
+ Label string
+ }{
+ ctx, url, label,
+ }
+
+ return g.render(w, ctx, osFS, g.LinkTemplatePath, payload)
+ }
+ }
+
+ translated, err := parser.Translate(buf)
+ if err != nil {
+ return fmt.Errorf("translating gemtext: %w", err)
+ }
+
+ payload := struct {
+ *templates.TemplateContext
+ gemtext.HTML
+ }{
+ ctx, translated,
+ }
+
+ buf.Reset()
+ if err := g.render(
+ buf, ctx, osFS, g.TemplatePath, payload,
+ ); err != nil {
+ // templates may return a custom HTTP error to be propagated to the
+ // client, otherwise for any other error we assume the template is
+ // broken
+ var handlerErr caddyhttp.HandlerError
+ if errors.As(err, &handlerErr) {
+ return handlerErr
+ }
+ return caddyhttp.Error(http.StatusInternalServerError, err)
+ }
+
+ rec.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
+ rec.Header().Del("Accept-Ranges") // we don't know ranges for dynamically-created content
+ rec.Header().Del("Last-Modified") // useless for dynamic content since it's always changing
+
+ // we don't know a way to quickly generate etag for dynamic content,
+ // and weak etags still cause browsers to rely on it even after a
+ // refresh, so disable them until we find a better way to do this
+ rec.Header().Del("Etag")
+
+ // The Content-Type was originally text/gemini, but now it will be text/html
+ // (we assume, since the HTML translator was used). Deleting here will cause
+ // Caddy to do an auto-detect of the Content-Type, so it will even get the
+ // charset properly set.
+ rec.Header().Del("Content-Type")
+
+ return rec.WriteResponse()
+}
+
+// gemtextParseCaddyfile sets up the handler from Caddyfile tokens. Syntax:
+//
+// gemtext [<matcher>] {
+// between <open_delim> <close_delim>
+// root <path>
+// }
+func gemtextParseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
+ h.Next() // consume directive name
+ g := new(Gemtext)
+ for h.NextBlock(0) {
+ switch h.Val() {
+ case "template":
+ if !h.Args(&g.TemplatePath) {
+ return nil, h.ArgErr()
+ }
+ case "link_template":
+ if !h.Args(&g.LinkTemplatePath) {
+ return nil, h.ArgErr()
+ }
+ case "root":
+ if !h.Args(&g.FileRoot) {
+ return nil, h.ArgErr()
+ }
+ case "between":
+ g.Delimiters = h.RemainingArgs()
+ if len(g.Delimiters) != 2 {
+ return nil, h.ArgErr()
+ }
+ }
+ }
+ return g, nil
+}
diff --git a/http/handlers/handlers.go b/http/handlers/handlers.go
new file mode 100644
index 0000000..c6ded57
--- /dev/null
+++ b/http/handlers/handlers.go
@@ -0,0 +1,2 @@
+// Package handlers implements extra HTTP middlewares.
+package handlers
diff --git a/http/handlers/templates/functions/gemtext.go b/http/handlers/templates/functions/gemtext.go
index 68a10ca..bc7a31f 100644
--- a/http/handlers/templates/functions/gemtext.go
+++ b/http/handlers/templates/functions/gemtext.go
@@ -18,7 +18,7 @@ import (
func init() {
caddy.RegisterModule(Gemtext{})
httpcaddyfile.RegisterDirective(
- "gemtext",
+ "gemtext_function",
func(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) {
var f Gemtext
err := f.UnmarshalCaddyfile(h.Dispenser)
@@ -56,7 +56,7 @@ func (f *Gemtext) CustomTemplateFunctions() template.FuncMap {
func (Gemtext) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
- ID: "http.handlers.templates.functions.gemtext",
+ ID: "http.handlers.templates.functions.gemtext_function",
New: func() caddy.Module { return new(Gemtext) },
}
}
diff --git a/internal/toolkit/toolkit.go b/internal/toolkit/toolkit.go
new file mode 100644
index 0000000..f87a496
--- /dev/null
+++ b/internal/toolkit/toolkit.go
@@ -0,0 +1,23 @@
+// Package toolkit contains useful, general-purpose utilities
+package toolkit
+
+import (
+ "bytes"
+ "sync"
+)
+
+var bufPool = sync.Pool{
+ New: func() any {
+ return new(bytes.Buffer)
+ },
+}
+
+// GetBuffer returns an empty buffer, along with a function which can be used to
+// return it to a global pool, which helps reduce allocations.
+func GetBuffer() (*bytes.Buffer, func()) {
+ buf := bufPool.Get().(*bytes.Buffer)
+ return buf, func() {
+ buf.Reset()
+ bufPool.Put(buf)
+ }
+}
diff --git a/plugins.go b/plugins.go
index 7a98baa..fd16169 100644
--- a/plugins.go
+++ b/plugins.go
@@ -3,5 +3,6 @@
package mediocrecaddyplugins
import (
+ _ "dev.mediocregopher.com/mediocre-caddy-plugins.git/http/handlers"
_ "dev.mediocregopher.com/mediocre-caddy-plugins.git/http/handlers/templates/functions"
)