Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5ec86222 | ||
|
|
d80a5255ed | ||
|
|
89c83ee5de | ||
|
|
69b5637bd6 |
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
|
||||
# reviewers catch drift.
|
||||
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
("patreon", re.compile(
|
||||
# Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/`
|
||||
# (the "creator workspace" URL served once subscribed, see
|
||||
# patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a
|
||||
# creator's inner page still derives the slug. Nav pages stay excluded.
|
||||
r"^https?://(?:www\.)?patreon\.com/"
|
||||
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
||||
r"(?P<slug>[^/?#]+)/?$",
|
||||
r"(?:cw/|c/)?"
|
||||
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
|
||||
r"(?P<slug>[^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
("subscribestar", re.compile(
|
||||
|
||||
@@ -31,6 +31,69 @@ browser.runtime.onInstalled.addListener(() => ensureInitialized());
|
||||
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
||||
ensureInitialized().catch(e => console.error('init failed:', e));
|
||||
|
||||
// ---- Extension self-update check (#1489) ----
|
||||
// Installed per-instance from the operator's FC host, so Firefox's static
|
||||
// update_url can't apply (each instance has a different host). Instead ask the
|
||||
// configured backend for the latest published version and nudge the operator to
|
||||
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
|
||||
// a toolbar badge (daily). /api/extension/manifest is public and returns
|
||||
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
|
||||
|
||||
function versionIsNewer(candidate, current) {
|
||||
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
|
||||
const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
|
||||
const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkForUpdateInfo() {
|
||||
await ensureInitialized();
|
||||
if (!api.isConfigured()) return { updateAvailable: false, configured: false };
|
||||
let info;
|
||||
try {
|
||||
info = await api.getExtensionManifest();
|
||||
} catch (e) {
|
||||
return { updateAvailable: false, error: e.message };
|
||||
}
|
||||
const currentVersion = browser.runtime.getManifest().version;
|
||||
const latestVersion = info && info.version ? info.version : null;
|
||||
// latest_url is served from the web root; strip the /api suffix off baseUrl
|
||||
// (same transform as OPEN_ARTIST_PAGE).
|
||||
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
return {
|
||||
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshUpdateBadge() {
|
||||
let r;
|
||||
try { r = await checkForUpdateInfo(); } catch { return; }
|
||||
try {
|
||||
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
|
||||
if (r.updateAvailable) {
|
||||
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
|
||||
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
|
||||
} else {
|
||||
await browser.action.setTitle({ title: 'FabledCurator' });
|
||||
}
|
||||
} catch { /* action API unavailable — non-fatal */ }
|
||||
}
|
||||
|
||||
// Daily proactive check (needs the "alarms" permission). create() is idempotent
|
||||
// by name, so re-running it on each event-page load is safe.
|
||||
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
|
||||
browser.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'fc-update-check') refreshUpdateBadge();
|
||||
});
|
||||
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
|
||||
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());
|
||||
|
||||
// ---- Discord token capture via webRequest ----
|
||||
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||
@@ -298,6 +361,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
}
|
||||
}
|
||||
|
||||
case 'CHECK_UPDATE':
|
||||
return await checkForUpdateInfo();
|
||||
|
||||
default:
|
||||
return { error: `Unknown message type: ${msg.type}` };
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@ class FabledCuratorAPI {
|
||||
const qs = new URLSearchParams({ url }).toString();
|
||||
return this.request('GET', `/extension/probe?${qs}`);
|
||||
}
|
||||
// Latest published extension version on this instance — drives the in-app
|
||||
// update prompt. Public endpoint (no key needed, but request() sends it
|
||||
// harmlessly). Returns {version, xpi_url, latest_url, sha256}.
|
||||
getExtensionManifest() {
|
||||
return this.request('GET', '/extension/manifest');
|
||||
}
|
||||
|
||||
// Connection test = the cheapest read with auth.
|
||||
testConnection() {
|
||||
|
||||
@@ -86,7 +86,16 @@ const PLATFORMS = {
|
||||
* script to decide whether to show the floating "Add as source" button.
|
||||
*/
|
||||
const PLATFORM_ARTIST_PATTERNS = {
|
||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
|
||||
// Patreon serves the same creator under three URL shapes (see backend
|
||||
// patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and
|
||||
// `cw/` "creator workspace" — the last is the URL you land on once you're
|
||||
// SUBSCRIBED, which is exactly when the button matters. Match all three, and
|
||||
// drop the single-segment end-anchor so a creator's inner page
|
||||
// (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages
|
||||
// (home/search/…/posts permalink) stay excluded. Mirrors extension_service
|
||||
// ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button
|
||||
// vanished once subscribed because the old pattern only matched the bare root).
|
||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
|
||||
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
@@ -22,7 +22,8 @@
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"webRequest",
|
||||
"webRequestBlocking"
|
||||
"webRequestBlocking",
|
||||
"alarms"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
|
||||
@@ -72,6 +72,17 @@ body {
|
||||
.btn.block { display: block; width: 100%; margin-top: 8px; }
|
||||
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
|
||||
.btn.link:hover { color: var(--accent); }
|
||||
.btn.small { padding: 6px 12px; font-size: 13px; }
|
||||
|
||||
/* In-app update prompt (accent-tinted so it reads as an actionable notice). */
|
||||
.update-banner {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 10px 10px 0; padding: 10px 12px;
|
||||
background: rgba(244, 186, 122, 0.12);
|
||||
border: 1px solid rgba(244, 186, 122, 0.4);
|
||||
border-radius: 6px;
|
||||
}
|
||||
#update-text { flex: 1; font-size: 13px; }
|
||||
|
||||
.source-row .play {
|
||||
background: none; border: none; color: var(--on-surface-variant);
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
</section>
|
||||
|
||||
<section id="main-content" class="main hidden">
|
||||
<div id="update-banner" class="update-banner hidden">
|
||||
<span id="update-text"></span>
|
||||
<button id="update-btn" class="btn primary small">Update</button>
|
||||
</div>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="platforms">Platforms</button>
|
||||
<button class="tab" data-tab="sources">Sources</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ async function init() {
|
||||
setupEventListeners();
|
||||
showPlatformsLoading();
|
||||
testConnectionIfNeeded();
|
||||
checkForUpdate();
|
||||
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
||||
} catch (e) {
|
||||
showSetupRequired();
|
||||
@@ -63,6 +64,26 @@ function updateConnectionDot(connected) {
|
||||
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
|
||||
}
|
||||
|
||||
// Nudge to reinstall when the configured instance publishes a newer signed XPI
|
||||
// (the extension is self-hosted, so there's no Firefox auto-update). Never
|
||||
// blocks the popup — a failed check just leaves the banner hidden.
|
||||
async function checkForUpdate() {
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
|
||||
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
function showUpdateBanner(r) {
|
||||
document.getElementById('update-text').textContent =
|
||||
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
|
||||
// Opening the signed XPI triggers Firefox's native install prompt.
|
||||
document.getElementById('update-btn').addEventListener('click', () => {
|
||||
browser.tabs.create({ url: r.xpiUrl });
|
||||
});
|
||||
document.getElementById('update-banner').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadPlatformStatus() {
|
||||
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
||||
const c = document.getElementById('platforms-list');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Unit tests for ExtensionService._derive — the URL → (platform, slug)
|
||||
parser that gates the browser extension's "Add as source" button and pulls
|
||||
the creator slug on probe/add.
|
||||
|
||||
Regression cover for #1485: Patreon serves the same creator under three URL
|
||||
shapes — bare `patreon.com/Atole`, `c/`, and `cw/` (the "creator workspace"
|
||||
URL you land on once SUBSCRIBED). The button used to vanish while subscribed
|
||||
because the pattern only matched the bare root and excluded `c/`.
|
||||
|
||||
_derive is pure URL parsing (no DB / no async), so a session-less instance is
|
||||
fine to exercise directly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.extension_service import (
|
||||
ExtensionService,
|
||||
InvalidUrlError,
|
||||
UnknownPlatformError,
|
||||
)
|
||||
|
||||
_svc = ExtensionService(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, slug",
|
||||
[
|
||||
# All three Patreon creator prefixes resolve to the same vanity slug.
|
||||
("https://www.patreon.com/Atole", "Atole"),
|
||||
("https://www.patreon.com/c/Atole", "Atole"),
|
||||
("https://www.patreon.com/cw/Atole", "Atole"), # subscribed-view URL
|
||||
# A creator's inner page still derives the slug (trailing sub-path).
|
||||
("https://www.patreon.com/cw/Atole/posts", "Atole"),
|
||||
("https://www.patreon.com/Atole/membership", "Atole"),
|
||||
("https://patreon.com/c/Atole", "Atole"), # bare host, no www
|
||||
],
|
||||
)
|
||||
def test_derive_patreon_creator_urls(url, slug):
|
||||
platform, got = _svc._derive(url)
|
||||
assert platform == "patreon"
|
||||
assert got == slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
# Patreon's own nav pages must never read as a creator slug.
|
||||
"https://www.patreon.com/home",
|
||||
"https://www.patreon.com/settings",
|
||||
"https://www.patreon.com/search",
|
||||
"https://www.patreon.com/messages",
|
||||
"https://www.patreon.com/library",
|
||||
"https://www.patreon.com/notifications",
|
||||
"https://www.patreon.com/posts/12345", # post permalink
|
||||
"https://www.patreon.com/settings/billing", # nav sub-page
|
||||
],
|
||||
)
|
||||
def test_derive_patreon_nav_pages_rejected(url):
|
||||
with pytest.raises(UnknownPlatformError):
|
||||
_svc._derive(url)
|
||||
|
||||
|
||||
def test_derive_rejects_missing_scheme():
|
||||
with pytest.raises(InvalidUrlError):
|
||||
_svc._derive("patreon.com/Atole")
|
||||
Reference in New Issue
Block a user