public.go (5918 bytes)
1 package main 2 3 // The public outputs. The server makes each one from the links file at each 4 // request, and puts in only the links with an http or https address: 5 // 6 // /links/links.txt the links as sbm lines, for cut, awk, dmenu and bm 7 // /links/atom.xml an Atom feed, for sfeed and other feed readers 8 // /links/list.html the links as HTML, for a page of the site to include 9 10 import ( 11 "bytes" 12 "encoding/xml" 13 "html/template" 14 "log" 15 "maps" 16 "net/http" 17 "slices" 18 "strings" 19 "time" 20 ) 21 22 // published gives the links of text that the outputs show, in the order of 23 // the file. The URL of each link is the address that Href gives, and the 24 // feed is the address that FeedHref gives. 25 func published(text string) []Link { 26 var out []Link 27 for _, l := range parse(text) { 28 if l.URL, l.Feed = l.Href(), l.FeedHref(); l.URL != "" { 29 out = append(out, l) 30 } 31 } 32 return out 33 } 34 35 // serve sends the output that render makes from the links. The time of the 36 // file is the Last-Modified time, so that a client can send 37 // If-Modified-Since and get 304 Not Modified. 38 func (s *server) serve(w http.ResponseWriter, r *http.Request, contentType string, render func([]Link, time.Time) []byte) { 39 text, mod, err := s.links.read() 40 if err != nil { 41 log.Print(err) 42 http.Error(w, "cannot read the links", http.StatusInternalServerError) 43 return 44 } 45 w.Header().Set("Content-Type", contentType) 46 http.ServeContent(w, r, "", mod, bytes.NewReader(render(published(text), mod))) 47 } 48 49 // plain sends the links as sbm lines, oldest first: URL, description, tags 50 // and feed, with a tab between them. A link without a feed has no fourth 51 // field. 52 func (s *server) plain(w http.ResponseWriter, r *http.Request) { 53 s.serve(w, r, "text/plain; charset=utf-8", func(ls []Link, _ time.Time) []byte { 54 var b strings.Builder 55 for _, l := range ls { 56 b.WriteString(line(l.URL, l.Desc, strings.Join(l.Tags, " "), l.Feed) + "\n") 57 } 58 return []byte(b.String()) 59 }) 60 } 61 62 // The Atom feed (RFC 4287). 63 type atomFeed struct { 64 XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"` 65 Title string `xml:"title"` 66 ID string `xml:"id"` 67 Link atomLink `xml:"link"` 68 Updated string `xml:"updated"` 69 Author string `xml:"author>name"` 70 Entries []atomEntry `xml:"entry"` 71 } 72 73 type atomEntry struct { 74 Title string `xml:"title"` 75 Link atomLink `xml:"link"` 76 ID string `xml:"id"` 77 Updated string `xml:"updated"` 78 Categories []atomCategory `xml:"category"` 79 } 80 81 type atomLink struct { 82 Rel string `xml:"rel,attr,omitempty"` 83 Href string `xml:"href,attr"` 84 } 85 86 type atomCategory struct { 87 Term string `xml:"term,attr"` 88 } 89 90 func atomTime(t time.Time) string { 91 return t.UTC().Format(time.RFC3339) 92 } 93 94 // proto gives the scheme that the client used. Behind nginx, 95 // X-Forwarded-Proto gives it. 96 func proto(r *http.Request) string { 97 if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 98 return "https" 99 } 100 return "http" 101 } 102 103 // feed sends the links as an Atom feed, newest first. The time of a link is 104 // the time of its date line. A link without a date line gets the time of the 105 // file. Each tag is a category. The feed gets its address and name from the 106 // request, so it needs no settings. 107 func (s *server) feed(w http.ResponseWriter, r *http.Request) { 108 s.serve(w, r, "application/atom+xml; charset=utf-8", func(ls []Link, mod time.Time) []byte { 109 self := proto(r) + "://" + r.Host + r.URL.Path 110 f := atomFeed{ 111 Title: r.Host + " links", 112 ID: self, 113 Link: atomLink{Rel: "self", Href: self}, 114 Updated: atomTime(mod), 115 Author: r.Host, 116 } 117 for _, l := range slices.Backward(ls) { 118 t := l.Added 119 if t.IsZero() { 120 t = mod 121 } 122 e := atomEntry{Title: l.Title(), Link: atomLink{Href: l.URL}, ID: l.URL, Updated: atomTime(t)} 123 for _, tag := range l.Tags { 124 e.Categories = append(e.Categories, atomCategory{tag}) 125 } 126 f.Entries = append(f.Entries, e) 127 } 128 // Marshal fails only for types that XML cannot hold, and the feed 129 // has only strings. It writes U+FFFD for each character that XML 130 // cannot hold. 131 b, _ := xml.MarshalIndent(f, "", "\t") 132 return append([]byte(xml.Header), append(b, '\n')...) 133 }) 134 } 135 136 // group is the links of one tag, for list.html. 137 type group struct { 138 Tag string 139 Links []Link 140 } 141 142 // groups puts each link under each of its tags, newest link first. The tags 143 // are in alphabetical order. The links without tags come last, under 144 // "untagged". 145 func groups(ls []Link) []group { 146 byTag := map[string][]Link{} 147 for _, l := range slices.Backward(ls) { 148 tags := l.Tags 149 if len(tags) == 0 { 150 tags = []string{""} 151 } 152 for _, t := range slices.Compact(slices.Sorted(slices.Values(tags))) { 153 byTag[t] = append(byTag[t], l) 154 } 155 } 156 var out []group 157 for _, t := range slices.Sorted(maps.Keys(byTag)) { 158 if t != "" { 159 out = append(out, group{t, byTag[t]}) 160 } 161 } 162 if untagged := byTag[""]; untagged != nil { 163 out = append(out, group{"untagged", untagged}) 164 } 165 return out 166 } 167 168 var listTmpl = template.Must(template.New("list").Parse(`{{with .}}<p>{{range .}}<a href="#{{.Tag}}">{{.Tag}}</a> {{end}}</p> 169 {{range .}}<h2 id="{{.Tag}}">{{.Tag}}</h2> 170 <ul> 171 {{range .Links}}<li><a href="{{.URL}}">{{.Title}}</a> <small>{{.Address}}</small>{{with .Feed}} <a class="feed" href="{{.}}">rss</a>{{end}}</li> 172 {{end}}</ul> 173 {{end}}{{else}}<p>No links yet.</p> 174 {{end}}`)) 175 176 // list sends the links as an HTML fragment, under each of their tags. A link 177 // with a feed has an "rss" link to the feed after it. A page of the site 178 // includes the fragment with nginx SSI, so the page needs no script: 179 // 180 // <!--# include virtual="/links/list.html" --> 181 func (s *server) list(w http.ResponseWriter, r *http.Request) { 182 s.serve(w, r, "text/html; charset=utf-8", func(ls []Link, _ time.Time) []byte { 183 var b bytes.Buffer 184 if err := listTmpl.Execute(&b, groups(ls)); err != nil { 185 log.Print(err) 186 } 187 return b.Bytes() 188 }) 189 }