diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/atom/atom.go | 71 | ||||
-rw-r--r-- | internal/builder/builder.go | 271 | ||||
-rw-r--r-- | internal/builder/files.go | 120 | ||||
-rw-r--r-- | internal/builder/hasher.go | 13 | ||||
-rw-r--r-- | internal/builder/template.go | 172 | ||||
-rw-r--r-- | internal/config/config.go | 72 | ||||
-rw-r--r-- | internal/config/csp.go | 45 | ||||
-rw-r--r-- | internal/config/cspgenerator.go | 82 | ||||
-rw-r--r-- | internal/content/posts.go | 136 | ||||
-rw-r--r-- | internal/http/error.go | 7 | ||||
-rw-r--r-- | internal/server/dev.go | 109 | ||||
-rw-r--r-- | internal/server/logging.go | 42 | ||||
-rw-r--r-- | internal/server/mime.go | 19 | ||||
-rw-r--r-- | internal/server/server.go | 270 | ||||
-rw-r--r-- | internal/server/tcp.go | 14 | ||||
-rw-r--r-- | internal/server/tls.go | 191 | ||||
-rw-r--r-- | internal/sitemap/sitemap.go | 36 | ||||
-rw-r--r-- | internal/vcs/repository.go | 123 | ||||
-rw-r--r-- | internal/website/filemap.go | 113 | ||||
-rw-r--r-- | internal/website/mux.go | 140 |
20 files changed, 2046 insertions, 0 deletions
diff --git a/internal/atom/atom.go b/internal/atom/atom.go new file mode 100644 index 0000000..f75d18a --- /dev/null +++ b/internal/atom/atom.go @@ -0,0 +1,71 @@ +package atom + +import ( + "bytes" + "encoding/xml" + "net/url" + "time" + + "go.alanpearce.eu/website/internal/config" +) + +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,omitempty"` + Type string `xml:"type,attr,omitempty"` + Href string `xml:"href,attr"` +} + +func MakeLink(url *url.URL) Link { + return Link{ + Rel: "alternate", + Type: "text/html", + Href: url.String(), + } +} + +type FeedContent struct { + Content string `xml:",chardata"` + Type string `xml:"type,attr"` +} + +type FeedEntry struct { + XMLName xml.Name `xml:"entry"` + Title string `xml:"title"` + Link Link `xml:"link"` + ID string `xml:"id"` + Updated time.Time `xml:"updated"` + Summary string `xml:"summary,omitempty"` + 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 new file mode 100644 index 0000000..b99d919 --- /dev/null +++ b/internal/builder/builder.go @@ -0,0 +1,271 @@ +package builder + +import ( + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "slices" + "time" + + "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" + + "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: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, 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 := io.Copy(file, output); err != nil { + return errors.WithMessage(err, "could not write output file") + } + + return nil +} + +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") + } + defer file.Close() + + if _, err := writer.WriteTo(file); err != nil { + return errors.WithMessage(err, "could not write output file") + } + + return nil +} + +func joinSourcePath(src string) func(string) string { + return func(rel string) string { + return filepath.Join(src, rel) + } +} + +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 := copyRecursive(joinSource("static"), outDir) + if err != nil { + return nil, errors.WithMessage(err, "could not copy static files") + } + + 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 := content.ReadPosts(&content.Config{ + Root: joinSource("content"), + InputDir: "post", + OutputDir: outDir, + }, log.Named("content")) + if err != nil { + return nil, err + } + + sitemap := sitemap.New(config) + lastMod := time.Now() + if len(posts) > 0 { + lastMod = posts[0].Date + } + + for _, post := range posts { + 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) + sitemap.AddPath(post.URL, post.Date) + if err := renderToFile(templates.PostPage(config, post), post.Output); err != nil { + return nil, err + } + } + + if err := mkdirp(outDir, "tags"); err != nil { + return nil, errors.WithMessage(err, "could not create directory for tags") + } + log.Debug("rendering tags list") + if err := renderToFile( + templates.TagsPage(config, "tags", mapset.Sorted(tags), "/tags"), + outDir, + "tags", + "index.html", + ); err != nil { + return nil, err + } + sitemap.AddPath("/tags/", lastMod) + + for _, tag := range tags.ToSlice() { + matchingPosts := []content.Post{} + for _, post := range posts { + if slices.Contains(post.Taxonomies.Tags, tag) { + matchingPosts = append(matchingPosts, post) + } + } + 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 + if err := renderToFile( + templates.TagPage(config, tag, matchingPosts, url), + outDir, + "tags", + tag, + "index.html", + ); err != nil { + return nil, err + } + sitemap.AddPath(url, matchingPosts[0].Date) + + log.Debug("rendering tags feed", "tag", tag) + feed, err := renderFeed( + fmt.Sprintf("%s - %s", config.Title, tag), + config, + matchingPosts, + tag, + ) + if err != nil { + return nil, errors.WithMessage(err, "could not render tag feed page") + } + if err := writerToFile(feed, outDir, "tags", tag, "atom.xml"); err != nil { + return nil, err + } + } + + log.Debug("rendering list page") + if err := renderToFile(templates.ListPage(config, posts, "/post"), outDir, "post", "index.html"); err != nil { + return nil, err + } + sitemap.AddPath("/post/", lastMod) + + log.Debug("rendering feed") + feed, err := renderFeed(config.Title, config, posts, "feed") + if err != nil { + return nil, errors.WithMessage(err, "could not render feed") + } + if err := writerToFile(feed, outDir, "atom.xml"); err != nil { + return nil, err + } + + log.Debug("rendering feed styles") + 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 nil, err + } + h, err := getFeedStylesHash(feedStyles) + if err != nil { + return nil, err + } + r.Hashes = append(r.Hashes, h) + + log.Debug("rendering homepage") + _, 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 nil, 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 + sitemap.AddPath("/", time.Time{}) + h, _ = getHTMLStyleHash(outDir, "index.html") + r.Hashes = append(r.Hashes, h) + + log.Debug("rendering sitemap") + if err := writerToFile(sitemap, outDir, "sitemap.xml"); err != nil { + return nil, err + } + + log.Debug("rendering robots.txt") + rob, err := renderRobotsTXT(ioConfig.Source, config) + if err != nil { + return nil, err + } + if err := outputToFile(rob, outDir, "robots.txt"); err != nil { + return nil, err + } + + return r, nil +} + +func BuildSite(ioConfig *IOConfig, cfg *config.Config, log *log.Logger) (*Result, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + cfg.InjectLiveReload = ioConfig.Development + compressFiles = !ioConfig.Development + + templates.Setup() + loadCSS(ioConfig.Source) + + 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/template.go b/internal/builder/template.go new file mode 100644 index 0000000..9f019df --- /dev/null +++ b/internal/builder/template.go @@ -0,0 +1,172 @@ +package builder + +import ( + "bytes" + "encoding/xml" + "io" + "os" + "path/filepath" + "strings" + "text/template" + + "go.alanpearce.eu/website/internal/atom" + "go.alanpearce.eu/website/internal/config" + "go.alanpearce.eu/website/internal/content" + + "github.com/PuerkitoBio/goquery" + "github.com/antchfx/xmlquery" + "github.com/antchfx/xpath" + "gitlab.com/tozd/go/errors" +) + +var ( + 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 loadCSS(source string) { + bytes, err := os.ReadFile(filepath.Join(source, "templates/style.css")) + if err != nil { + panic(err) + } + css = string(bytes) +} + +type QuerySelection struct { + *goquery.Selection +} + +type QueryDocument struct { + *goquery.Document +} + +func NewDocumentFromReader(r io.Reader) (*QueryDocument, error) { + doc, err := goquery.NewDocumentFromReader(r) + + return &QueryDocument{doc}, errors.Wrap(err, "could not create query document") +} + +func (q *QueryDocument) Find(selector string) *QuerySelection { + return &QuerySelection{q.Document.Find(selector)} +} + +func renderRobotsTXT(source string, config *config.Config) (io.Reader, error) { + r, w := io.Pipe() + tpl, err := template.ParseFiles(filepath.Join(source, "templates/robots.tmpl")) + if err != nil { + return nil, err + } + go func() { + err = tpl.Execute(w, map[string]interface{}{ + "BaseURL": config.BaseURL, + }) + if err != nil { + w.CloseWithError(err) + } + w.Close() + }() + + return r, nil +} + +func renderFeed( + title string, + config *config.Config, + posts []content.Post, + specific string, +) (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 + } + feed := &atom.Feed{ + Title: title, + Link: atom.MakeLink(config.BaseURL.URL), + ID: atom.MakeTagURI(config, specific), + Updated: datetime, + Entries: make([]*atom.FeedEntry, len(posts)), + } + + for i, post := range posts { + feed.Entries[i] = &atom.FeedEntry{ + Title: post.Title, + Link: atom.MakeLink(config.BaseURL.JoinPath(post.URL)), + ID: atom.MakeTagURI(config, post.Basename), + Updated: post.Date.UTC(), + Summary: post.Description, + Author: config.Title, + Content: atom.FeedContent{ + Content: post.Content, + Type: "html", + }, + } + } + enc := xml.NewEncoder(buf) + err = enc.Encode(feed) + if err != nil { + return nil, err + } + + return buf, nil +} + +func renderFeedStyles(source string) (*strings.Reader, error) { + tpl, err := template.ParseFiles(filepath.Join(source, "templates/feed-styles.xsl")) + if err != nil { + return nil, err + } + + esc := &strings.Builder{} + err = xml.EscapeText(esc, []byte(css)) + if err != nil { + return nil, err + } + + 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 "", err + } + expr, err := xpath.CompileWithNS("//xhtml:style", nsMap) + if err != nil { + return "", errors.Wrap(err, "could not parse XPath") + } + style := xmlquery.QuerySelector(doc, expr) + + return hash(style.InnerText()), nil +} + +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 hash(html), nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7ccad85 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,72 @@ +package config + +import ( + "io/fs" + "net/url" + "path/filepath" + + "go.alanpearce.eu/x/log" + + "github.com/BurntSushi/toml" + "gitlab.com/tozd/go/errors" +) + +type Taxonomy struct { + Name string + Feed bool +} + +type MenuItem struct { + Name string + URL URL `toml:"url"` +} + +type URL struct { + *url.URL +} + +func (u *URL) UnmarshalText(text []byte) (err error) { + u.URL, err = url.Parse(string(text)) + + return errors.Wrapf(err, "could not parse URL %s", string(text)) +} + +type Config struct { + DefaultLanguage string `toml:"default_language"` + BaseURL URL `toml:"base_url"` + InjectLiveReload bool + Title string + Email string + 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"` + Extra struct { + Headers map[string]string + } + Menus map[string][]MenuItem +} + +func GetConfig(dir string, log *log.Logger) (*Config, error) { + config := 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: + return nil, errors.WithMessage(t, "could not read configuration") + case *toml.ParseError: + return nil, errors.WithMessage(t, t.ErrorWithUsage()) + } + + return nil, errors.Wrap(err, "config error") + } + + return &config, nil +} diff --git a/internal/config/csp.go b/internal/config/csp.go new file mode 100644 index 0000000..970663c --- /dev/null +++ b/internal/config/csp.go @@ -0,0 +1,45 @@ +package config + +// Code generated DO NOT EDIT. + +import ( + "github.com/crewjam/csp" +) + +type CSP struct { + BaseURI []string `csp:"base-uri" toml:"base-uri"` + BlockAllMixedContent bool `csp:"block-all-mixed-content" toml:"block-all-mixed-content"` + ChildSrc []string `csp:"child-src" toml:"child-src"` + ConnectSrc []string `csp:"connect-src" toml:"connect-src"` + DefaultSrc []string `csp:"default-src" toml:"default-src"` + FontSrc []string `csp:"font-src" toml:"font-src"` + FormAction []string `csp:"form-action" toml:"form-action"` + FrameAncestors []string `csp:"frame-ancestors" toml:"frame-ancestors"` + FrameSrc []string `csp:"frame-src" toml:"frame-src"` + ImgSrc []string `csp:"img-src" toml:"img-src"` + ManifestSrc []string `csp:"manifest-src" toml:"manifest-src"` + MediaSrc []string `csp:"media-src" toml:"media-src"` + NavigateTo []string `csp:"navigate-to" toml:"navigate-to"` + ObjectSrc []string `csp:"object-src" toml:"object-src"` + PluginTypes []string `csp:"plugin-types" toml:"plugin-types"` + PrefetchSrc []string `csp:"prefetch-src" toml:"prefetch-src"` + Referrer csp.ReferrerPolicy `csp:"referrer" toml:"referrer"` + ReportTo string `csp:"report-to" toml:"report-to"` + ReportURI string `csp:"report-uri" toml:"report-uri"` + RequireSRIFor []csp.RequireSRIFor `csp:"require-sri-for" toml:"require-sri-for"` + RequireTrustedTypesFor []csp.RequireTrustedTypesFor `csp:"require-trusted-types-for" toml:"require-trusted-types-for"` + Sandbox csp.Sandbox `csp:"sandbox" toml:"sandbox"` + ScriptSrc []string `csp:"script-src" toml:"script-src"` + ScriptSrcAttr []string `csp:"script-src-attr" toml:"script-src-attr"` + ScriptSrcElem []string `csp:"script-src-elem" toml:"script-src-elem"` + StyleSrc []string `csp:"style-src" toml:"style-src"` + StyleSrcAttr []string `csp:"style-src-attr" toml:"style-src-attr"` + StyleSrcElem []string `csp:"style-src-elem" toml:"style-src-elem"` + TrustedTypes []string `csp:"trusted-types" toml:"trusted-types"` + UpgradeInsecureRequests bool `csp:"upgrade-insecure-requests" toml:"upgrade-insecure-requests"` + WorkerSrc []string `csp:"worker-src" toml:"worker-src"` +} + +func (c *CSP) String() string { + return csp.Header(*c).String() +} diff --git a/internal/config/cspgenerator.go b/internal/config/cspgenerator.go new file mode 100644 index 0000000..9974819 --- /dev/null +++ b/internal/config/cspgenerator.go @@ -0,0 +1,82 @@ +package config + +//go:generate go run ../../cmd/cspgenerator/ + +import ( + "fmt" + "os" + "reflect" + + "github.com/crewjam/csp" + "github.com/fatih/structtag" + "gitlab.com/tozd/go/errors" +) + +func GenerateCSP() error { + t := reflect.TypeFor[csp.Header]() + file, err := os.OpenFile("./csp.go", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0) + if err != nil { + + return errors.Wrap(err, "could not write to output") + } + defer file.Close() + + _, err = fmt.Fprintf(file, `package config + +// Code generated DO NOT EDIT. + +import ( + "github.com/crewjam/csp" +) + +`) + if err != nil { + + return errors.Wrap(err, "could not write to output") + } + + _, err = fmt.Fprintf(file, "type CSP struct {\n") + if err != nil { + return errors.Wrap(err, "could not write to output") + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + var t reflect.Type + t = field.Type + tags, err := structtag.Parse(string(field.Tag)) + if err != nil { + return errors.Wrap(err, "could not write to output") + } + cspTag, err := tags.Get("csp") + if err != nil { + return errors.Wrap(err, "could not get csp tag") + } + err = tags.Set(&structtag.Tag{ + Key: "toml", + Name: cspTag.Name, + }) + if err != nil { + return errors.Wrap(err, "could not set toml tag") + } + + _, err = fmt.Fprintf(file, "\t%-23s %-28s `%s`\n", field.Name, t, tags.String()) + if err != nil { + return errors.Wrap(err, "could not write to output") + } + } + _, err = fmt.Fprintln(file, "}") + if err != nil { + return errors.Wrap(err, "could not write to output") + } + + _, err = fmt.Fprintln(file, ` +func (c *CSP) String() string { + return csp.Header(*c).String() +}`) + if err != nil { + return errors.Wrap(err, "could not write to output") + } + + return nil +} diff --git a/internal/content/posts.go b/internal/content/posts.go new file mode 100644 index 0000000..f4c6c76 --- /dev/null +++ b/internal/content/posts.go @@ -0,0 +1,136 @@ +package content + +import ( + "bytes" + "os" + "path" + "path/filepath" + "slices" + "strings" + "time" + + "go.alanpearce.eu/x/log" + + "github.com/adrg/frontmatter" + mapset "github.com/deckarep/golang-set/v2" + 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 { + Date time.Time `toml:"date"` + Description string `toml:"description"` + Title string `toml:"title"` + Taxonomies struct { + Tags []string `toml:"tags"` + } `toml:"taxonomies"` +} + +type Post struct { + Input string + Output string + Basename string + URL string + Content string + PostMatter +} + +type Tags mapset.Set[string] + +var markdown = goldmark.New( + goldmark.WithRendererOptions( + htmlrenderer.WithUnsafe(), + ), + goldmark.WithExtensions( + extension.GFM, + extension.Footnote, + extension.Typographer, + &fences.Extender{}, + ), +) + +func GetPost(filename string) (*PostMatter, []byte, error) { + matter := PostMatter{} + content, err := os.Open(filename) + if err != nil { + return nil, nil, errors.WithMessagef(err, "could not open post %s", filename) + } + defer content.Close() + rest, err := frontmatter.MustParse(content, &matter) + if err != nil { + return nil, nil, errors.WithMessagef( + err, + "could not parse front matter of post %s", + filename, + ) + } + + return &matter, rest, nil +} + +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") + } + + return buf.String(), nil +} + +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(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(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) + if err != nil { + return nil, nil, err + } + + for _, tag := range matter.Taxonomies.Tags { + tags.Add(strings.ToLower(tag)) + } + + log.Debug("rendering markdown in post", "post", pathFromRoot) + html, err := RenderMarkdown(content) + if err != nil { + return nil, nil, err + } + post := Post{ + Input: pathFromRoot, + Output: output, + Basename: filepath.Base(url), + URL: url, + PostMatter: *matter, + Content: html, + } + + posts = append(posts, post) + } + } + slices.SortFunc(posts, func(a, b Post) int { + return b.Date.Compare(a.Date) + }) + + return posts, tags, nil +} 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/server/dev.go b/internal/server/dev.go new file mode 100644 index 0000000..6fcc93e --- /dev/null +++ b/internal/server/dev.go @@ -0,0 +1,109 @@ +package server + +import ( + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "time" + + "go.alanpearce.eu/x/log" + + "github.com/fsnotify/fsnotify" + "gitlab.com/tozd/go/errors" +) + +type FileWatcher struct { + *fsnotify.Watcher +} + +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 { + 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() { + 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) + } + } + + return nil + }) + + return errors.WithMessage(err, "error walking directory tree") +} + +func (watcher FileWatcher) Start(callback func(string)) { + var timer *time.Timer + for { + select { + case event := <-watcher.Events: + 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 { + 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) || + 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: + l.Error("error in watcher", "error", err) + } + } +} diff --git a/internal/server/logging.go b/internal/server/logging.go new file mode 100644 index 0000000..f744931 --- /dev/null +++ b/internal/server/logging.go @@ -0,0 +1,42 @@ +package server + +import ( + "net/http" + + "go.alanpearce.eu/x/log" +) + +type LoggingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (lrw *LoggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + // avoids warning: superfluous response.WriteHeader call + if lrw.statusCode != http.StatusOK { + lrw.ResponseWriter.WriteHeader(code) + } +} + +func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter { + return &LoggingResponseWriter{w, http.StatusOK} +} + +func wrapHandlerWithLogging(wrappedHandler http.Handler, log *log.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lw := NewLoggingResponseWriter(w) + wrappedHandler.ServeHTTP(lw, r) + if r.URL.Path == "/health" { + return + } + log.Info( + "http request", + "method", r.Method, + "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 new file mode 100644 index 0000000..cb1b1cf --- /dev/null +++ b/internal/server/mime.go @@ -0,0 +1,19 @@ +package server + +import ( + "mime" + + "go.alanpearce.eu/x/log" +) + +var newMIMEs = map[string]string{ + ".xsl": "text/xsl", +} + +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) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..269ed9e --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,270 @@ +package server + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "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" + "gitlab.com/tozd/go/errors" +) + +var ( + CommitSHA = "local" + ShortSHA = "local" + serverHeader = fmt.Sprintf("website (%s)", ShortSHA) +) + +type Config struct { + Root string `conf:"default:public"` + Redirect bool `conf:"default:true"` + ListenAddress string `conf:"default:localhost"` + 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, 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: 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) { + w.Header().Set("Server", serverHeader) + wrappedHandler.ServeHTTP(w, r) + }) +} + +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) + } + + top := http.NewServeMux() + + 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(log.Named("watcher")) + if err != nil { + return nil, errors.WithMessage(err, "could not create file watcher") + } + for _, dir := range []string{"content", "static", "templates", "internal/builder"} { + err := fw.AddRecursive(dir) + if err != nil { + return nil, errors.WithMessagef( + err, + "could not add directory %s to file watcher", + dir, + ) + } + } + 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.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, log.Named("website")) + if err != nil { + return nil, errors.Wrap(err, "could not create website mux") + } + + 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) + } + + 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{ + 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) serve(tls bool) error { + if tls { + return s.serveTLS() + } + + 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") + } + + return nil +} + +func (s *Server) Stop() chan struct{} { + s.log.Debug("stop called") + + idleConnsClosed := make(chan struct{}) + + go func() { + s.log.Debug("shutting down server") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err := s.Server.Shutdown(ctx) + s.log.Debug("server shut down") + if err != nil { + // Error from closing listeners, or context timeout: + s.log.Warn("HTTP server Shutdown", "error", err) + } + close(idleConnsClosed) + }() + + return 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..9f22a5e --- /dev/null +++ b/internal/server/tls.go @@ -0,0 +1,191 @@ +package server + +import ( + "context" + "crypto/x509" + "net" + "net/http" + "slices" + "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") + + wildcardDomain := "*." + s.config.WildcardDomain + certificateDomains := slices.Clone(s.config.Domains) + + // 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, + }, + }, + }) + + certificateDomains = append(slices.Clone(s.config.Domains), wildcardDomain) + + 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(), certificateDomains) + 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 new file mode 100644 index 0000000..64b914f --- /dev/null +++ b/internal/website/filemap.go @@ -0,0 +1,113 @@ +package website + +import ( + "fmt" + "hash/fnv" + "io" + "io/fs" + "mime" + "os" + "path/filepath" + "strings" + + "go.alanpearce.eu/x/log" + + "gitlab.com/tozd/go/errors" +) + +type File struct { + contentType string + etag string + alternatives map[string]string +} + +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) + if err != nil { + return "", errors.Wrapf(err, "could not open file %s for hashing", filename) + } + defer f.Close() + hash := fnv.New64a() + if _, err := io.Copy(hash, f); err != nil { + return "", errors.Wrapf(err, "could not hash file %s", filename) + } + + return fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), nil +} + +var encodings = map[string]string{ + "br": ".br", + "gzip": ".gz", +} + +func registerFile(urlpath string, fp string) error { + hash, err := hashFile(fp) + if err != nil { + return err + } + 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, 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) + } + relPath, err := filepath.Rel(root, filePath) + if err != nil { + return errors.WithMessagef(err, "failed to make path relative, path: %s", filePath) + } + urlPath, _ := strings.CutSuffix("/"+relPath, "index.html") + if !f.IsDir() { + switch filepath.Ext(relPath) { + case ".br", ".gz": + return nil + } + log.Debug("registering file", "urlpath", urlPath) + + return registerFile(urlPath, filePath) + } + + return nil + }) + if err != nil { + return errors.Wrap(err, "could not walk directory") + } + + return nil +} + +func GetFile(urlPath string) *File { + return files[urlPath] +} diff --git a/internal/website/mux.go b/internal/website/mux.go new file mode 100644 index 0000000..6844551 --- /dev/null +++ b/internal/website/mux.go @@ -0,0 +1,140 @@ +package website + +import ( + "encoding/json" + "net/http" + "strings" + + "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/kevinpollet/nego" + "gitlab.com/tozd/go/errors" +) + +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+"/"] != nil { + cPath, differs = path+"/", true + } + + return cPath, differs +} + +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 WrappedWebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer func() { + if fail := recover(); fail != nil { + w.WriteHeader(http.StatusInternalServerError) + fn.log.Error("runtime panic!", "error", fail) + } + }() + if err := fn.handler(w, r); err != nil { + if strings.Contains(r.Header.Get("Accept"), "text/html") { + w.WriteHeader(err.Code) + 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, log *log.Logger) (mux *http.ServeMux, err error) { + mux = &http.ServeMux{} + + 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("/", 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 == nil { + return &ihttp.Error{ + Message: "File not found", + Code: http.StatusNotFound, + } + } + w.Header().Add("ETag", file.etag) + w.Header().Add("Vary", "Accept-Encoding") + w.Header().Add("Content-Security-Policy", cfg.CSP.String()) + for k, v := range cfg.Extra.Headers { + w.Header().Add(k, v) + } + 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). + Link("http://openid.net/specs/connect/1.0/issuer", "", cfg.OIDCHost.String()) + + mux.HandleFunc("/.well-known/webfinger", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("resource") == acctResource { + obj, err := json.Marshal(me) + if err != nil { + http.Error( + w, + http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError, + ) + + return + } + + w.Header().Add("Content-Type", "application/jrd+json") + w.Header().Add("Access-Control-Allow-Origin", "*") + _, err = w.Write(obj) + if err != nil { + log.Warn("error writing webfinger request", "error", err) + } + } + }) + const oidcPath = "/.well-known/openid-configuration" + mux.HandleFunc( + oidcPath, + func(w http.ResponseWriter, r *http.Request) { + u := cfg.OIDCHost.JoinPath(oidcPath) + http.Redirect(w, r, u.String(), 302) + }) + + return mux, nil +} |