From cf6af6def1cac3fc6cd044c82282208b7073eb64 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Fri, 1 Nov 2024 13:00:18 +0100 Subject: Implement gemtext HTTP middleware --- README.md | 70 ++++++- example/Caddyfile | 23 +- example/tpl/render_gemtext.html | 9 +- example/tpl/render_gemtext_link.html | 5 + example/tpl/render_gemtext_with_templates.html | 15 ++ http/handlers/gemtext.go | 277 +++++++++++++++++++++++++ http/handlers/handlers.go | 2 + http/handlers/templates/functions/gemtext.go | 4 +- internal/toolkit/toolkit.go | 23 ++ plugins.go | 1 + 10 files changed, 412 insertions(+), 17 deletions(-) create mode 100644 example/tpl/render_gemtext_link.html create mode 100644 example/tpl/render_gemtext_with_templates.html create mode 100644 http/handlers/gemtext.go create mode 100644 http/handlers/handlers.go create mode 100644 internal/toolkit/toolkit.go 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) }} - {{ $gemtextRes.Title | default "Example Gemtext File" }} + {{ .Title | default "Example Gemtext File" }} - {{ $gemtextRes.Body }} + {{ .Body }} 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 }} +

{{ .Label }}

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) }} + + + + {{ $gemtextRes.Title | default "Example Gemtext File" }} + + + + {{ $gemtextRes.Body }} + + 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 [] { +// between +// root +// } +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" ) -- cgit v1.2.3