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 }