version 0.2 for public listing
This commit is contained in:
14
Makefile
14
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
|
||||
|
||||
107
src/background.ts
Normal file
107
src/background.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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' };
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
export interface Selectors {
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const FORM = 'form[name="LoginAP"]';
|
||||
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 {
|
||||
|
||||
45
src/webext.d.ts
vendored
45
src/webext.d.ts
vendored
@@ -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<void>;
|
||||
const onInstalled: Event0;
|
||||
const onStartup: Event0;
|
||||
const onMessage: {
|
||||
addListener(
|
||||
callback: (
|
||||
@@ -51,6 +57,45 @@ declare namespace chrome {
|
||||
remove(keys: string | string[]): Promise<void>;
|
||||
}
|
||||
const local: StorageArea;
|
||||
const onChanged: {
|
||||
addListener(
|
||||
callback: (
|
||||
changes: Record<string, { oldValue?: unknown; newValue?: unknown }>,
|
||||
areaName: string,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
}
|
||||
|
||||
namespace permissions {
|
||||
interface Permissions {
|
||||
origins?: string[];
|
||||
permissions?: string[];
|
||||
}
|
||||
function request(permissions: Permissions): Promise<boolean>;
|
||||
function contains(permissions: Permissions): Promise<boolean>;
|
||||
function remove(permissions: Permissions): Promise<boolean>;
|
||||
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<void>;
|
||||
function getRegisteredContentScripts(filter?: {
|
||||
ids?: string[];
|
||||
}): Promise<RegisteredContentScript[]>;
|
||||
function unregisterContentScripts(filter?: { ids?: string[] }): Promise<void>;
|
||||
}
|
||||
|
||||
namespace tabs {
|
||||
|
||||
Reference in New Issue
Block a user