b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
101 lines
4.1 KiB
Go
101 lines
4.1 KiB
Go
package userscript
|
|
|
|
import (
|
|
"bytes"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/httpmw"
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// 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 path segment is the credential itself, so once it resolves it is also
|
|
// exactly what the served copy must carry — no re-derivation needed.
|
|
func Handler(s *store.Store, path string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
cred := r.PathValue("token")
|
|
if _, ok := httpmw.ResolveReader(s, cred); !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
Render(w, r, path, cred)
|
|
}
|
|
}
|