Recently Written · git

sbm-webpublish

Publish an sbm bookmark file as a list of links on a web site

git clone https://github.com/equwal/sbm-webpublish

Log | Files | Refs


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 }