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


main.go (9338 bytes)

1 // sbm-webpublish publishes an sbm bookmark file as a list of links on a
2 // web site. Everyone can get the links as sbm lines, as an Atom feed and as
3 // HTML (see public.go). The owner adds, deletes and imports links on
4 // /links/admin, or adds links in a shell on the server:
5 //
6 //	sbm-webpublish merge < bookmarks
7 package main
8 
9 import (
10 	"crypto/subtle"
11 	_ "embed"
12 	"errors"
13 	"fmt"
14 	"html/template"
15 	"io"
16 	"io/fs"
17 	"log"
18 	"net/http"
19 	"os"
20 	"path/filepath"
21 	"slices"
22 	"strconv"
23 	"sync"
24 	"time"
25 )
26 
27 //go:embed admin.html
28 var adminHTML string
29 
30 var adminTmpl = template.Must(template.New("admin").Parse(adminHTML))
31 
32 // maxImport is the largest file that the import form takes.
33 const maxImport = 10 << 20
34 
35 // store keeps the links file. One mutex puts the changes in a sequence.
36 type store struct {
37 	mu   sync.Mutex
38 	path string
39 }
40 
41 // read gives the text of the file and the time of its last change. A
42 // missing file is an empty file.
43 func (s *store) read() (string, time.Time, error) {
44 	f, err := os.Open(s.path)
45 	if errors.Is(err, fs.ErrNotExist) {
46 		return "", time.Time{}, nil
47 	}
48 	if err != nil {
49 		return "", time.Time{}, err
50 	}
51 	defer f.Close()
52 	// The time comes from the open file and not from its name, so that the
53 	// time and the text are of the same version of the file.
54 	st, err := f.Stat()
55 	if err != nil {
56 		return "", time.Time{}, err
57 	}
58 	b, err := io.ReadAll(f)
59 	return string(b), st.ModTime(), err
60 }
61 
62 // change reads the file, gives its text to f, and writes the result when it
63 // is different. It writes a temporary file and renames it, so that a reader
64 // never sees half a file.
65 func (s *store) change(f func(string) string) error {
66 	s.mu.Lock()
67 	defer s.mu.Unlock()
68 	text, _, err := s.read()
69 	if err != nil {
70 		return err
71 	}
72 	out := f(text)
73 	if out == text {
74 		return nil
75 	}
76 	tmp, err := os.CreateTemp(filepath.Dir(s.path), ".links-*")
77 	if err != nil {
78 		return err
79 	}
80 	defer os.Remove(tmp.Name())
81 	if _, err := tmp.WriteString(out); err != nil {
82 		tmp.Close()
83 		return err
84 	}
85 	// The links are public. With mode 0644 the service can read the file
86 	// after root runs "sbm-webpublish merge" on the server.
87 	if err := tmp.Chmod(0o644); err != nil {
88 		tmp.Close()
89 		return err
90 	}
91 	if err := tmp.Close(); err != nil {
92 		return err
93 	}
94 	return os.Rename(tmp.Name(), s.path)
95 }
96 
97 // importMessage tells what an import did.
98 func importMessage(added, skipped, feeds int) string {
99 	m := "Imported " + strconv.Itoa(added) + " links. Skipped " + strconv.Itoa(skipped) + " duplicates."
100 	if feeds > 0 {
101 		m += " Added " + strconv.Itoa(feeds) + " feeds to links that had none."
102 	}
103 	return m
104 }
105 
106 // mergeFrom adds the links of in, as the import page does, and tells what it
107 // did. in holds sbm lines or the HTML that browsers export.
108 func mergeFrom(links *store, in io.Reader, now time.Time) (string, error) {
109 	b, err := io.ReadAll(in)
110 	if err != nil {
111 		return "", err
112 	}
113 	var added, skipped, feeds int
114 	err = links.change(func(text string) string {
115 		text, added, skipped, feeds = addDated(text, importRows(string(b)), now)
116 		return text
117 	})
118 	return importMessage(added, skipped, feeds), err
119 }
120 
121 type server struct {
122 	links    *store
123 	password string
124 	// now gives the time for the date lines. Tests set a fixed time.
125 	now func() time.Time
126 }
127 
128 // admin lets only the owner through: HTTP basic auth with any user name
129 // and the password of ADMIN_PASSWORD.
130 func (s *server) admin(h http.HandlerFunc) http.HandlerFunc {
131 	return func(w http.ResponseWriter, r *http.Request) {
132 		_, pw, ok := r.BasicAuth()
133 		if !ok || subtle.ConstantTimeCompare([]byte(pw), []byte(s.password)) != 1 {
134 			w.Header().Set("WWW-Authenticate", `Basic realm="sbm-webpublish", charset="UTF-8"`)
135 			http.Error(w, "sign in", http.StatusUnauthorized)
136 			return
137 		}
138 		h(w, r)
139 	}
140 }
141 
142 type adminData struct {
143 	Links   []Link
144 	Message string
145 }
146 
147 func (s *server) show(w http.ResponseWriter, message string) {
148 	text, _, err := s.links.read()
149 	if err != nil {
150 		http.Error(w, "cannot read the links", http.StatusInternalServerError)
151 		log.Print(err)
152 		return
153 	}
154 	ls := parse(text)
155 	slices.Reverse(ls)
156 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
157 	if err := adminTmpl.Execute(w, adminData{ls, message}); err != nil {
158 		log.Print(err)
159 	}
160 }
161 
162 func (s *server) adminPage(w http.ResponseWriter, r *http.Request) {
163 	s.show(w, r.URL.Query().Get("m"))
164 }
165 
166 // done sends the browser back to the admin page with a message.
167 func done(w http.ResponseWriter, r *http.Request, message string) {
168 	http.Redirect(w, r, "../admin?m="+template.URLQueryEscaper(message), http.StatusSeeOther)
169 }
170 
171 // badFeed is true for a feed that the outputs cannot show: an address that
172 // is not http or https.
173 func badFeed(feed string) bool {
174 	f := squeeze(feed)
175 	return f != "" && href(f) == ""
176 }
177 
178 func (s *server) add(w http.ResponseWriter, r *http.Request) {
179 	row := line(r.FormValue("url"), r.FormValue("desc"), r.FormValue("tags"), r.FormValue("feed"))
180 	switch {
181 	case row == "":
182 		done(w, r, "Give a URL.")
183 		return
184 	case badFeed(r.FormValue("feed")):
185 		done(w, r, "Give an http or https address for the feed.")
186 		return
187 	}
188 	var added, feeds int
189 	err := s.links.change(func(text string) string {
190 		text, added, _, feeds = addDated(text, []string{row}, s.now())
191 		return text
192 	})
193 	switch {
194 	case err != nil:
195 		log.Print(err)
196 		http.Error(w, "cannot write the links", http.StatusInternalServerError)
197 	case feeds > 0:
198 		done(w, r, "That URL is a link already. It got the feed.")
199 	case added == 0:
200 		done(w, r, "That URL is a link already.")
201 	default:
202 		done(w, r, "Added.")
203 	}
204 }
205 
206 // saveFeed sets the feed of the link with the URL u, and tells what it did.
207 // An empty feed removes the feed.
208 func saveFeed(links *store, u, feed string) (string, error) {
209 	if badFeed(feed) {
210 		return "Give an http or https address for the feed.", nil
211 	}
212 	k, found := norm(u), false
213 	err := links.change(func(text string) string {
214 		for _, s := range lines(text) {
215 			found = found || k != "" && key(s) == k
216 		}
217 		return setFeed(text, k, feed)
218 	})
219 	switch {
220 	case err != nil:
221 		return "", err
222 	case !found:
223 		return "No link has that URL.", nil
224 	}
225 	return "Saved the feed.", nil
226 }
227 
228 // editFeed sets the feed of one link. An empty feed removes the feed.
229 func (s *server) editFeed(w http.ResponseWriter, r *http.Request) {
230 	message, err := saveFeed(s.links, r.FormValue("url"), r.FormValue("feed"))
231 	if err != nil {
232 		log.Print(err)
233 		http.Error(w, "cannot write the links", http.StatusInternalServerError)
234 		return
235 	}
236 	done(w, r, message)
237 }
238 
239 func (s *server) delete(w http.ResponseWriter, r *http.Request) {
240 	k := norm(r.FormValue("url"))
241 	if err := s.links.change(func(text string) string { return remove(text, k) }); err != nil {
242 		log.Print(err)
243 		http.Error(w, "cannot write the links", http.StatusInternalServerError)
244 		return
245 	}
246 	done(w, r, "Deleted.")
247 }
248 
249 func (s *server) importFile(w http.ResponseWriter, r *http.Request) {
250 	r.Body = http.MaxBytesReader(w, r.Body, maxImport)
251 	f, _, err := r.FormFile("file")
252 	if err != nil {
253 		done(w, r, "Choose a file of 10 MB or less.")
254 		return
255 	}
256 	defer f.Close()
257 	message, err := mergeFrom(s.links, f, s.now())
258 	if err != nil {
259 		log.Print(err)
260 		http.Error(w, "cannot write the links", http.StatusInternalServerError)
261 		return
262 	}
263 	done(w, r, message)
264 }
265 
266 // routes gives the handler of the server. The paths start with /links/,
267 // so that nginx can send /links/ here and serve the rest of the site.
268 func (s *server) routes() http.Handler {
269 	mux := http.NewServeMux()
270 	mux.HandleFunc("GET /links/links.txt", s.plain)
271 	mux.HandleFunc("GET /links/atom.xml", s.feed)
272 	mux.HandleFunc("GET /links/list.html", s.list)
273 	mux.HandleFunc("GET /links/admin", s.admin(s.adminPage))
274 	mux.HandleFunc("POST /links/admin/add", s.admin(s.add))
275 	mux.HandleFunc("POST /links/admin/delete", s.admin(s.delete))
276 	mux.HandleFunc("POST /links/admin/feed", s.admin(s.editFeed))
277 	mux.HandleFunc("POST /links/admin/import", s.admin(s.importFile))
278 	// The admin forms use basic auth, which the browser sends with each
279 	// request. CrossOriginProtection refuses a POST from another site.
280 	return http.NewCrossOriginProtection().Handler(mux)
281 }
282 
283 func env(name, fallback string) string {
284 	if v := os.Getenv(name); v != "" {
285 		return v
286 	}
287 	return fallback
288 }
289 
290 func main() {
291 	links := &store{path: env("LINKS_FILE", "links.sbm")}
292 	if len(os.Args) > 1 {
293 		// The commands change the file as the admin page does, in a shell
294 		// on the server. They do not lock the file against the service: do
295 		// not use the admin page at the same time.
296 		var message string
297 		var err error
298 		switch args := os.Args[1:]; {
299 		case len(args) == 1 && args[0] == "merge":
300 			// merge adds the links of stdin, as bm --merge does.
301 			message, err = mergeFrom(links, os.Stdin, time.Now())
302 		case len(args) == 3 && args[0] == "feed":
303 			// feed URL FEED sets the feed of a link.
304 			message, err = saveFeed(links, args[1], args[2])
305 		default:
306 			fmt.Fprintln(os.Stderr, "usage: sbm-webpublish [merge < bookmarks | feed URL FEED]")
307 			os.Exit(2)
308 		}
309 		if err != nil {
310 			log.Fatal(err)
311 		}
312 		fmt.Println(message)
313 		return
314 	}
315 	pw := os.Getenv("ADMIN_PASSWORD")
316 	if pw == "" {
317 		log.Fatal("set ADMIN_PASSWORD")
318 	}
319 	s := &server{links: links, password: pw, now: time.Now}
320 	addr := env("ADDR", "127.0.0.1:8082")
321 	log.Printf("listening on %s, links file %s", addr, s.links.path)
322 	log.Fatal(http.ListenAndServe(addr, s.routes()))
323 }