Commit 7e0547cd17

7e0547cd17f0812524e4120a2ac27282896b3f36

parent: b74215186e

Verified · cmc ci/build: success ci/lint: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 13:47 UTC

Make the Atom feed valid

Feed-level id, updated and a self link, which the format requires and
readers reject a feed without. Entry ids are the post's URL rather than
a bare number, timestamps are RFC 3339 with a matching updated, and the
thumbnail element is spelled media:thumbnail.

Closes #17

Layout: unified · split

app/atom_test.go added +107
@@ -0,0 +1,107 @@
1package app
2
3import (
4 "encoding/xml"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/krazywarez/devianter"
12)
13
14// atomFeed is the subset of an Atom document the tests check. Decoding through
15// encoding/xml also proves the output is well-formed XML.
16type atomFeed struct {
17 XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
18 ID string `xml:"id"`
19 Updated string `xml:"updated"`
20 Links []struct {
21 Rel string `xml:"rel,attr"`
22 Href string `xml:"href,attr"`
23 } `xml:"link"`
24 Entries []struct {
25 ID string `xml:"id"`
26 Published string `xml:"published"`
27 Updated string `xml:"updated"`
28 Thumbnail struct {
29 URL string `xml:"url,attr"`
30 } `xml:"http://search.yahoo.com/mrss/ group>thumbnail"`
31 } `xml:"entry"`
32}
33
34func renderFeed(t *testing.T, devs []devianter.Deviation) atomFeed {
35 t.Helper()
36 rec := httptest.NewRecorder()
37 s := skunkyart{Writer: rec, Host: "http://localhost", Atom: true, _pth: "/dd", Args: url.Values{"atom": {"true"}}}
38 s.DeviationList(devs, true)
39
40 var feed atomFeed
41 if err := xml.Unmarshal(rec.Body.Bytes(), &feed); err != nil {
42 t.Fatalf("feed is not well-formed: %v\n%s", err, rec.Body.String())
43 }
44 return feed
45}
46
47func selfLink(f atomFeed) string {
48 for _, l := range f.Links {
49 if l.Rel == "self" {
50 return l.Href
51 }
52 }
53 return ""
54}
55
56// TestAtomFeedHasTheRequiredElements pins what the spec demands and readers
57// reject a feed without: a feed id and updated time, IRI entry ids, RFC 3339
58// timestamps, and the correctly spelled media thumbnail.
59func TestAtomFeedHasTheRequiredElements(t *testing.T) {
60 proxy, nsfw := CFG.Proxy, CFG.Nsfw
61 CFG.Proxy, CFG.Nsfw = true, true
62 defer func() { CFG.Proxy, CFG.Nsfw = proxy, nsfw }()
63
64 d := *fullviewDeviation()
65 d.ID = 123
66 d.Title = "T"
67 d.Author.Username = "alice"
68 d.PublishedTime.Time = time.Date(2026, 8, 11, 18, 36, 34, 0, time.UTC)
69
70 feed := renderFeed(t, []devianter.Deviation{d})
71
72 if !strings.HasPrefix(feed.ID, "http://localhost/dd?") {
73 t.Errorf("feed id %q, want the feed's own URL", feed.ID)
74 }
75 if feed.Updated != "2026-08-11T18:36:34Z" {
76 t.Errorf("feed updated %q, want the newest entry's RFC 3339 time", feed.Updated)
77 }
78 if selfLink(feed) != feed.ID {
79 t.Errorf("self link %q, want %q", selfLink(feed), feed.ID)
80 }
81 if len(feed.Entries) != 1 {
82 t.Fatalf("%d entries, want 1", len(feed.Entries))
83 }
84 e := feed.Entries[0]
85 if !strings.HasPrefix(e.ID, "http://localhost/post/alice/") {
86 t.Errorf("entry id %q, want the post's URL", e.ID)
87 }
88 if e.Published != "2026-08-11T18:36:34Z" || e.Updated != e.Published {
89 t.Errorf("entry published %q updated %q, want RFC 3339 and equal", e.Published, e.Updated)
90 }
91 if e.Thumbnail.URL == "" {
92 t.Error("entry has no media:thumbnail, want one (the element used to be misspelled)")
93 }
94}
95
96// TestAtomFeedIsValidWhenEmpty pins that an empty listing still yields a feed
97// with an updated time, so a reader polling an empty gallery gets a document
98// rather than an error.
99func TestAtomFeedIsValidWhenEmpty(t *testing.T) {
100 feed := renderFeed(t, nil)
101 if feed.ID == "" || feed.Updated == "" {
102 t.Errorf("empty feed id %q updated %q, want both set", feed.ID, feed.Updated)
103 }
104 if _, err := time.Parse(time.RFC3339, feed.Updated); err != nil {
105 t.Errorf("empty feed updated %q is not RFC 3339: %v", feed.Updated, err)
106 }
107}
app/parsers.go +31 −6
@@ -4,6 +4,7 @@ import (
44 "encoding/json"
55 "strconv"
66 "strings"
7 "time"
78
89 "github.com/krazywarez/devianter"
910 "golang.org/x/net/html"
@@ -102,6 +103,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
102103 }
103104
104105 var list, listContent strings.Builder
106 var newest time.Time // the latest entry, for the feed's own <updated>
105107
106108 for i, l := 0, len(devs); i < l; i++ {
107109 data := &devs[i]
@@ -115,21 +117,30 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
115117 if preview, fullview := esc(ParseMedia(s.Host, data.Media, 320)), esc(ParseMedia(s.Host, data.Media)); true {
116118 if allowAtom && s.Atom {
117119 s.Writer.Header().Add("Content-Type", "application/atom+xml")
118 id := strconv.Itoa(data.ID)
120 // The entry id is the post's URL here: Atom wants an IRI, and a
121 // bare number is not one. Timestamps are RFC 3339, the only form
122 // the format allows.
123 entryURL := esc(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+strconv.Itoa(data.ID)))
124 published := data.PublishedTime.UTC()
125 if published.After(newest) {
126 newest = published
127 }
119128 listContent.WriteString(`<entry><author><name>`)
120129 listContent.WriteString(author)
121130 listContent.WriteString(`</name></author><title>`)
122131 listContent.WriteString(title)
123132 listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`)
124 listContent.WriteString(esc(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+id)))
133 listContent.WriteString(entryURL)
125134 listContent.WriteString(`"/><id>`)
126 listContent.WriteString(id)
135 listContent.WriteString(entryURL)
127136 listContent.WriteString(`</id><published>`)
128 listContent.WriteString(data.PublishedTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"))
129 listContent.WriteString(`</published>`)
137 listContent.WriteString(published.Format(time.RFC3339))
138 listContent.WriteString(`</published><updated>`)
139 listContent.WriteString(published.Format(time.RFC3339))
140 listContent.WriteString(`</updated>`)
130141 listContent.WriteString(`<media:group><media:title>`)
131142 listContent.WriteString(title)
132 listContent.WriteString(`</media:title><media:thumbinal url="`)
143 listContent.WriteString(`</media:title><media:thumbnail url="`)
133144 listContent.WriteString(preview)
134145 listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
135146 listContent.WriteString(postURL)
@@ -185,6 +196,20 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
185196 }
186197 list.WriteString(`</title>`)
187198
199 // Atom requires a feed id and updated time; readers reject a feed
200 // without them. The id is the feed's own URL on this instance.
201 feedURL := esc(s.Host + s._pth + "?" + s.Args.Encode())
202 if newest.IsZero() {
203 newest = time.Now().UTC()
204 }
205 list.WriteString(`<id>`)
206 list.WriteString(feedURL)
207 list.WriteString(`</id><updated>`)
208 list.WriteString(newest.Format(time.RFC3339))
209 list.WriteString(`</updated><link rel="self" type="application/atom+xml" href="`)
210 list.WriteString(feedURL)
211 list.WriteString(`"/>`)
212
188213 list.WriteString(`<link rel="alternate" href="`)
189214 list.WriteString(esc(s.Host))
190215 list.WriteString(`"/>`)