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 --- http/handlers/gemtext.go | 277 +++++++++++++++++++++++++++ http/handlers/handlers.go | 2 + http/handlers/templates/functions/gemtext.go | 4 +- 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 http/handlers/gemtext.go create mode 100644 http/handlers/handlers.go (limited to 'http') 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) }, } } -- cgit v1.2.3