orchard
mirrorEvery 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
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}