version 0.2 for public listing
This commit is contained in:
14
Makefile
14
Makefile
@@ -67,9 +67,17 @@ build: check-deps
|
|||||||
xpi: build
|
xpi: build
|
||||||
rm -f $(XPI)
|
rm -f $(XPI)
|
||||||
cd build && zip -qr ../$(XPI) .
|
cd build && zip -qr ../$(XPI) .
|
||||||
@echo "Package: $(XPI)"
|
@echo
|
||||||
@echo "Install: about:addons -> gear -> Install Add-on From File"
|
@echo "Built: $(CURDIR)/$(XPI)"
|
||||||
@echo "Needs xpinstall.signatures.required=false on ESR/Developer/Nightly."
|
@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:
|
clean:
|
||||||
rm -rf build
|
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 { toOrigin } from './format.ts';
|
||||||
|
import { DEFAULT_SELECTORS } from './portal.ts';
|
||||||
|
import type { Selectors } from './portal.ts';
|
||||||
|
|
||||||
const CONFIG_KEY = 'config';
|
const CONFIG_KEY = 'config';
|
||||||
const STATE_KEY = 'state';
|
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 =
|
export type SubmitDecision =
|
||||||
| { submit: true }
|
| { submit: true }
|
||||||
| { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' };
|
| { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' };
|
||||||
|
|||||||
@@ -16,16 +16,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": ["storage"],
|
"permissions": ["storage", "scripting"],
|
||||||
"host_permissions": ["*://*/*"],
|
"optional_host_permissions": ["*://*/*"],
|
||||||
"content_scripts": [
|
"background": {
|
||||||
{
|
"scripts": ["background.js"]
|
||||||
"matches": ["*://*/*"],
|
},
|
||||||
"js": ["content.js"],
|
|
||||||
"run_at": "document_idle",
|
|
||||||
"all_frames": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": ["injected.js"],
|
"resources": ["injected.js"],
|
||||||
|
|||||||
@@ -19,41 +19,71 @@
|
|||||||
// * rcb@beco.cc *
|
// * 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
|
// These are defaults, not constants. They describe the shape of one common
|
||||||
// at runtime and lives only in local storage, so nothing here names the
|
// portal, and every one of them can be overridden per installation from the
|
||||||
// institution.
|
// 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 login form. Every field lookup is scoped to it on purpose.
|
||||||
*
|
*
|
||||||
* The same page carries a "forgot your registration number" modal whose
|
* Portals of this kind routinely carry a second "forgot your registration
|
||||||
* inputs are id="CPF" and id="DTNASC". The three login inputs have no id at
|
* number" form in a modal on the same page, using the same field names or
|
||||||
* all, so an unscoped lookup for a CPF field finds the modal's copy and
|
* ids. An unscoped lookup finds that copy and writes into the wrong form.
|
||||||
* writes the value 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 DEFAULT_SELECTORS: Selectors = {
|
||||||
export const FIELDS = {
|
form: 'form[name="LoginAP"]',
|
||||||
ra: 'input[name="RA"]',
|
ra: 'input[name="RA"]',
|
||||||
dn: 'input[name="DN"]',
|
dn: 'input[name="DN"]',
|
||||||
cpf: 'input[name="CPF"]',
|
cpf: 'input[name="CPF"]',
|
||||||
} as const;
|
submit: 'button[type="submit"]',
|
||||||
|
logout: '.js_logout',
|
||||||
|
};
|
||||||
|
|
||||||
/** The "Entrar" button, relative to FORM. */
|
export const SELECTOR_KEYS = [
|
||||||
export const SUBMIT = 'button[type="submit"]';
|
'form',
|
||||||
|
'ra',
|
||||||
|
'dn',
|
||||||
|
'cpf',
|
||||||
|
'submit',
|
||||||
|
'logout',
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The logout control, present on the pages behind the login. Clicking it is
|
* Reject anything the browser cannot parse as a selector, so a typo in the
|
||||||
* the signal that the user wants to stay logged out, which suppresses the
|
* options page fails there instead of silently never matching a page.
|
||||||
* auto-submit that would otherwise fire the moment the logout redirect lands
|
|
||||||
* back on the login 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';
|
export const EVENT_RESULT = 'logsdu:result';
|
||||||
|
|
||||||
/** Payload handed to the page-world script through its own dataset. */
|
/** Payload handed to the page-world script through its own dataset. */
|
||||||
@@ -62,6 +92,7 @@ export interface FillRequest {
|
|||||||
dn: string;
|
dn: string;
|
||||||
cpf: string;
|
cpf: string;
|
||||||
submit: boolean;
|
submit: boolean;
|
||||||
|
selectors: Selectors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FillResult {
|
export interface FillResult {
|
||||||
|
|||||||
45
src/webext.d.ts
vendored
45
src/webext.d.ts
vendored
@@ -29,10 +29,16 @@
|
|||||||
// rather than a source concern.
|
// rather than a source concern.
|
||||||
|
|
||||||
declare namespace chrome {
|
declare namespace chrome {
|
||||||
|
interface Event0 {
|
||||||
|
addListener(callback: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
namespace runtime {
|
namespace runtime {
|
||||||
const lastError: { message?: string } | undefined;
|
const lastError: { message?: string } | undefined;
|
||||||
function getURL(path: string): string;
|
function getURL(path: string): string;
|
||||||
function openOptionsPage(): Promise<void>;
|
function openOptionsPage(): Promise<void>;
|
||||||
|
const onInstalled: Event0;
|
||||||
|
const onStartup: Event0;
|
||||||
const onMessage: {
|
const onMessage: {
|
||||||
addListener(
|
addListener(
|
||||||
callback: (
|
callback: (
|
||||||
@@ -51,6 +57,45 @@ declare namespace chrome {
|
|||||||
remove(keys: string | string[]): Promise<void>;
|
remove(keys: string | string[]): Promise<void>;
|
||||||
}
|
}
|
||||||
const local: StorageArea;
|
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 {
|
namespace tabs {
|
||||||
|
|||||||
Reference in New Issue
Block a user