// posts serves a Markdown blog: each .md file in CONTENT_DIR is one post. package main import ( "bytes" "html/template" "io/fs" "log" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) type app struct { store *Store config *configFile pages map[string]*template.Template assets map[string]string // static file -> content hash about template.HTML siteURL string // absolute base URL, no trailing slash siteName string commit commitInfo releaseFS fs.FS // pull-releases/ releases []*Release releaseByID map[string]*Release } func newApp(contentDir, siteURL string) (*app, error) { pages, err := loadTemplates() if err != nil { return nil, err } siteURL = strings.TrimRight(siteURL, "/") u, err := url.Parse(siteURL) if err != nil { return nil, err } assets, err := assetHashes() if err != nil { return nil, err } md := newMarkdown() var about bytes.Buffer if err := md.Convert(aboutMarkdown, &about); err != nil { return nil, err } relFS, err := fs.Sub(releaseFS, "pull-releases") if err != nil { return nil, err } releases, byID, err := loadReleases(relFS, md) if err != nil { return nil, err } return &app{ store: NewStore(contentDir, md), config: newConfigFile(filepath.Join(contentDir, "site.json")), pages: pages, assets: assets, about: template.HTML(about.String()), siteURL: siteURL, siteName: u.Host, commit: buildCommit(), releaseFS: relFS, releases: releases, releaseByID: byID, }, nil } func (a *app) routes() http.Handler { static, err := fs.Sub(staticFS, "static") if err != nil { panic(err) } files := http.StripPrefix("/static/", http.FileServerFS(static)) mux := http.NewServeMux() mux.HandleFunc("GET /{$}", a.index) mux.HandleFunc("GET /posts/{slug}", a.post) mux.HandleFunc("GET /about", a.aboutPage) mux.HandleFunc("GET /releases", a.releaseList) mux.HandleFunc("GET /releases/{id}", a.releaseRedirect) mux.HandleFunc("GET /releases/{id}/{$}", a.releasePage) mux.HandleFunc("GET /releases/{id}/{file}", a.releaseFile) mux.HandleFunc("GET /feed.xml", a.feed) mux.HandleFunc("GET /static/", func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/") { // no directory listings a.notFound(w, r) return } // A URL carrying the file's current hash never changes content, so it // can be cached for a year; anything else gets an hour. cache := "public, max-age=3600" if v := r.URL.Query().Get("v"); v != "" && v == a.assets[strings.TrimPrefix(r.URL.Path, "/static/")] { cache = "public, max-age=31536000, immutable" } w.Header().Set("Cache-Control", cache) files.ServeHTTP(w, r) }) mux.HandleFunc("/", a.notFound) return securityHeaders(mux) } func (a *app) index(w http.ResponseWriter, r *http.Request) { idx, err := a.store.Index() if err != nil { a.serverError(w, err) return } a.render(w, http.StatusOK, "index", pageData{Posts: idx.listing}) } func (a *app) post(w http.ResponseWriter, r *http.Request) { idx, err := a.store.Index() if err != nil { a.serverError(w, err) return } i, ok := idx.bySlug[r.PathValue("slug")] if !ok { a.notFound(w, r) return } data := pageData{Post: idx.posts[i]} if i > 0 { data.Newer = idx.posts[i-1] } if i+1 < len(idx.posts) { data.Older = idx.posts[i+1] } a.render(w, http.StatusOK, "post", data) } func (a *app) aboutPage(w http.ResponseWriter, r *http.Request) { idx, err := a.store.Index() if err != nil { a.serverError(w, err) return } var commitURL string if a.commit.Full != "" && !a.commit.Dirty { commitURL = strings.TrimRight(a.config.Get().RepoURL, "/") + "/commit/" + a.commit.Full } a.render(w, http.StatusOK, "about", pageData{ Posts: idx.posts, Releases: a.releases, Version: version, Commit: a.commit, CommitURL: commitURL, About: a.about, }) } func (a *app) notFound(w http.ResponseWriter, r *http.Request) { a.render(w, http.StatusNotFound, "error", pageData{Status: 404, Message: "That page doesn't exist."}) } func (a *app) serverError(w http.ResponseWriter, err error) { log.Printf("error: %v", err) a.render(w, http.StatusInternalServerError, "error", pageData{Status: 500, Message: "Something went wrong."}) } // securityHeaders sets a strict policy: posts never contain scripts, and // images may come from anywhere over HTTPS. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' https: data:; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "strict-origin-when-cross-origin") next.ServeHTTP(w, r) }) } func getenv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func main() { port := os.Getenv("PORT") if port == "" { log.Fatal("PORT not set") } a, err := newApp(getenv("CONTENT_DIR", "content"), getenv("SITE_URL", "https://posts.monoexp.com")) if err != nil { log.Fatal(err) } srv := &http.Server{ Addr: "127.0.0.1:" + port, Handler: a.routes(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute, } log.Printf("listening on %s", srv.Addr) log.Fatal(srv.ListenAndServe()) }