package userscript import ( "bytes" "log" "net/http" "os" "regexp" "time" "bookmarkmanager/backend/internal/httpmw" "bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/token" ) // tokenPlaceholder is what the bindmounted userscript carries where the // Reader's credential goes: in the API_TOKEN constant and in the @downloadURL // and @updateURL metadata lines. The handler substitutes the requesting // Reader's credential for it at serve time, so no credential literal is ever // committed or deployed, and each Reader's copy carries exactly their own. var tokenPlaceholder = []byte("__API_TOKEN__") // versionLine matches the userscript metadata block's @version directive. var versionLine = regexp.MustCompile(`(?m)^// @version[ \t]+.*$`) // stampVersion replaces the served @version with one derived from the file's // mtime, discarding whatever the file body says. // // Violentmonkey only updates when the served version sorts higher than the // installed one. Deriving it from the body means one accidental downgrade or // typo freezes updates forever; an mtime-derived version is monotonic by // construction, so any later write always outranks any earlier one. // // A file with no @version line is returned untouched: such a script never // auto-updates anyway, and inventing a metadata block is not this handler's job. func stampVersion(src []byte, mod time.Time) []byte { return versionLine.ReplaceAll(src, []byte("// @version "+mod.UTC().Format("2006.01.02.1504"))) } // substituteToken replaces every tokenPlaceholder with the Reader's // credential. A file without the placeholder is returned unchanged so Render // can warn about it rather than silently serving a credential-less script. func substituteToken(src []byte, credential string) []byte { return bytes.ReplaceAll(src, tokenPlaceholder, []byte(credential)) } // Render writes one userscript file with the credential substituted and the // mtime-derived version stamped. Shared by the download path (Handler) and // the web UI's install endpoints, so both serve byte-identical scripts. // // The file is read per request — that is what lets a bindmounted copy be // edited on the host without a restart. It is ~50 KB and polled about once a // day. func Render(w http.ResponseWriter, r *http.Request, path, credential string) { info, err := os.Stat(path) if err != nil { log.Printf("userscript: stat %s: %v", path, err) http.NotFound(w, r) return } src, err := os.ReadFile(path) if err != nil { log.Printf("userscript: read %s: %v", path, err) http.NotFound(w, r) return } rendered := substituteToken(src, credential) if bytes.Equal(rendered, src) { // The bindmounted file was not built for per-Reader rendering. Serving // it as written is the operator's freedom, but a credential-less copy // is a deployment bug worth one log line — the symptom (silent 401s on // every device) is otherwise indistinguishable from a network fault. log.Printf("userscript: %s has no %s placeholder; serving as written", path, tokenPlaceholder) } w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Write(stampVersion(rendered, info.ModTime())) } // Handler serves the userscript to Violentmonkey's updater, rendered for the // Reader whose credential is in the path. // // The credential lives in the path because the update poll sends no // Authorization header, and the rendered file embeds the credential in // plaintext, so an open path would hand it to anyone who guessed the URL. A // mismatch answers 404 rather than 401: a prober learns nothing about whether // the route exists. The same credential authenticates the API bearer header, // so the two are one secret with one blast radius. // // The credential substituted is the resolved Reader's derived one, not the // raw path segment: while the retired global token is still accepted during // the grace window (httpmw.ResolveReader), an already-installed script // polling its legacy URL is served a copy carrying the Reader's own // credential, so the next update poll migrates the device onto its per-Reader // path — the window empties itself instead of ending in a silent 401 for // every device that never visited the web UI. func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token")) if !ok { http.NotFound(w, r) return } discordID, epoch, err := s.ReaderTokenInfo(readerID) if err != nil { log.Printf("userscript: reader %d token info: %v", readerID, err) http.NotFound(w, r) return } Render(w, r, path, token.Token(tokenKey, discordID, epoch)) } }