diff --git a/Makefile b/Makefile index bcae899..d99ffa7 100644 --- a/Makefile +++ b/Makefile @@ -67,9 +67,17 @@ build: check-deps xpi: build rm -f $(XPI) cd build && zip -qr ../$(XPI) . - @echo "Package: $(XPI)" - @echo "Install: about:addons -> gear -> Install Add-on From File" - @echo "Needs xpinstall.signatures.required=false on ESR/Developer/Nightly." + @echo + @echo "Built: $(CURDIR)/$(XPI)" + @echo + @echo "To install, in Firefox:" + @echo " 1. about:config -> set xpinstall.signatures.required = false" + @echo " (only ESR, Developer Edition and Nightly honour this)" + @echo " 2. about:addons -> gear icon -> Install Add-on From File" + @echo " 3. paste this path into the file picker:" + @echo + @echo " $(CURDIR)/$(XPI)" + @echo clean: rm -rf build diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..25eb2f5 --- /dev/null +++ b/src/background.ts @@ -0,0 +1,107 @@ +// ************************************************************************* +// * (C)opyright 2026 by Ruben Carlo Benante * +// * * +// * This program is free software; you can redistribute it and/or modify * +// * it under the terms of the GNU General Public License as published by * +// * the Free Software Foundation, either version 3 of the License, or * +// * (at your option) any later version. * +// * * +// * This program is distributed in the hope that it will be useful, * +// * but WITHOUT ANY WARRANTY; without even the implied warranty of * +// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +// * GNU General Public License for more details. * +// * * +// * You should have received a copy of the GNU General Public License * +// * along with this program. If not, see http://www.gnu.org/licenses/. * +// * * +// * Contact author at: * +// * Ruben Carlo Benante * +// * rcb@beco.cc * +// ************************************************************************* + +// Decides where the content script is allowed to run, at runtime. +// +// The manifest declares no content script and no host permission at all, so a +// fresh install can read nothing. The single site the user configures is +// granted through permissions.request() from the options page, and only then +// does this register the content script against that one origin. Removing the +// permission, or clearing the address, unregisters it again. +// +// The alternative was a manifest matching every URL with the script bailing +// out on the wrong origin. That works, but it means holding read access to +// every page the user visits in order to act on one of them. + +import { isConfigured, loadConfig, sitePattern } from './config.ts'; + +const SCRIPT_ID = 'logsdu-portal'; + +async function unregister(): Promise { + const existing = await chrome.scripting.getRegisteredContentScripts({ + ids: [SCRIPT_ID], + }); + if (existing.length > 0) { + await chrome.scripting.unregisterContentScripts({ ids: [SCRIPT_ID] }); + } +} + +/** + * Bring the registered script in line with the stored config. + * + * Called on install, on startup, whenever the config changes, and whenever a + * host permission is revoked. It always tears down first and rebuilds, which + * is cheap and avoids reasoning about the previous state. + */ +async function sync(): Promise { + await unregister(); + + const config = await loadConfig(); + const pattern = sitePattern(config); + if (!isConfigured(config) || pattern === null) return; + + // Registering without the host permission throws, and the user is free to + // revoke it from about:addons at any time. + const granted = await chrome.permissions.contains({ origins: [pattern] }); + if (!granted) return; + + await chrome.scripting.registerContentScripts([ + { + id: SCRIPT_ID, + matches: [pattern], + js: ['content.js'], + runAt: 'document_idle', + allFrames: false, + // Firefox keeps registrations across restarts, so without this the + // script would be registered twice on the next startup. + persistAcrossSessions: false, + }, + ]); +} + +function resync(): void { + void sync().catch((error: unknown) => { + console.warn('logsdu: could not register content script:', error); + }); +} + +chrome.runtime.onInstalled.addListener(resync); +chrome.runtime.onStartup.addListener(resync); +chrome.permissions.onRemoved.addListener(resync); + +chrome.storage.onChanged.addListener((changes, area) => { + // Only the address matters here. Ignore the state key, which is written on + // every login attempt and would otherwise re-register constantly. + if (area === 'local' && 'config' in changes) resync(); +}); + +// The event page is also woken by the options page after a successful +// permission request, so that the script starts working without a restart. +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if ((message as { type?: string })?.type !== 'resync') return undefined; + void sync().then( + () => sendResponse({ ok: true }), + (error: unknown) => sendResponse({ ok: false, error: String(error) }), + ); + return true; +}); + +resync(); diff --git a/src/config.ts b/src/config.ts index aee12a8..4d23482 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,8 @@ // ************************************************************************* import { toOrigin } from './format.ts'; +import { DEFAULT_SELECTORS } from './portal.ts'; +import type { Selectors } from './portal.ts'; const CONFIG_KEY = 'config'; const STATE_KEY = 'state'; @@ -112,6 +114,18 @@ export function matchesSite(config: Config, href: string): boolean { } } +/** + * The configured site as a match pattern, for permissions.request() and for + * scripting.registerContentScripts(). Null when the address is unusable. + * + * The pattern is pinned to one origin -- scheme, host and port -- so granting + * it never widens beyond the single site the user typed. + */ +export function sitePattern(config: Config): string | null { + const origin = toOrigin(config.url); + return origin === null ? null : `${origin}/*`; +} + export type SubmitDecision = | { submit: true } | { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' }; diff --git a/src/manifest.json b/src/manifest.json index 7860874..a3dfcb6 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -16,16 +16,11 @@ } } }, - "permissions": ["storage"], - "host_permissions": ["*://*/*"], - "content_scripts": [ - { - "matches": ["*://*/*"], - "js": ["content.js"], - "run_at": "document_idle", - "all_frames": false - } - ], + "permissions": ["storage", "scripting"], + "optional_host_permissions": ["*://*/*"], + "background": { + "scripts": ["background.js"] + }, "web_accessible_resources": [ { "resources": ["injected.js"], diff --git a/src/portal.ts b/src/portal.ts index 39b7b9b..3635237 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -19,41 +19,71 @@ // * rcb@beco.cc * // ************************************************************************* -// Every selector the extension knows about the target page, in one place. +// How to find the login form on a page. // -// These describe a form shape, not a site: the address itself is configured -// at runtime and lives only in local storage, so nothing here names the -// institution. +// These are defaults, not constants. They describe the shape of one common +// portal, and every one of them can be overridden per installation from the +// options page, so the extension is not silently tied to a single institution +// it never names. -/** - * The login form. Every field lookup is scoped to it on purpose. - * - * The same page carries a "forgot your registration number" modal whose - * inputs are id="CPF" and id="DTNASC". The three login inputs have no id at - * all, so an unscoped lookup for a CPF field finds the modal's copy and - * writes the value into the wrong form. - */ -export const FORM = 'form[name="LoginAP"]'; +export interface Selectors { + /** + * The login form. Every field lookup is scoped to it on purpose. + * + * Portals of this kind routinely carry a second "forgot your registration + * number" form in a modal on the same page, using the same field names or + * ids. An unscoped lookup finds that copy and writes into the wrong form. + */ + form: string; + /** Registration number or code, relative to the form. */ + ra: string; + /** Birth date, relative to the form. */ + dn: string; + /** Document number, relative to the form. */ + cpf: string; + /** The button that submits the login, relative to the form. */ + submit: string; + /** + * The logout control on the pages behind the login. Clicking it is the + * signal that the user wants to stay out, which suppresses the auto-submit + * that would otherwise fire when the logout redirect lands back here. + */ + logout: string; +} -/** The three credential inputs, relative to FORM. */ -export const FIELDS = { +export const DEFAULT_SELECTORS: Selectors = { + form: 'form[name="LoginAP"]', ra: 'input[name="RA"]', dn: 'input[name="DN"]', cpf: 'input[name="CPF"]', -} as const; + submit: 'button[type="submit"]', + logout: '.js_logout', +}; -/** The "Entrar" button, relative to FORM. */ -export const SUBMIT = 'button[type="submit"]'; +export const SELECTOR_KEYS = [ + 'form', + 'ra', + 'dn', + 'cpf', + 'submit', + 'logout', +] as const; /** - * The logout control, present on the pages behind the login. Clicking it is - * the signal that the user wants to stay logged out, which suppresses the - * auto-submit that would otherwise fire the moment the logout redirect lands - * back on the login page. + * Reject anything the browser cannot parse as a selector, so a typo in the + * options page fails there instead of silently never matching a page. */ -export const LOGOUT = '.js_logout'; +export function isValidSelector(value: string): boolean { + if (value.trim() === '') return false; + try { + document.querySelector(value); + return true; + } catch { + return false; + } +} -/** Event names used to talk to the page-world filler. */ +/** Event name used to talk back from the page-world filler. */ export const EVENT_RESULT = 'logsdu:result'; /** Payload handed to the page-world script through its own dataset. */ @@ -62,6 +92,7 @@ export interface FillRequest { dn: string; cpf: string; submit: boolean; + selectors: Selectors; } export interface FillResult { diff --git a/src/webext.d.ts b/src/webext.d.ts index 49b8da2..a5c6e8d 100644 --- a/src/webext.d.ts +++ b/src/webext.d.ts @@ -29,10 +29,16 @@ // rather than a source concern. declare namespace chrome { + interface Event0 { + addListener(callback: () => void): void; + } + namespace runtime { const lastError: { message?: string } | undefined; function getURL(path: string): string; function openOptionsPage(): Promise; + const onInstalled: Event0; + const onStartup: Event0; const onMessage: { addListener( callback: ( @@ -51,6 +57,45 @@ declare namespace chrome { remove(keys: string | string[]): Promise; } const local: StorageArea; + const onChanged: { + addListener( + callback: ( + changes: Record, + areaName: string, + ) => void, + ): void; + }; + } + + namespace permissions { + interface Permissions { + origins?: string[]; + permissions?: string[]; + } + function request(permissions: Permissions): Promise; + function contains(permissions: Permissions): Promise; + function remove(permissions: Permissions): Promise; + const onRemoved: { + addListener(callback: (permissions: Permissions) => void): void; + }; + } + + namespace scripting { + interface RegisteredContentScript { + id: string; + matches?: string[]; + js?: string[]; + runAt?: 'document_start' | 'document_end' | 'document_idle'; + allFrames?: boolean; + persistAcrossSessions?: boolean; + } + function registerContentScripts( + scripts: RegisteredContentScript[], + ): Promise; + function getRegisteredContentScripts(filter?: { + ids?: string[]; + }): Promise; + function unregisterContentScripts(filter?: { ids?: string[] }): Promise; } namespace tabs {