repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

3.6 KB · 125 lines · Go Raw History
  1package main
  2
  3import (
  4	"bytes"
  5	"encoding/xml"
  6	"net/http"
  7	"time"
  8)
  9
 10const feedPath = "/feed.xml"
 11
 12type atomFeed struct {
 13	XMLName  xml.Name    `xml:"feed"`
 14	NS       string      `xml:"xmlns,attr"`
 15	Title    string      `xml:"title"`
 16	Subtitle string      `xml:"subtitle,omitempty"`
 17	ID       string      `xml:"id"`
 18	Updated  string      `xml:"updated"`
 19	Links    []atomLink  `xml:"link"`
 20	Author   atomAuthor  `xml:"author"`
 21	Entries  []atomEntry `xml:"entry"`
 22}
 23
 24type atomLink struct {
 25	Rel  string `xml:"rel,attr,omitempty"`
 26	Type string `xml:"type,attr,omitempty"`
 27	Href string `xml:"href,attr"`
 28}
 29
 30type atomAuthor struct {
 31	Name string `xml:"name"`
 32	URI  string `xml:"uri,omitempty"`
 33}
 34
 35type atomEntry struct {
 36	Title      string         `xml:"title"`
 37	ID         string         `xml:"id"`
 38	Link       atomLink       `xml:"link"`
 39	Published  string         `xml:"published"`
 40	Updated    string         `xml:"updated"`
 41	Summary    string         `xml:"summary,omitempty"`
 42	Categories []atomCategory `xml:"category,omitempty"`
 43	Content    atomContent    `xml:"content"`
 44}
 45
 46type atomCategory struct {
 47	Term string `xml:"term,attr"`
 48}
 49
 50// atomContent is type="html", so a post's raw HTML is escaped into a text node
 51// rather than inlined as XHTML, where it could produce a malformed feed.
 52type atomContent struct {
 53	Type string `xml:"type,attr"`
 54	Body string `xml:",chardata"`
 55}
 56
 57// feedTime turns a YYYY-MM-DD front matter date into Atom's RFC3339 stamp. A
 58// date that will not parse falls back to the epoch, so one bad file does not
 59// fail the whole feed.
 60func feedTime(day string) string {
 61	t, err := time.Parse("2006-01-02", day)
 62	if err != nil {
 63		return time.Unix(0, 0).UTC().Format(time.RFC3339)
 64	}
 65	return t.UTC().Format(time.RFC3339)
 66}
 67
 68func (s *site) feed(w http.ResponseWriter, r *http.Request) {
 69	published, _, _ := s.lib.Published()
 70
 71	feed := atomFeed{
 72		NS:       "http://www.w3.org/2005/Atom",
 73		Title:    siteName,
 74		Subtitle: "Writing about webdev, infrastructure, security, and tooling.",
 75		ID:       baseURL + "/",
 76		Author:   atomAuthor{Name: authorName, URI: baseURL + "/"},
 77		Links: []atomLink{
 78			{Rel: "self", Type: "application/atom+xml", Href: baseURL + feedPath},
 79			{Rel: "alternate", Type: "text/html", Href: baseURL + "/"},
 80		},
 81	}
 82
 83	// Published() is already newest first, and an empty blog gets now, since
 84	// the element is required.
 85	if len(published) > 0 {
 86		feed.Updated = feedTime(published[0].Date)
 87	} else {
 88		feed.Updated = time.Now().UTC().Format(time.RFC3339)
 89	}
 90
 91	for _, post := range published {
 92		entry := atomEntry{
 93			Title:     post.Title,
 94			ID:        baseURL + post.URL(),
 95			Link:      atomLink{Rel: "alternate", Type: "text/html", Href: baseURL + post.URL()},
 96			Published: feedTime(post.PublishDate),
 97			Updated:   feedTime(post.Date),
 98			Summary:   post.Description,
 99			Content:   atomContent{Type: "html", Body: string(post.BodyHTML)},
100		}
101		for _, tag := range post.Tags {
102			entry.Categories = append(entry.Categories, atomCategory{Term: tag})
103		}
104		feed.Entries = append(feed.Entries, entry)
105	}
106
107	var buf bytes.Buffer
108	buf.WriteString(xml.Header)
109	encoder := xml.NewEncoder(&buf)
110	encoder.Indent("", "  ")
111	if err := encoder.Encode(feed); err != nil {
112		http.Error(w, "internal server error", http.StatusInternalServerError)
113		return
114	}
115
116	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
117	w.Header().Set("Cache-Control", "public, max-age=3600")
118	_, _ = buf.WriteTo(w)
119}
120
121// redirectFeed points the names a reader is likely to try at the real one.
122func redirectFeed(w http.ResponseWriter, r *http.Request) {
123	http.Redirect(w, r, feedPath, http.StatusMovedPermanently)
124}