8f752ed86b
Each Reader's userscript credential is derived from TOKEN_KEY, their Discord id and a token epoch (HMAC-SHA256, hex); only its SHA-256 sits in readers.token_sha256, so install URLs survive restarts while a database leak yields nothing but hashes. One credential authenticates the script download path and the API bearer header. - internal/token: derivation + hashing; migration 0006 adds token_epoch - seed refreshes the owner's epoch-0 hash only before first rotation - httpmw.Auth resolves the acting Reader from the credential hash and stashes it in the request context; the retired API_TOKEN resolves to the owner until API_TOKEN_GRACE_UNTIL, logged per use, on both the bearer and script-download paths - userscript handler renders the bindmounted file with the resolved Reader's credential substituted for __API_TOKEN__; a legacy-path request during grace serves the derived credential, so devices self-migrate on their next update poll - web UI: Userscripts panel with session-gated install endpoints that render the script directly (credential never in markup, address bar or a redirect) and confirm-gated rotation; atomic epoch bump + hash rewrite in the store - both userscripts carry __API_TOKEN__ placeholders; the committed global-token literal is removed (rotating at deploy retires it for real — it survives in git history) - env: TOKEN_KEY required, API_TOKEN/API_TOKEN_GRACE_UNTIL retire the legacy credential; docs and compose updated
68 lines
2.5 KiB
Go
68 lines
2.5 KiB
Go
package userscript
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
|
// @version line, the credential placeholder in its metadata and body, plus
|
|
// content that must survive the rewrites untouched.
|
|
const sampleScript = `// ==UserScript==
|
|
// @name Manga Bookmark Sync
|
|
// @version 1.5.0
|
|
// @downloadURL https://api.example/u/__API_TOKEN__/manga-bookmark.user.js
|
|
// @match https://asurascans.com/*
|
|
// ==/UserScript==
|
|
(function () { "use strict";
|
|
const API_TOKEN = "__API_TOKEN__";
|
|
})();
|
|
`
|
|
|
|
func TestStampVersionReplacesVersionLineOnly(t *testing.T) {
|
|
mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC)
|
|
got := string(stampVersion([]byte(sampleScript), mod))
|
|
|
|
if !strings.Contains(got, "// @version "+mod.UTC().Format("2006.01.02.1504")) {
|
|
t.Errorf("body has no stamped version:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "1.5.0") {
|
|
t.Errorf("body still carries the file's own version:\n%s", got)
|
|
}
|
|
// Everything outside the @version line is served verbatim, including the
|
|
// placeholder — stamping must not do the substitution's job.
|
|
if !strings.Contains(got, `const API_TOKEN = "__API_TOKEN__";`) {
|
|
t.Errorf("body was altered beyond the version line:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestStampVersionWithoutVersionLineServedUnmodified(t *testing.T) {
|
|
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
|
if got := string(stampVersion([]byte(noVersion), time.Now())); got != noVersion {
|
|
t.Errorf("stampVersion altered a file with no @version line:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestSubstituteTokenReplacesEveryPlaceholder(t *testing.T) {
|
|
got := string(substituteToken([]byte(sampleScript), "abc123"))
|
|
|
|
if strings.Contains(got, "__API_TOKEN__") {
|
|
t.Errorf("placeholder survived substitution:\n%s", got)
|
|
}
|
|
// The credential lands in the constant and in both metadata lines.
|
|
if want := `const API_TOKEN = "abc123";`; !strings.Contains(got, want) {
|
|
t.Errorf("no substituted constant %q:\n%s", want, got)
|
|
}
|
|
if want := "https://api.example/u/abc123/manga-bookmark.user.js"; !strings.Contains(got, want) {
|
|
t.Errorf("no substituted download URL %q:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
func TestSubstituteTokenWithoutPlaceholderServedUnmodified(t *testing.T) {
|
|
const noPlaceholder = "// ==UserScript==\n// @name x\n// ==/UserScript==\n"
|
|
if got := string(substituteToken([]byte(noPlaceholder), "abc123")); got != noPlaceholder {
|
|
t.Errorf("substituteToken altered a file without the placeholder:\n%s", got)
|
|
}
|
|
}
|