diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/atom/atom.go | 42 | ||||
-rw-r--r-- | internal/builder/builder.go | 277 | ||||
-rw-r--r-- | internal/builder/files.go | 120 | ||||
-rw-r--r-- | internal/builder/hasher.go | 13 | ||||
-rw-r--r-- | internal/builder/sitemap.go | 29 | ||||
-rw-r--r-- | internal/builder/template.go | 437 | ||||
-rw-r--r-- | internal/config/config.go | 18 | ||||
-rw-r--r-- | internal/config/cspgenerator.go | 2 | ||||
-rw-r--r-- | internal/content/posts.go (renamed from internal/builder/posts.go) | 29 | ||||
-rw-r--r-- | internal/http/error.go | 7 | ||||
-rw-r--r-- | internal/log/log.go | 48 | ||||
-rw-r--r-- | internal/server/dev.go | 74 | ||||
-rw-r--r-- | internal/server/logging.go | 16 | ||||
-rw-r--r-- | internal/server/mime.go | 9 | ||||
-rw-r--r-- | internal/server/server.go | 234 | ||||
-rw-r--r-- | internal/server/tcp.go | 14 | ||||
-rw-r--r-- | internal/server/tls.go | 185 | ||||
-rw-r--r-- | internal/sitemap/sitemap.go | 36 | ||||
-rw-r--r-- | internal/vcs/repository.go | 123 | ||||
-rw-r--r-- | internal/website/filemap.go | 68 | ||||
-rw-r--r-- | internal/website/mux.go | 76 |
21 files changed, 1106 insertions, 751 deletions
diff --git a/internal/atom/atom.go b/internal/atom/atom.go index 37c53d9..f75d18a 100644 --- a/internal/atom/atom.go +++ b/internal/atom/atom.go @@ -1,33 +1,52 @@ package atom import ( + "bytes" "encoding/xml" + "net/url" "time" - "website/internal/config" + "go.alanpearce.eu/website/internal/config" ) -func MakeTagURI(config config.Config, specific string) string { +func MakeTagURI(config *config.Config, specific string) string { return "tag:" + config.OriginalDomain + "," + config.DomainStartDate + ":" + specific } +func LinkXSL(w *bytes.Buffer, url string) error { + _, err := w.WriteString(`<?xml-stylesheet href="`) + if err != nil { + return err + } + err = xml.EscapeText(w, []byte(url)) + if err != nil { + return err + } + _, err = w.WriteString(`" type="text/xsl"?>`) + if err != nil { + return err + } + + return nil +} + type Link struct { XMLName xml.Name `xml:"link"` - Rel string `xml:"rel,attr"` - Type string `xml:"type,attr"` + Rel string `xml:"rel,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` Href string `xml:"href,attr"` } -func MakeLink(url string) Link { +func MakeLink(url *url.URL) Link { return Link{ Rel: "alternate", Type: "text/html", - Href: url, + Href: url.String(), } } type FeedContent struct { - Content string `xml:",innerxml"` + Content string `xml:",chardata"` Type string `xml:"type,attr"` } @@ -41,3 +60,12 @@ type FeedEntry struct { Content FeedContent `xml:"content"` Author string `xml:"author>name"` } + +type Feed struct { + XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"` + Title string `xml:"title"` + Link Link `xml:"link"` + ID string `xml:"id"` + Updated time.Time `xml:"updated"` + Entries []*FeedEntry `xml:"entry"` +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index b17fbc2..b99d919 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -1,54 +1,80 @@ package builder import ( + "context" "fmt" "io" - "net/url" "os" "path" + "path/filepath" "slices" - "sync" "time" - "website/internal/config" - "website/internal/log" + "go.alanpearce.eu/website/internal/config" + "go.alanpearce.eu/website/internal/content" + "go.alanpearce.eu/x/log" + "go.alanpearce.eu/website/internal/sitemap" + "go.alanpearce.eu/website/templates" - cp "github.com/otiai10/copy" - "github.com/pkg/errors" - "github.com/snabb/sitemap" + "github.com/a-h/templ" + mapset "github.com/deckarep/golang-set/v2" + "gitlab.com/tozd/go/errors" ) type IOConfig struct { Source string `conf:"default:.,short:s,flag:src"` - Destination string `conf:"default:website,short:d,flag:dest"` - BaseURL config.URL - Development bool `conf:"default:false,flag:dev"` + Destination string `conf:"default:public,short:d,flag:dest"` + Development bool `conf:"default:false,flag:dev"` } +type Result struct { + Hashes []string +} + +var compressFiles = false + func mkdirp(dirs ...string) error { err := os.MkdirAll(path.Join(dirs...), 0755) return errors.Wrap(err, "could not create directory") } -func outputToFile(output io.Reader, filename ...string) error { - log.Debug("outputting file", "filename", path.Join(filename...)) - file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) +func outputToFile(output io.Reader, pathParts ...string) error { + filename := path.Join(pathParts...) + // log.Debug("outputting file", "filename", filename) + file, err := openFileAndVariants(filename) if err != nil { return errors.WithMessage(err, "could not open output file") } defer file.Close() - if _, err := file.ReadFrom(output); err != nil { + if _, err := io.Copy(file, output); err != nil { return errors.WithMessage(err, "could not write output file") } return nil } -func writerToFile(writer io.WriterTo, filename ...string) error { - log.Debug("outputting file", "filename", path.Join(filename...)) - file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) +func renderToFile(component templ.Component, pathParts ...string) error { + filename := path.Join(pathParts...) + // log.Debug("outputting file", "filename", filename) + file, err := openFileAndVariants(filename) + if err != nil { + return errors.WithMessage(err, "could not open output file") + } + defer file.Close() + + if err := component.Render(context.TODO(), file); err != nil { + return errors.WithMessage(err, "could not write output file") + } + + return nil +} + +func writerToFile(writer io.WriterTo, pathParts ...string) error { + filename := path.Join(pathParts...) + // log.Debug("outputting file", "filename", path.Join(filename...)) + file, err := openFileAndVariants(filename) if err != nil { return errors.WithMessage(err, "could not open output file") } @@ -61,206 +87,185 @@ func writerToFile(writer io.WriterTo, filename ...string) error { return nil } -func build(outDir string, config config.Config) error { - log.Debug("output", "dir", outDir) - assetsOnce = sync.Once{} - privateDir := path.Join(outDir, "private") - if err := mkdirp(privateDir); err != nil { - return errors.WithMessage(err, "could not create private directory") +func joinSourcePath(src string) func(string) string { + return func(rel string) string { + return filepath.Join(src, rel) } - publicDir := path.Join(outDir, "public") - if err := mkdirp(publicDir); err != nil { - return errors.WithMessage(err, "could not create public directory") +} + +func build(ioConfig *IOConfig, config *config.Config, log *log.Logger) (*Result, error) { + outDir := ioConfig.Destination + joinSource := joinSourcePath(ioConfig.Source) + log.Debug("output", "dir", outDir) + r := &Result{ + Hashes: make([]string, 0), } - err := cp.Copy("static", publicDir, cp.Options{ - PreserveTimes: true, - PermissionControl: cp.AddPermission(0755), - }) + err := copyRecursive(joinSource("static"), outDir) if err != nil { - return errors.WithMessage(err, "could not copy static files") + return nil, errors.WithMessage(err, "could not copy static files") } - if err := mkdirp(publicDir, "post"); err != nil { - return errors.WithMessage(err, "could not create post output directory") + if err := mkdirp(outDir, "post"); err != nil { + return nil, errors.WithMessage(err, "could not create post output directory") } log.Debug("reading posts") - posts, tags, err := readPosts("content", "post", publicDir) + posts, tags, err := content.ReadPosts(&content.Config{ + Root: joinSource("content"), + InputDir: "post", + OutputDir: outDir, + }, log.Named("content")) if err != nil { - return err + return nil, err } - sm := NewSitemap(config) + sitemap := sitemap.New(config) lastMod := time.Now() if len(posts) > 0 { lastMod = posts[0].Date } for _, post := range posts { - if err := mkdirp(publicDir, "post", post.Basename); err != nil { - return errors.WithMessage(err, "could not create directory for post") + if err := mkdirp(outDir, "post", post.Basename); err != nil { + return nil, errors.WithMessage(err, "could not create directory for post") } log.Debug("rendering post", "post", post.Basename) - sm.Add(&sitemap.URL{ - Loc: post.URL, - LastMod: &post.Date, - }) - output, err := renderPost(post, config) - if err != nil { - return errors.WithMessagef(err, "could not render post %s", post.Input) - } - if err := outputToFile(output, post.Output); err != nil { - return err + sitemap.AddPath(post.URL, post.Date) + if err := renderToFile(templates.PostPage(config, post), post.Output); err != nil { + return nil, err } } - if err := mkdirp(publicDir, "tags"); err != nil { - return errors.WithMessage(err, "could not create directory for tags") + if err := mkdirp(outDir, "tags"); err != nil { + return nil, errors.WithMessage(err, "could not create directory for tags") } log.Debug("rendering tags list") - output, err := renderTags(tags, config, "/tags") - if err != nil { - return errors.WithMessage(err, "could not render tags") + if err := renderToFile( + templates.TagsPage(config, "tags", mapset.Sorted(tags), "/tags"), + outDir, + "tags", + "index.html", + ); err != nil { + return nil, err } - if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil { - return err - } - sm.Add(&sitemap.URL{ - Loc: "/tags/", - LastMod: &lastMod, - }) + sitemap.AddPath("/tags/", lastMod) for _, tag := range tags.ToSlice() { - matchingPosts := []Post{} + matchingPosts := []content.Post{} for _, post := range posts { if slices.Contains(post.Taxonomies.Tags, tag) { matchingPosts = append(matchingPosts, post) } } - if err := mkdirp(publicDir, "tags", tag); err != nil { - return errors.WithMessage(err, "could not create directory") + if err := mkdirp(outDir, "tags", tag); err != nil { + return nil, errors.WithMessage(err, "could not create directory") } log.Debug("rendering tags page", "tag", tag) url := "/tags/" + tag - output, err := renderListPage(tag, config, matchingPosts, url) - if err != nil { - return errors.WithMessage(err, "could not render tag page") - } - if err := outputToFile(output, publicDir, "tags", tag, "index.html"); err != nil { - return err + if err := renderToFile( + templates.TagPage(config, tag, matchingPosts, url), + outDir, + "tags", + tag, + "index.html", + ); err != nil { + return nil, err } - sm.Add(&sitemap.URL{ - Loc: url, - LastMod: &matchingPosts[0].Date, - }) + sitemap.AddPath(url, matchingPosts[0].Date) log.Debug("rendering tags feed", "tag", tag) - output, err = renderFeed( + feed, err := renderFeed( fmt.Sprintf("%s - %s", config.Title, tag), config, matchingPosts, tag, ) if err != nil { - return errors.WithMessage(err, "could not render tag feed page") + return nil, errors.WithMessage(err, "could not render tag feed page") } - if err := outputToFile(output, publicDir, "tags", tag, "atom.xml"); err != nil { - return err + if err := writerToFile(feed, outDir, "tags", tag, "atom.xml"); err != nil { + return nil, err } } log.Debug("rendering list page") - listPage, err := renderListPage("", config, posts, "/post") - if err != nil { - return errors.WithMessage(err, "could not render list page") + if err := renderToFile(templates.ListPage(config, posts, "/post"), outDir, "post", "index.html"); err != nil { + return nil, err } - if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil { - return err - } - sm.Add(&sitemap.URL{ - Loc: "/post/", - LastMod: &lastMod, - }) + sitemap.AddPath("/post/", lastMod) log.Debug("rendering feed") feed, err := renderFeed(config.Title, config, posts, "feed") if err != nil { - return errors.WithMessage(err, "could not render feed") + return nil, errors.WithMessage(err, "could not render feed") } - if err := outputToFile(feed, publicDir, "atom.xml"); err != nil { - return err + if err := writerToFile(feed, outDir, "atom.xml"); err != nil { + return nil, err } log.Debug("rendering feed styles") - feedStyles, err := renderFeedStyles() + feedStyles, err := renderFeedStyles(ioConfig.Source) + if err != nil { + return nil, errors.WithMessage(err, "could not render feed styles") + } + if err := outputToFile(feedStyles, outDir, "feed-styles.xsl"); err != nil { + return nil, err + } + _, err = feedStyles.Seek(0, 0) if err != nil { - return errors.WithMessage(err, "could not render feed styles") + return nil, err } - if err := outputToFile(feedStyles, publicDir, "feed-styles.xsl"); err != nil { - return err + h, err := getFeedStylesHash(feedStyles) + if err != nil { + return nil, err } + r.Hashes = append(r.Hashes, h) log.Debug("rendering homepage") - homePage, err := renderHomepage(config, posts, "/") + _, text, err := content.GetPost(joinSource(filepath.Join("content", "index.md"))) + if err != nil { + return nil, err + } + content, err := content.RenderMarkdown(text) if err != nil { - return errors.WithMessage(err, "could not render homepage") + return nil, err } - if err := outputToFile(homePage, publicDir, "index.html"); err != nil { - return err + if err := renderToFile(templates.Homepage(config, posts, content), outDir, "index.html"); err != nil { + return nil, err } // it would be nice to set LastMod here, but using the latest post // date would be wrong as the homepage has its own content file // without a date, which could be newer - sm.Add(&sitemap.URL{ - Loc: "/", - }) - - log.Debug("rendering 404 page") - notFound, err := render404(config, "/404.html") - if err != nil { - return errors.WithMessage(err, "could not render 404 page") - } - if err := outputToFile(notFound, publicDir, "404.html"); err != nil { - return err - } + sitemap.AddPath("/", time.Time{}) + h, _ = getHTMLStyleHash(outDir, "index.html") + r.Hashes = append(r.Hashes, h) log.Debug("rendering sitemap") - if err := writerToFile(sm, publicDir, "sitemap.xml"); err != nil { - return err + if err := writerToFile(sitemap, outDir, "sitemap.xml"); err != nil { + return nil, err } log.Debug("rendering robots.txt") - rob, err := renderRobotsTXT(config) + rob, err := renderRobotsTXT(ioConfig.Source, config) if err != nil { - return err + return nil, err } - if err := outputToFile(rob, publicDir, "robots.txt"); err != nil { - return err + if err := outputToFile(rob, outDir, "robots.txt"); err != nil { + return nil, err } - return nil + return r, nil } -func BuildSite(ioConfig IOConfig) error { - config, err := config.GetConfig() - if err != nil { - return errors.WithMessage(err, "could not get config") +func BuildSite(ioConfig *IOConfig, cfg *config.Config, log *log.Logger) (*Result, error) { + if cfg == nil { + return nil, errors.New("config is nil") } - config.InjectLiveReload = ioConfig.Development + cfg.InjectLiveReload = ioConfig.Development + compressFiles = !ioConfig.Development - if ioConfig.BaseURL.URL != nil { - config.BaseURL.URL, err = url.Parse(ioConfig.BaseURL.String()) - if err != nil { - return errors.WithMessage(err, "could not re-parse base URL") - } - } - - if ioConfig.Development && ioConfig.Destination != "website" { - err = os.RemoveAll(ioConfig.Destination) - if err != nil { - return errors.WithMessage(err, "could not remove destination directory") - } - } + templates.Setup() + loadCSS(ioConfig.Source) - return build(ioConfig.Destination, *config) + return build(ioConfig, cfg, log) } diff --git a/internal/builder/files.go b/internal/builder/files.go new file mode 100644 index 0000000..a9046d7 --- /dev/null +++ b/internal/builder/files.go @@ -0,0 +1,120 @@ +package builder + +import ( + "compress/gzip" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/andybalholm/brotli" +) + +const ( + gzipLevel = 6 + brotliLevel = 9 +) + +type MultiWriteCloser struct { + writers []io.WriteCloser + multiWriter io.Writer +} + +func (mw *MultiWriteCloser) Write(p []byte) (n int, err error) { + return mw.multiWriter.Write(p) +} + +func (mw *MultiWriteCloser) Close() error { + var lastErr error + for _, w := range mw.writers { + err := w.Close() + if err != nil { + lastErr = err + } + } + + return lastErr +} + +func openFileWrite(filename string) (*os.File, error) { + return os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) +} + +func openFileGz(filename string) (*gzip.Writer, error) { + filenameGz := filename + ".gz" + f, err := openFileWrite(filenameGz) + if err != nil { + return nil, err + } + + return gzip.NewWriterLevel(f, gzipLevel) +} + +func openFileBrotli(filename string) (*brotli.Writer, error) { + filenameBrotli := filename + ".br" + f, err := openFileWrite(filenameBrotli) + if err != nil { + return nil, err + } + + return brotli.NewWriterLevel(f, brotliLevel), nil +} + +func multiOpenFile(filename string) (*MultiWriteCloser, error) { + r, err := openFileWrite(filename) + if err != nil { + return nil, err + } + gz, err := openFileGz(filename) + if err != nil { + return nil, err + } + br, err := openFileBrotli(filename) + if err != nil { + return nil, err + } + + return &MultiWriteCloser{ + writers: []io.WriteCloser{r, gz, br}, + multiWriter: io.MultiWriter(r, gz, br), + }, nil +} + +func openFileAndVariants(filename string) (io.WriteCloser, error) { + if compressFiles { + return multiOpenFile(filename) + } + + return openFileWrite(filename) +} + +func copyRecursive(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if d.IsDir() { + return mkdirp(dst, rel) + } + + sf, err := os.Open(path) + if err != nil { + return err + } + defer sf.Close() + df, err := openFileAndVariants(filepath.Join(dst, rel)) + if err != nil { + return err + } + defer df.Close() + if _, err := io.Copy(df, sf); err != nil { + return err + } + + return nil + }) +} diff --git a/internal/builder/hasher.go b/internal/builder/hasher.go new file mode 100644 index 0000000..f0f9167 --- /dev/null +++ b/internal/builder/hasher.go @@ -0,0 +1,13 @@ +package builder + +import ( + "crypto/sha256" + "encoding/base64" +) + +func hash(s string) string { + shasum := sha256.New() + shasum.Write([]byte(s)) + + return "sha256-" + base64.StdEncoding.EncodeToString(shasum.Sum(nil)) +} diff --git a/internal/builder/sitemap.go b/internal/builder/sitemap.go deleted file mode 100644 index 81e3a31..0000000 --- a/internal/builder/sitemap.go +++ /dev/null @@ -1,29 +0,0 @@ -package builder - -import ( - "io" - "website/internal/config" - - "github.com/snabb/sitemap" -) - -type Sitemap struct { - config *config.Config - Sitemap *sitemap.Sitemap -} - -func NewSitemap(cfg config.Config) *Sitemap { - return &Sitemap{ - config: &cfg, - Sitemap: sitemap.New(), - } -} - -func (s *Sitemap) Add(u *sitemap.URL) { - u.Loc = s.config.BaseURL.JoinPath(u.Loc).String() - s.Sitemap.Add(u) -} - -func (s *Sitemap) WriteTo(w io.Writer) (int64, error) { - return s.Sitemap.WriteTo(w) -} diff --git a/internal/builder/template.go b/internal/builder/template.go index ab36c85..9f019df 100644 --- a/internal/builder/template.go +++ b/internal/builder/template.go @@ -1,55 +1,41 @@ package builder import ( + "bytes" "encoding/xml" - "fmt" "io" - "net/url" "os" + "path/filepath" "strings" - "sync" "text/template" - "time" - "website/internal/atom" - "website/internal/config" - "website/internal/log" + + "go.alanpearce.eu/website/internal/atom" + "go.alanpearce.eu/website/internal/config" + "go.alanpearce.eu/website/internal/content" "github.com/PuerkitoBio/goquery" - "github.com/a-h/htmlformat" "github.com/antchfx/xmlquery" "github.com/antchfx/xpath" - mapset "github.com/deckarep/golang-set/v2" - "github.com/pkg/errors" - "golang.org/x/net/html" + "gitlab.com/tozd/go/errors" ) var ( - assetsOnce sync.Once - css string - countHTML *goquery.Document - liveReloadHTML *goquery.Document - templates = make(map[string]*os.File) + css string + nsMap = map[string]string{ + "xsl": "http://www.w3.org/1999/XSL/Transform", + "atom": "http://www.w3.org/2005/Atom", + "xhtml": "http://www.w3.org/1999/xhtml", + } ) -func loadTemplate(path string) (file *os.File, err error) { - if templates[path] == nil { - file, err = os.OpenFile(path, os.O_RDONLY, 0) - if err != nil { - return nil, errors.Wrapf(err, "could not load template at path %s", path) - } - templates[path] = file +func loadCSS(source string) { + bytes, err := os.ReadFile(filepath.Join(source, "templates/style.css")) + if err != nil { + panic(err) } - file = templates[path] - - return + css = string(bytes) } -var ( - imgOnce sync.Once - img *goquery.Selection - urlTemplate *url.URL -) - type QuerySelection struct { *goquery.Selection } @@ -68,237 +54,9 @@ func (q *QueryDocument) Find(selector string) *QuerySelection { return &QuerySelection{q.Document.Find(selector)} } -func NewDocumentNoScript(r io.Reader) (*goquery.Document, error) { - root, err := html.ParseWithOptions(r, html.ParseOptionEnableScripting(false)) - - return goquery.NewDocumentFromNode(root), errors.Wrap(err, "could not parse HTML") -} - -func (root QuerySelection) setImgURL(pageURL string, pageTitle string) QuerySelection { - clone := countHTML.Clone() - imgOnce.Do(func() { - var err error - img = clone.Find("img") - attr, _ := img.Attr("src") - if attr == "" { - panic("<img> does not have src attribute") - } - urlTemplate, err = url.Parse(attr) - if err != nil { - panic(err.Error()) - } - }) - q := urlTemplate.Query() - urlTemplate.RawQuery = "" - q.Set("p", pageURL) - q.Set("t", pageTitle) - output := urlTemplate.String() + "?" + q.Encode() - clone.Find("img").SetAttr("src", output) - root.AppendSelection(clone.Find("body").Children()) - - return root -} - -func layout( - filename string, - config config.Config, - pageTitle string, - pageURL string, -) (*goquery.Document, error) { - html, err := loadTemplate(filename) - if err != nil { - return nil, err - } - defer func() { - _, err := html.Seek(0, io.SeekStart) - if err != nil { - panic("could not reset template file offset: " + err.Error()) - } - }() - assetsOnce.Do(func() { - var bytes []byte - bytes, err = os.ReadFile("templates/style.css") - if err != nil { - return - } - css = string(bytes) - countFile, err := os.OpenFile("templates/count.html", os.O_RDONLY, 0) - if err != nil { - return - } - defer countFile.Close() - countHTML, err = NewDocumentNoScript(countFile) - if err != nil { - return - } - if config.InjectLiveReload { - liveReloadFile, err := os.OpenFile("templates/dev.html", os.O_RDONLY, 0) - if err != nil { - return - } - defer liveReloadFile.Close() - liveReloadHTML, err = goquery.NewDocumentFromReader(liveReloadFile) - if err != nil { - return - } - } - }) - if err != nil { - return nil, errors.Wrap(err, "could not set up layout template") - } - - doc, err := NewDocumentFromReader(html) - if err != nil { - return nil, err - } - doc.Find("html").SetAttr("lang", config.DefaultLanguage) - doc.Find("head > link[rel=alternate]").SetAttr("title", config.Title) - doc.Find("head > link[rel=canonical]").SetAttr("href", pageURL) - doc.Find(".title").SetText(config.Title) - doc.Find("title").Add(".p-name").SetText(pageTitle) - doc.Find("head > style").SetHtml(css) - doc.Find("body").setImgURL(pageURL, pageTitle) - if config.InjectLiveReload { - doc.Find("body").AppendSelection(liveReloadHTML.Find("body").Clone()) - } - nav := doc.Find("nav") - navLink := doc.Find("nav a") - nav.Empty() - for _, link := range config.Menus["main"] { - nav.AppendSelection(navLink.Clone().SetAttr("href", link.URL).SetText(link.Name)) - } - - return doc.Document, nil -} - -func renderPost(post Post, config config.Config) (r io.Reader, err error) { - doc, err := layout("templates/post.html", config, post.PostMatter.Title, post.URL) - if err != nil { - return nil, err - } - doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author") - doc.Find(".h-entry .dt-published"). - SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). - SetText( - post.PostMatter.Date.Format("2006-01-02"), - ) - doc.Find(".h-entry .e-content").SetHtml(post.Content) - categories := doc.Find(".h-entry .p-categories") - tpl := categories.Find(".p-category").ParentsUntilSelection(categories) - tpl.Remove() - for _, tag := range post.Taxonomies.Tags { - cat := tpl.Clone() - cat.Find(".p-category").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag) - categories.AppendSelection(cat) - } - - return renderHTML(doc), nil -} - -func renderTags(tags Tags, config config.Config, url string) (io.Reader, error) { - doc, err := layout("templates/tags.html", config, config.Title, url) - if err != nil { - return nil, err - } - tagList := doc.Find(".tags") - tpl := doc.Find(".h-feed") - tpl.Remove() - for _, tag := range mapset.Sorted(tags) { - li := tpl.Clone() - li.Find("a").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag) - tagList.AppendSelection(li) - } - - return renderHTML(doc), nil -} - -func renderListPage(tag string, config config.Config, posts []Post, url string) (io.Reader, error) { - var title string - if len(tag) > 0 { - title = tag - } else { - title = config.Title - } - doc, err := layout("templates/list.html", config, title, url) - if err != nil { - return nil, err - } - feed := doc.Find(".h-feed") - tpl := feed.Find(".h-entry") - tpl.Remove() - - doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author") - if tag == "" { - doc.Find(".filter").Remove() - } else { - doc.Find(".filter").Find("h3").SetText("#" + tag) - } - - for _, post := range posts { - entry := tpl.Clone() - entry.Find(".p-name").SetText(post.Title).SetAttr("href", post.URL) - entry.Find(".dt-published"). - SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). - SetText(post.PostMatter.Date.Format("2006-01-02")) - feed.AppendSelection(entry) - } - - return renderHTML(doc), nil -} - -func renderHomepage(config config.Config, posts []Post, url string) (io.Reader, error) { - _, index, err := getPost("content/_index.md") - if err != nil { - return nil, err - } - doc, err := layout("templates/homepage.html", config, config.Title, url) - if err != nil { - return nil, err - } - doc.Find("body").AddClass("h-card") - doc.Find(".title").AddClass("p-name u-url") - - html, err := renderMarkdown(index) - if err != nil { - return nil, err - } - doc.Find("#content").SetHtml(html) - - feed := doc.Find(".h-feed") - tpl := feed.Find(".h-entry") - tpl.Remove() - - for _, post := range posts[0:3] { - entry := tpl.Clone() - entry.Find(".p-name").SetText(post.Title) - entry.Find(".u-url").SetAttr("href", post.URL) - entry. - Find(".dt-published"). - SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). - SetText(post.PostMatter.Date.Format("2006-01-02")) - - feed.AppendSelection(entry) - } - doc.Find(".u-email"). - SetAttr("href", fmt.Sprintf("mailto:%s", config.Email)). - SetText(config.Email) - - elsewhere := doc.Find(".elsewhere") - linkRelMe := elsewhere.Find(".u-url[rel=me]").ParentsUntil("ul") - linkRelMe.Remove() - - for _, link := range config.Menus["me"] { - el := linkRelMe.Clone() - el.Find("a").SetAttr("href", link.URL).SetText(link.Name) - elsewhere.AppendSelection(el) - } - - return renderHTML(doc), nil -} - -func renderRobotsTXT(config config.Config) (io.Reader, error) { +func renderRobotsTXT(source string, config *config.Config) (io.Reader, error) { r, w := io.Pipe() - tpl, err := template.ParseFiles("templates/robots.tmpl") + tpl, err := template.ParseFiles(filepath.Join(source, "templates/robots.tmpl")) if err != nil { return nil, err } @@ -311,55 +69,36 @@ func renderRobotsTXT(config config.Config) (io.Reader, error) { } w.Close() }() - return r, nil -} -func render404(config config.Config, url string) (io.Reader, error) { - doc, err := layout("templates/404.html", config, "404 Not Found", url) - if err != nil { - return nil, err - } - - return renderHTML(doc), nil + return r, nil } func renderFeed( title string, - config config.Config, - posts []Post, + config *config.Config, + posts []content.Post, specific string, -) (io.Reader, error) { - reader, err := loadTemplate("templates/feed.xml") +) (io.WriterTo, error) { + buf := &bytes.Buffer{} + datetime := posts[0].Date.UTC() + + buf.WriteString(xml.Header) + err := atom.LinkXSL(buf, "/feed-styles.xsl") if err != nil { return nil, err } - defer func() { - _, err := reader.Seek(0, io.SeekStart) - if err != nil { - panic("could not reset reader: " + err.Error()) - } - }() - doc, err := xmlquery.Parse(reader) - if err != nil { - return nil, errors.Wrap(err, "could not parse XML") - } - feed := doc.SelectElement("feed") - feed.SelectElement("title").FirstChild.Data = title - feed.SelectElement("link").SetAttr("href", config.BaseURL.String()) - feed.SelectElement("id").FirstChild.Data = atom.MakeTagURI(config, specific) - datetime, err := posts[0].Date.UTC().MarshalText() - if err != nil { - return nil, errors.Wrap(err, "could not convert post date to text") + feed := &atom.Feed{ + Title: title, + Link: atom.MakeLink(config.BaseURL.URL), + ID: atom.MakeTagURI(config, specific), + Updated: datetime, + Entries: make([]*atom.FeedEntry, len(posts)), } - feed.SelectElement("updated").FirstChild.Data = string(datetime) - tpl := feed.SelectElement("entry") - xmlquery.RemoveFromTree(tpl) - for _, post := range posts { - fullURL := config.BaseURL.JoinPath(post.URL).String() - text, err := xml.MarshalIndent(&atom.FeedEntry{ + for i, post := range posts { + feed.Entries[i] = &atom.FeedEntry{ Title: post.Title, - Link: atom.MakeLink(fullURL), + Link: atom.MakeLink(config.BaseURL.JoinPath(post.URL)), ID: atom.MakeTagURI(config, post.Basename), Updated: post.Date.UTC(), Summary: post.Description, @@ -368,80 +107,66 @@ func renderFeed( Content: post.Content, Type: "html", }, - }, " ", " ") - if err != nil { - return nil, errors.Wrap(err, "could not marshal xml") - } - entry, err := xmlquery.ParseWithOptions( - strings.NewReader(string(text)), - xmlquery.ParserOptions{ - Decoder: &xmlquery.DecoderOptions{ - Strict: false, - AutoClose: xml.HTMLAutoClose, - Entity: xml.HTMLEntity, - }, - }, - ) - if err != nil { - return nil, errors.Wrap(err, "could not parse XML") } - xmlquery.AddChild(feed, entry.SelectElement("entry")) + } + enc := xml.NewEncoder(buf) + err = enc.Encode(feed) + if err != nil { + return nil, err } - return strings.NewReader(doc.OutputXML(true)), nil + return buf, nil } -func renderFeedStyles() (io.Reader, error) { - reader, err := loadTemplate("templates/feed-styles.xsl") +func renderFeedStyles(source string) (*strings.Reader, error) { + tpl, err := template.ParseFiles(filepath.Join(source, "templates/feed-styles.xsl")) if err != nil { return nil, err } - defer func() { - _, err := reader.Seek(0, io.SeekStart) - if err != nil { - panic("could not reset reader: " + err.Error()) - } - }() - nsMap := map[string]string{ - "xsl": "http://www.w3.org/1999/XSL/Transform", - "atom": "http://www.w3.org/2005/Atom", - "xhtml": "http://www.w3.org/1999/xhtml", + + esc := &strings.Builder{} + err = xml.EscapeText(esc, []byte(css)) + if err != nil { + return nil, err } - doc, err := xmlquery.Parse(reader) + + w := &strings.Builder{} + err = tpl.Execute(w, map[string]interface{}{ + "css": esc.String(), + }) + if err != nil { + return nil, err + } + + return strings.NewReader(w.String()), nil +} + +func getFeedStylesHash(r io.Reader) (string, error) { + doc, err := xmlquery.Parse(r) if err != nil { - return nil, errors.Wrap(err, "could not parse XML") + return "", err } expr, err := xpath.CompileWithNS("//xhtml:style", nsMap) if err != nil { - return nil, errors.Wrap(err, "could not parse XML") + return "", errors.Wrap(err, "could not parse XPath") } style := xmlquery.QuerySelector(doc, expr) - xmlquery.AddChild(style, &xmlquery.Node{ - Type: xmlquery.TextNode, - Data: css, - }) - return strings.NewReader(doc.OutputXML(true)), nil + return hash(style.InnerText()), nil } -func renderHTML(doc *goquery.Document) io.Reader { - r, w := io.Pipe() - - go func() { - _, err := w.Write([]byte("<!doctype html>\n")) - if err != nil { - log.Error("error writing doctype", "error", err) - w.CloseWithError(err) - } - err = htmlformat.Nodes(w, []*html.Node{doc.Children().Get(0)}) - if err != nil { - log.Error("error rendering html", "error", err) - w.CloseWithError(err) - - return - } - defer w.Close() - }() +func getHTMLStyleHash(filenames ...string) (string, error) { + fn := filepath.Join(filenames...) + f, err := os.Open(fn) + if err != nil { + return "", err + } + defer f.Close() + doc, err := NewDocumentFromReader(f) + if err != nil { + return "", err + } + html := doc.Find("head > style").Text() - return r + return hash(html), nil } diff --git a/internal/config/config.go b/internal/config/config.go index df69bce..7ccad85 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,10 +3,12 @@ package config import ( "io/fs" "net/url" - "website/internal/log" + "path/filepath" + + "go.alanpearce.eu/x/log" "github.com/BurntSushi/toml" - "github.com/pkg/errors" + "gitlab.com/tozd/go/errors" ) type Taxonomy struct { @@ -16,7 +18,7 @@ type Taxonomy struct { type MenuItem struct { Name string - URL string `toml:"url"` + URL URL `toml:"url"` } type URL struct { @@ -38,6 +40,9 @@ type Config struct { Description string DomainStartDate string `toml:"domain_start_date"` OriginalDomain string `toml:"original_domain"` + GoatCounter URL `toml:"goatcounter"` + Domains []string + WildcardDomain string `toml:"wildcard_domain"` OIDCHost URL `toml:"oidc_host"` Taxonomies []Taxonomy CSP *CSP `toml:"content-security-policy"` @@ -47,10 +52,11 @@ type Config struct { Menus map[string][]MenuItem } -func GetConfig() (*Config, error) { +func GetConfig(dir string, log *log.Logger) (*Config, error) { config := Config{} - log.Debug("reading config.toml") - _, err := toml.DecodeFile("config.toml", &config) + filename := filepath.Join(dir, "config.toml") + log.Debug("reading config", "filename", filename) + _, err := toml.DecodeFile(filename, &config) if err != nil { switch t := err.(type) { case *fs.PathError: diff --git a/internal/config/cspgenerator.go b/internal/config/cspgenerator.go index 40eca01..9974819 100644 --- a/internal/config/cspgenerator.go +++ b/internal/config/cspgenerator.go @@ -9,7 +9,7 @@ import ( "github.com/crewjam/csp" "github.com/fatih/structtag" - "github.com/pkg/errors" + "gitlab.com/tozd/go/errors" ) func GenerateCSP() error { diff --git a/internal/builder/posts.go b/internal/content/posts.go index deae3e8..f4c6c76 100644 --- a/internal/builder/posts.go +++ b/internal/content/posts.go @@ -1,4 +1,4 @@ -package builder +package content import ( "bytes" @@ -8,15 +8,16 @@ import ( "slices" "strings" "time" - "website/internal/log" + + "go.alanpearce.eu/x/log" "github.com/adrg/frontmatter" mapset "github.com/deckarep/golang-set/v2" - "github.com/pkg/errors" fences "github.com/stefanfritsch/goldmark-fences" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" htmlrenderer "github.com/yuin/goldmark/renderer/html" + "gitlab.com/tozd/go/errors" ) type PostMatter struct { @@ -51,7 +52,7 @@ var markdown = goldmark.New( ), ) -func getPost(filename string) (*PostMatter, []byte, error) { +func GetPost(filename string) (*PostMatter, []byte, error) { matter := PostMatter{} content, err := os.Open(filename) if err != nil { @@ -70,7 +71,7 @@ func getPost(filename string) (*PostMatter, []byte, error) { return &matter, rest, nil } -func renderMarkdown(content []byte) (string, error) { +func RenderMarkdown(content []byte) (string, error) { var buf bytes.Buffer if err := markdown.Convert(content, &buf); err != nil { return "", errors.WithMessage(err, "could not convert markdown content") @@ -79,23 +80,29 @@ func renderMarkdown(content []byte) (string, error) { return buf.String(), nil } -func readPosts(root string, inputDir string, outputDir string) ([]Post, Tags, error) { +type Config struct { + Root string + InputDir string + OutputDir string +} + +func ReadPosts(config *Config, log *log.Logger) ([]Post, Tags, error) { tags := mapset.NewSet[string]() posts := []Post{} - subdir := filepath.Join(root, inputDir) + subdir := filepath.Join(config.Root, config.InputDir) files, err := os.ReadDir(subdir) if err != nil { return nil, nil, errors.WithMessagef(err, "could not read post directory %s", subdir) } - outputReplacer := strings.NewReplacer(root, outputDir, ".md", "/index.html") - urlReplacer := strings.NewReplacer(root, "", ".md", "/") + outputReplacer := strings.NewReplacer(config.Root, config.OutputDir, ".md", "/index.html") + urlReplacer := strings.NewReplacer(config.Root, "", ".md", "/") for _, f := range files { pathFromRoot := filepath.Join(subdir, f.Name()) if !f.IsDir() && path.Ext(pathFromRoot) == ".md" { output := outputReplacer.Replace(pathFromRoot) url := urlReplacer.Replace(pathFromRoot) log.Debug("reading post", "post", pathFromRoot) - matter, content, err := getPost(pathFromRoot) + matter, content, err := GetPost(pathFromRoot) if err != nil { return nil, nil, err } @@ -105,7 +112,7 @@ func readPosts(root string, inputDir string, outputDir string) ([]Post, Tags, er } log.Debug("rendering markdown in post", "post", pathFromRoot) - html, err := renderMarkdown(content) + html, err := RenderMarkdown(content) if err != nil { return nil, nil, err } diff --git a/internal/http/error.go b/internal/http/error.go new file mode 100644 index 0000000..8ad3e16 --- /dev/null +++ b/internal/http/error.go @@ -0,0 +1,7 @@ +package http + +type Error struct { + Error error + Message string + Code int +} diff --git a/internal/log/log.go b/internal/log/log.go deleted file mode 100644 index e16d7bb..0000000 --- a/internal/log/log.go +++ /dev/null @@ -1,48 +0,0 @@ -package log - -import ( - "os" - - zaplogfmt "github.com/sykesm/zap-logfmt" - prettyconsole "github.com/thessem/zap-prettyconsole" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -var logger *zap.SugaredLogger - -func DPanic(msg string, rest ...any) { - logger.DPanicw(msg, rest...) -} -func Debug(msg string, rest ...any) { - logger.Debugw(msg, rest...) -} -func Info(msg string, rest ...any) { - logger.Infow(msg, rest...) -} -func Warn(msg string, rest ...any) { - logger.Warnw(msg, rest...) -} -func Error(msg string, rest ...any) { - logger.Errorw(msg, rest...) -} -func Panic(msg string, rest ...any) { - logger.Panicw(msg, rest...) -} -func Fatal(msg string, rest ...any) { - logger.Fatalw(msg, rest...) -} - -func Configure(isProduction bool) { - var l *zap.Logger - if isProduction { - cfg := zap.NewProductionEncoderConfig() - cfg.TimeKey = "" - l = zap.New(zapcore.NewCore(zaplogfmt.NewEncoder(cfg), os.Stderr, zapcore.InfoLevel)) - } else { - cfg := prettyconsole.NewConfig() - cfg.EncoderConfig.TimeKey = "" - l = zap.Must(cfg.Build()) - } - logger = l.WithOptions(zap.AddCallerSkip(1)).Sugar() -} diff --git a/internal/server/dev.go b/internal/server/dev.go index f7ebb82..6fcc93e 100644 --- a/internal/server/dev.go +++ b/internal/server/dev.go @@ -3,37 +3,64 @@ package server import ( "fmt" "io/fs" - "log/slog" "os" + "path" "path/filepath" + "slices" "time" - "website/internal/log" + + "go.alanpearce.eu/x/log" "github.com/fsnotify/fsnotify" - "github.com/pkg/errors" + "gitlab.com/tozd/go/errors" ) type FileWatcher struct { *fsnotify.Watcher } -func NewFileWatcher() (*FileWatcher, error) { +var ( + l *log.Logger + ignores = []string{ + "*.templ", + "*.go", + } + checkSettleInterval = 200 * time.Millisecond +) + +func matches(name string) func(string) bool { + return func(pattern string) bool { + matched, err := path.Match(pattern, name) + if err != nil { + l.Warn("error checking watcher ignores", "error", err) + } + + return matched + } +} + +func ignored(pathname string) bool { + return slices.ContainsFunc(ignores, matches(path.Base(pathname))) +} + +func NewFileWatcher(log *log.Logger) (*FileWatcher, error) { watcher, err := fsnotify.NewWatcher() if err != nil { return nil, errors.WithMessage(err, "could not create watcher") } + l = log return &FileWatcher{watcher}, nil } func (watcher FileWatcher) AddRecursive(from string) error { - log.Debug("walking directory tree", "root", from) + l.Debug("walking directory tree", "root", from) err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error { if err != nil { return errors.WithMessagef(err, "could not walk directory %s", path) } if entry.IsDir() { - log.Debug("adding directory to watcher", "path", path) + l.Debug("adding directory to watcher", "path", path) if err = watcher.Add(path); err != nil { return errors.WithMessagef(err, "could not add directory %s to watcher", path) } @@ -46,26 +73,37 @@ func (watcher FileWatcher) AddRecursive(from string) error { } func (watcher FileWatcher) Start(callback func(string)) { + var timer *time.Timer for { select { case event := <-watcher.Events: - if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) { - f, err := os.Stat(event.Name) - if err != nil { - slog.Error(fmt.Sprintf("error handling %s event: %v", event.Op.String(), err)) - } else if f.IsDir() { - err = watcher.Add(event.Name) + if !ignored(event.Name) { + l.Debug("watcher event", "name", event.Name, "op", event.Op.String()) + if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) { + f, err := os.Stat(event.Name) if err != nil { - slog.Error(fmt.Sprintf("error adding new folder to watcher: %v", err)) + l.Error( + fmt.Sprintf("error handling %s event: %v", event.Op.String(), err), + ) + } else if f.IsDir() { + err = watcher.Add(event.Name) + if err != nil { + l.Error(fmt.Sprintf("error adding new folder to watcher: %v", err)) + } } } - } - if event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) { - callback(event.Name) - time.Sleep(500 * time.Millisecond) + if event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) || + event.Has(fsnotify.Create) || event.Has(fsnotify.Chmod) { + if timer == nil { + timer = time.AfterFunc(checkSettleInterval, func() { + callback(event.Name) + }) + } + timer.Reset(checkSettleInterval) + } } case err := <-watcher.Errors: - slog.Error(fmt.Sprintf("error in watcher: %v", err)) + l.Error("error in watcher", "error", err) } } } diff --git a/internal/server/logging.go b/internal/server/logging.go index a574bcb..f744931 100644 --- a/internal/server/logging.go +++ b/internal/server/logging.go @@ -2,7 +2,8 @@ package server import ( "net/http" - "website/internal/log" + + "go.alanpearce.eu/x/log" ) type LoggingResponseWriter struct { @@ -22,25 +23,18 @@ func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter { return &LoggingResponseWriter{w, http.StatusOK} } -func wrapHandlerWithLogging(wrappedHandler http.Handler) http.Handler { +func wrapHandlerWithLogging(wrappedHandler http.Handler, log *log.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - scheme := r.Header.Get("X-Forwarded-Proto") - if scheme == "" { - scheme = "http" - } - host := r.Host lw := NewLoggingResponseWriter(w) wrappedHandler.ServeHTTP(lw, r) if r.URL.Path == "/health" { return } - statusCode := lw.statusCode log.Info( "http request", - "scheme", scheme, "method", r.Method, - "status", statusCode, - "host", host, + "status", lw.statusCode, + "host", r.Host, "path", r.URL.Path, "location", lw.Header().Get("Location"), ) diff --git a/internal/server/mime.go b/internal/server/mime.go index 696a0ad..cb1b1cf 100644 --- a/internal/server/mime.go +++ b/internal/server/mime.go @@ -2,21 +2,18 @@ package server import ( "mime" - "website/internal/log" + + "go.alanpearce.eu/x/log" ) var newMIMEs = map[string]string{ ".xsl": "text/xsl", } -func fixupMIMETypes() { +func fixupMIMETypes(log *log.Logger) { for ext, newType := range newMIMEs { if err := mime.AddExtensionType(ext, newType); err != nil { log.Error("could not update mime type", "ext", ext, "mime", newType) } } } - -func init() { - fixupMIMETypes() -} diff --git a/internal/server/server.go b/internal/server/server.go index 77905f8..269ed9e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,18 +7,22 @@ import ( "net/http" "net/url" "os" + "path/filepath" + "regexp" "slices" + "strconv" + "strings" "time" - "website/internal/builder" - cfg "website/internal/config" - "website/internal/log" - "website/internal/website" + "go.alanpearce.eu/website/internal/builder" + cfg "go.alanpearce.eu/website/internal/config" + "go.alanpearce.eu/website/internal/vcs" + "go.alanpearce.eu/website/internal/website" + "go.alanpearce.eu/x/log" + "github.com/ardanlabs/conf/v3" "github.com/osdevisnot/sorvor/pkg/livereload" - "github.com/pkg/errors" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "gitlab.com/tozd/go/errors" ) var ( @@ -28,69 +32,129 @@ var ( ) type Config struct { - Development bool `conf:"default:false,flag:dev"` - Root string `conf:"default:website"` + Root string `conf:"default:public"` + Redirect bool `conf:"default:true"` ListenAddress string `conf:"default:localhost"` - Port string `conf:"default:3000,short:p"` + Port int `conf:"default:8080,short:p"` + TLSPort int `conf:"default:8443"` + TLS bool `conf:"default:false"` + + Development bool `conf:"default:false,flag:dev"` + ACMECA string `conf:"env:ACME_CA"` + ACMECACert string `conf:"env:ACME_CA_CERT"` + Domains string } type Server struct { *http.Server + runtimeConfig *Config + config *cfg.Config + log *log.Logger } -func applyDevModeOverrides(config *cfg.Config, listenAddress string) { - config.CSP.StyleSrc = slices.Insert(config.CSP.StyleSrc, 0, "'unsafe-inline'") +func applyDevModeOverrides(config *cfg.Config, runtimeConfig *Config) { config.CSP.ScriptSrc = slices.Insert(config.CSP.ScriptSrc, 0, "'unsafe-inline'") config.CSP.ConnectSrc = slices.Insert(config.CSP.ConnectSrc, 0, "'self'") + if runtimeConfig.Domains != "" { + config.Domains = strings.Split(runtimeConfig.Domains, ",") + } else { + config.Domains = []string{runtimeConfig.ListenAddress} + } + scheme := "http" + port := runtimeConfig.Port + if runtimeConfig.TLS { + scheme = "https" + port = runtimeConfig.TLSPort + } config.BaseURL = cfg.URL{ URL: &url.URL{ - Scheme: "http", - Host: listenAddress, + Scheme: scheme, + Host: net.JoinHostPort(config.Domains[0], strconv.Itoa(port)), }, } } +func updateCSPHashes(config *cfg.Config, r *builder.Result) { + for i, h := range r.Hashes { + config.CSP.StyleSrc[i] = fmt.Sprintf("'%s'", h) + } +} + func serverHeaderHandler(wrappedHandler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.ProtoMajor >= 2 && r.Header.Get("Host") != "" { - // net/http does this for HTTP/1.1, but not h2c - // TODO: check with HTTP/2.0 (i.e. with TLS) - r.Host = r.Header.Get("Host") - r.Header.Del("Host") - } w.Header().Set("Server", serverHeader) wrappedHandler.ServeHTTP(w, r) }) } -func New(runtimeConfig *Config) (*Server, error) { - var err error - config, err := cfg.GetConfig() +func rebuild(builderConfig *builder.IOConfig, config *cfg.Config, log *log.Logger) error { + r, err := builder.BuildSite(builderConfig, config, log.Named("builder")) + if err != nil { + return errors.WithMessage(err, "could not build site") + } + updateCSPHashes(config, r) + + return nil +} + +func New(runtimeConfig *Config, log *log.Logger) (*Server, error) { + builderConfig := &builder.IOConfig{ + Destination: runtimeConfig.Root, + Development: runtimeConfig.Development, + } + + if !runtimeConfig.Development { + vcsConfig := &vcs.Config{} + _, err := conf.Parse("VCS", vcsConfig) + if err != nil { + return nil, err + } + if vcsConfig.LocalPath != "" { + _, err = vcs.CloneOrUpdate(vcsConfig, log.Named("vcs")) + if err != nil { + return nil, err + } + err = os.Chdir(runtimeConfig.Root) + if err != nil { + return nil, err + } + + builderConfig.Source = vcsConfig.LocalPath + + publicDir := filepath.Join(runtimeConfig.Root, "public") + builderConfig.Destination = publicDir + runtimeConfig.Root = publicDir + } else { + log.Warn("in production mode without VCS configuration") + } + } + + config, err := cfg.GetConfig(builderConfig.Source, log.Named("config")) if err != nil { return nil, errors.WithMessage(err, "error parsing configuration file") } + if runtimeConfig.Development { + applyDevModeOverrides(config, runtimeConfig) + } - listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, runtimeConfig.Port) top := http.NewServeMux() - if runtimeConfig.Development { - applyDevModeOverrides(config, listenAddress) - builderConfig := builder.IOConfig{ - Source: "content", - Destination: runtimeConfig.Root, - BaseURL: config.BaseURL, - Development: true, - } - builder.BuildSite(builderConfig) + err = rebuild(builderConfig, config, log) + if err != nil { + return nil, err + } + + fixupMIMETypes(log) + if runtimeConfig.Development { liveReload := livereload.New() top.Handle("/_/reload", liveReload) liveReload.Start() - fw, err := NewFileWatcher() + fw, err := NewFileWatcher(log.Named("watcher")) if err != nil { return nil, errors.WithMessage(err, "could not create file watcher") } - for _, dir := range []string{"content", "static", "templates"} { + for _, dir := range []string{"content", "static", "templates", "internal/builder"} { err := fw.AddRecursive(dir) if err != nil { return nil, errors.WithMessagef( @@ -100,62 +164,84 @@ func New(runtimeConfig *Config) (*Server, error) { ) } } + err = fw.Add(".") + if err != nil { + return nil, errors.WithMessage(err, "could not add directory to file watcher") + } go fw.Start(func(filename string) { - log.Debug("file updated", "filename", filename) - builder.BuildSite(builderConfig) - liveReload.Reload() + log.Info("rebuilding site", "changed_file", filename) + err := rebuild(builderConfig, config, log) + if err != nil { + log.Error("error rebuilding site", "error", err) + } }) } loggingMux := http.NewServeMux() - mux, err := website.NewMux(config, runtimeConfig.Root) + mux, err := website.NewMux(config, runtimeConfig.Root, log.Named("website")) if err != nil { return nil, errors.Wrap(err, "could not create website mux") } - log.Debug("binding main handler to", "host", listenAddress) - hostname := config.BaseURL.Hostname() - - loggingMux.Handle(hostname+"/", mux) - loggingMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - newURL := config.BaseURL.JoinPath(r.URL.String()) - http.Redirect(w, r, newURL.String(), 301) - }) + if runtimeConfig.Redirect { + re := regexp.MustCompile( + "^(.*)\\." + strings.ReplaceAll(config.WildcardDomain, ".", `\.`) + "$", + ) + replace := "${1}." + config.Domains[0] + loggingMux.Handle(config.BaseURL.Hostname()+"/", mux) + loggingMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if slices.Contains(config.Domains, r.Host) { + path, _ := website.CanonicalisePath(r.URL.Path) + newURL := config.BaseURL.JoinPath(path) + http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently) + } else { + url := config.BaseURL + url.Host = re.ReplaceAllString(r.Host, replace) + http.Redirect(w, r, url.String(), http.StatusTemporaryRedirect) + } + }) + } else { + loggingMux.Handle("/", mux) + } - top.Handle("/", - serverHeaderHandler( - wrapHandlerWithLogging(loggingMux), - ), - ) + if runtimeConfig.Development { + top.Handle("/", + serverHeaderHandler( + wrapHandlerWithLogging(loggingMux, log), + ), + ) + } else { + top.Handle("/", serverHeaderHandler(loggingMux)) + } top.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) return &Server{ - &http.Server{ - Addr: listenAddress, - ReadHeaderTimeout: 1 * time.Minute, - Handler: http.MaxBytesHandler(h2c.NewHandler( - top, - &http2.Server{ - IdleTimeout: 15 * time.Minute, - }, - ), 0), + Server: &http.Server{ + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 1 * time.Minute, + WriteTimeout: 2 * time.Minute, + IdleTimeout: 10 * time.Minute, + Handler: top, }, + log: log, + config: config, + runtimeConfig: runtimeConfig, }, nil } -func (s *Server) Start() error { - f := os.NewFile(uintptr(3), "") - l, err := net.FileListener(f) - if err != nil { - l, err = net.Listen("tcp", s.Addr) - if err != nil { - return errors.Wrap(err, "could not create listener") - } +func (s *Server) serve(tls bool) error { + if tls { + return s.serveTLS() } - if err := http.Serve(l, s.Handler); err != http.ErrServerClosed { + + return s.serveTCP() +} + +func (s *Server) Start() error { + if err := s.serve(s.runtimeConfig.TLS); err != http.ErrServerClosed { return errors.Wrap(err, "error creating/closing server") } @@ -163,19 +249,19 @@ func (s *Server) Start() error { } func (s *Server) Stop() chan struct{} { - log.Debug("stop called") + s.log.Debug("stop called") idleConnsClosed := make(chan struct{}) go func() { - log.Debug("shutting down server") + s.log.Debug("shutting down server") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() err := s.Server.Shutdown(ctx) - log.Debug("server shut down") + s.log.Debug("server shut down") if err != nil { // Error from closing listeners, or context timeout: - log.Warn("HTTP server Shutdown", "error", err) + s.log.Warn("HTTP server Shutdown", "error", err) } close(idleConnsClosed) }() diff --git a/internal/server/tcp.go b/internal/server/tcp.go new file mode 100644 index 0000000..1627854 --- /dev/null +++ b/internal/server/tcp.go @@ -0,0 +1,14 @@ +package server + +import ( + "go.alanpearce.eu/x/listenfd" +) + +func (s *Server) serveTCP() error { + l, err := listenfd.GetListener(0, s.Addr, s.log.Named("tcp.listenfd")) + if err != nil { + return err + } + + return s.Serve(l) +} diff --git a/internal/server/tls.go b/internal/server/tls.go new file mode 100644 index 0000000..4d52b8d --- /dev/null +++ b/internal/server/tls.go @@ -0,0 +1,185 @@ +package server + +import ( + "context" + "crypto/x509" + "net" + "net/http" + "strconv" + + "go.alanpearce.eu/x/listenfd" + + "github.com/ardanlabs/conf/v3" + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/certmagic" + "github.com/libdns/acmedns" + certmagic_redis "github.com/pberkel/caddy-storage-redis" + "gitlab.com/tozd/go/errors" +) + +type redisConfig struct { + Address string `conf:"required"` + Username string `conf:"default:default"` + Password string `conf:"required"` + EncryptionKey string `conf:"required"` + KeyPrefix string `conf:"default:certmagic"` +} + +type acmeConfig struct { + Username string `conf:"required"` + Password string `conf:"required"` + Subdomain string `conf:"required"` + ServerURL string `conf:"env:SERVER_URL,default:https://acme.alanpearce.eu"` +} + +func (s *Server) serveTLS() (err error) { + log := s.log.Named("tls") + + // setting cfg.Logger is too late somehow + certmagic.Default.Logger = log.GetLogger().Named("certmagic") + cfg := certmagic.NewDefault() + cfg.DefaultServerName = s.config.Domains[0] + + var issuer *certmagic.ACMEIssuer + + if s.runtimeConfig.Development { + ca := s.runtimeConfig.ACMECA + if ca == "" { + return errors.New("can't enable tls in development without an ACME_CA") + } + + cp, err := x509.SystemCertPool() + if err != nil { + log.Warn("could not get system certificate pool", "error", err) + cp = x509.NewCertPool() + } + + if cacert := s.runtimeConfig.ACMECACert; cacert != "" { + cp.AppendCertsFromPEM([]byte(cacert)) + } + + // caddy's ACME server (step-ca) doesn't specify an OCSP server + cfg.OCSP.DisableStapling = true + + issuer = certmagic.NewACMEIssuer(cfg, certmagic.ACMEIssuer{ + CA: s.runtimeConfig.ACMECA, + TrustedRoots: cp, + DisableTLSALPNChallenge: true, + ListenHost: s.runtimeConfig.ListenAddress, + AltHTTPPort: s.runtimeConfig.Port, + AltTLSALPNPort: s.runtimeConfig.TLSPort, + Logger: certmagic.Default.Logger, + }) + } else { + rc := &redisConfig{} + _, err = conf.Parse("REDIS", rc) + if err != nil { + return errors.Wrap(err, "could not parse redis config") + } + + acme := &acmedns.Provider{} + _, err = conf.Parse("ACME", acme) + if err != nil { + return errors.Wrap(err, "could not parse ACME config") + } + + issuer = certmagic.NewACMEIssuer(cfg, certmagic.ACMEIssuer{ + CA: certmagic.LetsEncryptProductionCA, + Email: s.config.Email, + Agreed: true, + Logger: certmagic.Default.Logger, + DNS01Solver: &certmagic.DNS01Solver{ + DNSManager: certmagic.DNSManager{ + DNSProvider: acme, + Logger: certmagic.Default.Logger, + }, + }, + }) + + log.Info("acme", "username", acme.Username, "subdomain", acme.Subdomain, "server_url", acme.ServerURL) + + rs := certmagic_redis.New() + rs.Address = []string{rc.Address} + rs.Username = rc.Username + rs.Password = rc.Password + rs.EncryptionKey = rc.EncryptionKey + rs.KeyPrefix = rc.KeyPrefix + + cfg.Storage = rs + err = rs.Provision(caddy.Context{ + Context: context.Background(), + }) + if err != nil { + return errors.Wrap(err, "could not provision redis storage") + } + } + cfg.Issuers[0] = issuer + + ln, err := listenfd.GetListener( + 1, + net.JoinHostPort(s.runtimeConfig.ListenAddress, strconv.Itoa(s.runtimeConfig.Port)), + log.Named("listenfd"), + ) + if err != nil { + return errors.Wrap(err, "could not bind plain socket") + } + + go func(ln net.Listener, srv *http.Server) { + httpMux := http.NewServeMux() + httpMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if certmagic.LooksLikeHTTPChallenge(r) && issuer.HandleHTTPChallenge(w, r) { + return + } + url := r.URL + url.Scheme = "https" + port := s.config.BaseURL.Port() + if port == "" { + url.Host = r.Host + } else { + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + log.Warn("error splitting host and port", "error", err) + host = r.Host + } + url.Host = net.JoinHostPort(host, s.config.BaseURL.Port()) + } + http.Redirect(w, r, url.String(), http.StatusMovedPermanently) + }) + srv.Handler = httpMux + + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("error in http handler", "error", err) + } + }(ln, &http.Server{ + ReadHeaderTimeout: s.ReadHeaderTimeout, + ReadTimeout: s.ReadTimeout, + WriteTimeout: s.WriteTimeout, + IdleTimeout: s.IdleTimeout, + }) + + log.Debug( + "starting certmagic", + "http_port", + s.runtimeConfig.Port, + "https_port", + s.runtimeConfig.TLSPort, + ) + err = cfg.ManageAsync(context.TODO(), s.config.Domains) + if err != nil { + return errors.Wrap(err, "could not enable TLS") + } + tlsConfig := cfg.TLSConfig() + tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...) + + sln, err := listenfd.GetListenerTLS( + 0, + net.JoinHostPort(s.runtimeConfig.ListenAddress, strconv.Itoa(s.runtimeConfig.TLSPort)), + tlsConfig, + log.Named("listenfd"), + ) + if err != nil { + return errors.Wrap(err, "could not bind tls socket") + } + + return s.Serve(sln) +} diff --git a/internal/sitemap/sitemap.go b/internal/sitemap/sitemap.go new file mode 100644 index 0000000..b166f73 --- /dev/null +++ b/internal/sitemap/sitemap.go @@ -0,0 +1,36 @@ +package sitemap + +import ( + "io" + "time" + + "go.alanpearce.eu/website/internal/config" + + "github.com/snabb/sitemap" +) + +type Sitemap struct { + config *config.Config + Sitemap *sitemap.Sitemap +} + +func New(cfg *config.Config) *Sitemap { + return &Sitemap{ + config: cfg, + Sitemap: sitemap.New(), + } +} + +func (s *Sitemap) AddPath(path string, lastMod time.Time) { + url := &sitemap.URL{ + Loc: s.config.BaseURL.JoinPath(path).String(), + } + if !lastMod.IsZero() { + url.LastMod = &lastMod + } + s.Sitemap.Add(url) +} + +func (s *Sitemap) WriteTo(w io.Writer) (int64, error) { + return s.Sitemap.WriteTo(w) +} diff --git a/internal/vcs/repository.go b/internal/vcs/repository.go new file mode 100644 index 0000000..5950e53 --- /dev/null +++ b/internal/vcs/repository.go @@ -0,0 +1,123 @@ +package vcs + +import ( + "os" + + "go.alanpearce.eu/website/internal/config" + "go.alanpearce.eu/x/log" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "gitlab.com/tozd/go/errors" +) + +type Config struct { + LocalPath string + RemoteURL config.URL + Branch string `conf:"default:main"` +} + +type Repository struct { + repo *git.Repository + log *log.Logger +} + +func CloneOrUpdate(cfg *Config, log *log.Logger) (*Repository, error) { + gr, err := git.PlainClone(cfg.LocalPath, false, &git.CloneOptions{ + URL: cfg.RemoteURL.String(), + Progress: os.Stdout, + }) + if err != nil { + if !errors.Is(err, git.ErrRepositoryAlreadyExists) { + return nil, err + } + gr, err = git.PlainOpen(cfg.LocalPath) + if err != nil { + return nil, err + } + repo := &Repository{ + repo: gr, + log: log, + } + _, err := repo.Update() + if err != nil { + return nil, err + } + + return repo, nil + } + + return &Repository{ + repo: gr, + log: log, + }, nil +} + +func (r *Repository) Update() (bool, error) { + r.log.Info("updating repository") + + head, err := r.repo.Head() + if err != nil { + return false, err + } + + r.log.Info("updating from", "rev", head.Hash().String()) + err = r.repo.Fetch(&git.FetchOptions{ + Prune: true, + }) + if err != nil { + if errors.Is(err, git.NoErrAlreadyUpToDate) { + r.log.Info("already up-to-date") + + return true, nil + } + + return false, err + } + + rem, err := r.repo.Remote("origin") + if err != nil { + return false, err + } + refs, err := rem.List(&git.ListOptions{ + Timeout: 5, + }) + + var hash plumbing.Hash + for _, ref := range refs { + if ref.Name() == plumbing.Main { + hash = ref.Hash() + } + } + + wt, err := r.repo.Worktree() + if err != nil { + return false, err + } + wt.Checkout(&git.CheckoutOptions{ + Hash: hash, + Force: true, + }) + + r.log.Info("updated to", "rev", hash) + + return true, r.Clean(wt) +} + +func (r *Repository) Clean(wt *git.Worktree) error { + st, err := wt.Status() + if err != nil { + return err + } + + if !st.IsClean() { + err = wt.Clean(&git.CleanOptions{ + Dir: true, + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/website/filemap.go b/internal/website/filemap.go index c657848..64b914f 100644 --- a/internal/website/filemap.go +++ b/internal/website/filemap.go @@ -5,21 +5,32 @@ import ( "hash/fnv" "io" "io/fs" + "mime" "os" "path/filepath" "strings" - "website/internal/log" + "go.alanpearce.eu/x/log" - "github.com/pkg/errors" + "gitlab.com/tozd/go/errors" ) type File struct { - filename string - etag string + contentType string + etag string + alternatives map[string]string } -var files = map[string]File{} +func (f *File) AvailableEncodings() []string { + encs := []string{} + for enc := range f.alternatives { + encs = append(encs, enc) + } + + return encs +} + +var files = map[string]*File{} func hashFile(filename string) (string, error) { f, err := os.Open(filename) @@ -35,25 +46,40 @@ func hashFile(filename string) (string, error) { return fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), nil } -func registerFile(urlpath string, filepath string) error { - if files[urlpath] != (File{}) { - log.Info("registerFile called with duplicate file", "url_path", urlpath) +var encodings = map[string]string{ + "br": ".br", + "gzip": ".gz", +} - return nil - } - hash, err := hashFile(filepath) +func registerFile(urlpath string, fp string) error { + hash, err := hashFile(fp) if err != nil { return err } - files[urlpath] = File{ - filename: filepath, - etag: hash, + f := File{ + contentType: mime.TypeByExtension(filepath.Ext(fp)), + etag: hash, + alternatives: map[string]string{ + "identity": fp, + }, + } + for enc, suffix := range encodings { + _, err := os.Stat(fp + suffix) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + + return err + } + f.alternatives[enc] = fp + suffix } + files[urlpath] = &f return nil } -func registerContentFiles(root string) error { +func registerContentFiles(root string, log *log.Logger) error { err := filepath.WalkDir(root, func(filePath string, f fs.DirEntry, err error) error { if err != nil { return errors.WithMessagef(err, "failed to access path %s", filePath) @@ -62,11 +88,15 @@ func registerContentFiles(root string) error { if err != nil { return errors.WithMessagef(err, "failed to make path relative, path: %s", filePath) } - urlPath, _ := strings.CutSuffix(relPath, "index.html") + urlPath, _ := strings.CutSuffix("/"+relPath, "index.html") if !f.IsDir() { - log.Debug("registering file", "urlpath", "/"+urlPath) + switch filepath.Ext(relPath) { + case ".br", ".gz": + return nil + } + log.Debug("registering file", "urlpath", urlPath) - return registerFile("/"+urlPath, filePath) + return registerFile(urlPath, filePath) } return nil @@ -78,6 +108,6 @@ func registerContentFiles(root string) error { return nil } -func GetFile(urlPath string) File { +func GetFile(urlPath string) *File { return files[urlPath] } diff --git a/internal/website/mux.go b/internal/website/mux.go index 65a7e59..6844551 100644 --- a/internal/website/mux.go +++ b/internal/website/mux.go @@ -3,72 +3,85 @@ package website import ( "encoding/json" "net/http" - "path" "strings" - "website/internal/config" - "website/internal/log" + + "go.alanpearce.eu/website/internal/config" + ihttp "go.alanpearce.eu/website/internal/http" + "go.alanpearce.eu/x/log" + "go.alanpearce.eu/website/templates" "github.com/benpate/digit" - "github.com/pkg/errors" + "github.com/kevinpollet/nego" + "gitlab.com/tozd/go/errors" ) -type HTTPError struct { - Error error - Message string - Code int -} - -func canonicalisePath(path string) (cPath string, differs bool) { +func CanonicalisePath(path string) (cPath string, differs bool) { cPath = path if strings.HasSuffix(path, "/index.html") { cPath, differs = strings.CutSuffix(path, "index.html") - } else if !strings.HasSuffix(path, "/") && files[path+"/"] != (File{}) { + } else if !strings.HasSuffix(path, "/") && files[path+"/"] != nil { cPath, differs = path+"/", true } return cPath, differs } -type webHandler func(http.ResponseWriter, *http.Request) *HTTPError +type webHandler func(http.ResponseWriter, *http.Request) *ihttp.Error + +type WrappedWebHandler struct { + config *config.Config + handler webHandler + log *log.Logger +} + +func wrapHandler(cfg *config.Config, webHandler webHandler, log *log.Logger) WrappedWebHandler { + return WrappedWebHandler{ + config: cfg, + handler: webHandler, + log: log, + } +} -func (fn webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (fn WrappedWebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer func() { if fail := recover(); fail != nil { w.WriteHeader(http.StatusInternalServerError) - log.Error("runtime panic!", "error", fail) + fn.log.Error("runtime panic!", "error", fail) } }() - if err := fn(w, r); err != nil { + if err := fn.handler(w, r); err != nil { if strings.Contains(r.Header.Get("Accept"), "text/html") { w.WriteHeader(err.Code) - notFoundPage := "website/private/404.html" - http.ServeFile(w, r, notFoundPage) + err := templates.Error(fn.config, r.URL.Path, err).Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } else { http.Error(w, err.Message, err.Code) } } } -func NewMux(cfg *config.Config, root string) (mux *http.ServeMux, err error) { +func NewMux(cfg *config.Config, root string, log *log.Logger) (mux *http.ServeMux, err error) { mux = &http.ServeMux{} - prefix := path.Join(root, "public") - log.Debug("registering content files", "prefix", prefix) - err = registerContentFiles(prefix) + log.Debug("registering content files", "root", root) + err = registerContentFiles(root, log) if err != nil { return nil, errors.WithMessagef(err, "registering content files") } + templates.Setup() - mux.Handle("/", webHandler(func(w http.ResponseWriter, r *http.Request) *HTTPError { - urlPath, shouldRedirect := canonicalisePath(r.URL.Path) + mux.Handle("/", wrapHandler(cfg, func(w http.ResponseWriter, r *http.Request) *ihttp.Error { + urlPath, shouldRedirect := CanonicalisePath(r.URL.Path) if shouldRedirect { http.Redirect(w, r, urlPath, 302) return nil } file := GetFile(urlPath) - if file == (File{}) { - return &HTTPError{ + if file == nil { + return &ihttp.Error{ Message: "File not found", Code: http.StatusNotFound, } @@ -79,11 +92,16 @@ func NewMux(cfg *config.Config, root string) (mux *http.ServeMux, err error) { for k, v := range cfg.Extra.Headers { w.Header().Add(k, v) } - - http.ServeFile(w, r, files[urlPath].filename) + enc := nego.NegotiateContentEncoding(r, file.AvailableEncodings()...) + switch enc { + case "br", "gzip": + w.Header().Add("Content-Encoding", enc) + w.Header().Add("Content-Type", file.contentType) + } + http.ServeFile(w, r, files[urlPath].alternatives[enc]) return nil - })) + }, log)) var acctResource = "acct:" + cfg.Email me := digit.NewResource(acctResource). |