diff --git a/.gitignore b/.gitignore index 28983f0..294efab 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,17 @@ # dependencies node_modules -# Build output. The unpacked extension and the signing package are release -# artifacts, not sources. +# Unpacked build output. Regenerated by "make", never edited by hand. build + +# Intermediate packages. logsdu-*.zip -*.xpi + +# Packaged .xpi files are NOT ignored. They are the published artifact: the +# signed add-on is what people download and install, so it belongs in the +# repository (or attached to a release) rather than being rebuilt by everyone +# who wants to install it. An unsigned local build can be removed with +# "make clean". # Exclude sourcemaps *.map diff --git a/Makefile b/Makefile index d99ffa7..caf1f77 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ EXT_ID := logsdu VERSION := $(shell node -p "require('./package.json').version") XPI := $(EXT_ID)-$(VERSION).xpi -.PHONY: all build test xpi clean check-deps +.PHONY: all build chrome test xpi clean check-deps all: build @@ -61,6 +61,13 @@ build: check-deps node_modules/.bin/tsc -noEmit -skipLibCheck node esbuild.config.mjs production +# The same sources with Chrome's background key. Untested against Chrome; it +# exists so the port is a build flag rather than a fork. +chrome: check-deps + node_modules/.bin/tsc -noEmit -skipLibCheck + TARGET=chrome node esbuild.config.mjs production + @echo "Chrome build in build/ -- load it via chrome://extensions (Developer mode)." + # An .xpi is just a zip of the extension directory, with the manifest at the # top level rather than inside a wrapper folder. The same file installs # directly on ESR and uploads to AMO for signing. @@ -70,13 +77,16 @@ xpi: build @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 "This file is UNSIGNED. Two ways to use it:" @echo - @echo " $(CURDIR)/$(XPI)" + @echo " Publish -- upload it at addons.mozilla.org/developers/addon/submit/" + @echo " Mozilla signs it; the signed file installs on any Firefox." + @echo + @echo " Install locally -- only on ESR, Developer Edition or Nightly:" + @echo " 1. about:config -> xpinstall.signatures.required = false" + @echo " 2. about:addons -> gear icon -> Install Add-on From File" + @echo " 3. paste this path into the file picker:" + @echo " $(CURDIR)/$(XPI)" @echo clean: diff --git a/README.md b/README.md index e072079..3f8c549 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,81 @@ # logsdu -A Firefox extension that logs you into an academic portal whose sign-in form is -three fields -- registration number, birth date and national ID -- rather than -the usual user and password. +A Firefox extension for logins that password managers cannot save: the ones +made of a registration number, a date of birth and a document number, instead +of a username and a password. -That shape defeats password managers. The form is marked `autocomplete="off"`, -none of the three inputs is a `password` field, and Bitwarden, Firefox and -Chrome all decline to remember it. Since the values never change and the -institution does not let you pick a different login method, the only option -left is copying three values by hand, every time. +Academic portals do this a lot. The form is usually marked `autocomplete="off"`, +none of the fields is a `password` field, and Bitwarden, Firefox and Chrome all +decline to remember it. When the values never change and the institution offers +no other way in, the only option left is copying three values by hand, every +single time -- including the registration number nobody has memorised. -logsdu stores them once and fills them in. By default it also presses "Entrar", -so the normal case is zero clicks. +logsdu stores them once and fills them in. By default it also presses the submit +button, so the normal case is zero clicks. ## Install -Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below). -Node 22+ ships `corepack`, so pnpm does not have to be installed globally. +Once it is published, from addons.mozilla.org. Until then, build it yourself: ``` corepack pnpm install # first time, or after a dependency change make xpi # typecheck, bundle, and package logsdu-.xpi ``` -Then install it permanently. Firefox will not load an unsigned add-on unless -you tell it to, and only the ESR, Developer Edition and Nightly builds accept -being told: +`make xpi` prints the full path of the file and how to install it. The package +it produces is unsigned, and Firefox only accepts unsigned add-ons on the ESR, +Developer Edition and Nightly builds, after setting +`xpinstall.signatures.required` to `false` in `about:config`. On release Firefox +the file has to be signed by Mozilla first -- see "Publishing". -1. Open `about:config`, accept the warning -2. Set `xpinstall.signatures.required` to **false** -3. Open `about:addons`, click the **gear** icon, choose **Install Add-on From - File**, and pick `logsdu-.xpi` +Dependencies are managed with **pnpm**, never npm. See "Why pnpm" below. -It survives restarts. On **release** Firefox that pref is ignored -- see -"Release Firefox" below. +## Setting it up -Finally, open the extension's options page, fill in the four values, and save. - -## The four values +Open the extension's options page and fill in four values: | Field | Example | Notes | | --- | --- | --- | | Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored | | Registration number | `2000101010` | Digits only | -| Birth date | `01/01/2000` | Reformatted as you type | -| National ID | `000.000.000-00` | Reformatted as you type | +| Date of birth | `01/01/2000` | Reformatted as you type | +| Document number | `000.000.000-00` | Reformatted as you type | Paste raw digits if you like -- the options page inserts the separators, because -the portal's input masks expect the values in exactly that shape. +input masks on these forms expect the values in exactly that shape. -**The portal address is configuration, not code.** No institution is named -anywhere in the extension: not in the manifest, not in the source, not in the -build output. Be clear about what that does and does not buy you. It stops -someone who reads the extension from learning which portal it is for. It does -not hide anything from someone who can read the extension's storage -- and that -is the same access that would expose your national ID and birth date anyway. +When you press Save, Firefox asks whether logsdu may access the address you +entered. That prompt names one site. Accept it and the extension starts working +there; decline and nothing is stored as usable. + +If the portal's markup differs from the common shape, open **Ajustes avançados** +in the options page and adjust the CSS selectors. Invalid selectors are rejected +on save rather than failing silently later. + +## What it can access + +Nothing, until you say so. + +The manifest requests **no host permissions at all**. There is no content script +declared against any site. When you save an address, the extension asks for that +single origin through `permissions.request()`, and a background script then +registers the content script for that one origin and no other. + +That means a fresh install can read no pages, the permission prompt names one +site, and you can revoke it whenever you like in `about:addons` -> Permissions. +Clearing your data in the options page hands the permission back automatically. + +The `optional_host_permissions` entry in the manifest is `*://*/*`, because the +address is not known until you type it. It is the set the extension may *ask* +from, not what it holds -- nothing is granted without your click, and what is +granted is one origin. ## How it behaves - **Fills and submits** on the login page, with no interaction. -- **At most one automatic submit per hour.** After an attempt, the extension - drops back to filling only, so a wrong value cannot resubmit itself on every - page load and lock you out. Correct the values and save; saving clears the +- **At most one automatic submit per hour.** After an attempt it drops back to + filling only, so a wrong value cannot resubmit itself on every page load and + lock you out of your account. Correct the values and save; saving clears the timer, so the next visit tries again immediately. - **Logging out keeps you logged out.** Clicking the portal's logout control suppresses the automatic submit for five minutes -- otherwise the logout @@ -72,32 +86,44 @@ is the same access that would expose your national ID and birth date anyway. submitting, for when you want to check the values before sending them. - Automatic submission can be turned off entirely in the options. -## How the values are stored +## Where your data goes -In `storage.local`: private to this browser profile, never synced, never sent -anywhere. That is the same protection a browser-saved password gets, and it has -the same limit -- anyone with your unlocked account can read it. If that is not -good enough for your threat model, this extension is the wrong tool. +Nowhere. It is written to `storage.local`: private to your browser profile, +never synced, never transmitted. The extension makes no network requests of its +own and contains no analytics. -## Release Firefox +Be clear about the limit, though. This is the same protection a browser-saved +password gets, and it has the same weakness -- anyone with your unlocked +computer can read it. If you need protection at rest, this is the wrong tool. -`xpinstall.signatures.required` only works on ESR, Developer Edition and -Nightly. Release Firefox ignores it and refuses unsigned add-ons outright, so -there the same `.xpi` has to be signed first: upload it to -[addons.mozilla.org](https://addons.mozilla.org) as an **unlisted** add-on. -Signing is automated -- nothing is published publicly or reviewed by hand -- and -you install the signed file it hands back. +The interface is in Portuguese, matching the portals it was written for. -The `browser_specific_settings.gecko.id` in the manifest is what gives the -add-on a stable identity across both routes, so settings survive an upgrade -from one to the other. +## Publishing + +`make xpi` produces the file to upload at +[addons.mozilla.org](https://addons.mozilla.org/developers/addon/submit/). +Two distribution choices: + +- **Listed** -- public on addons.mozilla.org, searchable, installable by anyone, + and updates are delivered by Mozilla automatically. +- **Unlisted** -- signed but not published. You distribute the signed file + yourself. Updates need a self-hosted update manifest, or resending the file. + +Builds are never minified, which is deliberate: AMO requires a separate +source-code submission for any add-on whose uploaded code is machine-generated, +and that obligation would apply to every future release. The whole extension is +about 33 KB, so the saving would not pay for the process, and readable code is +easier for a reviewer -- or anyone auditing what handles their credentials -- to +check. ## Chrome -Not yet. The source deliberately avoids anything Firefox-specific: it uses the -`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build -should be a manifest question rather than a rewrite. It has not been tried, so -do not assume it works. +`make chrome` builds it. The only difference is the background key: Firefox MV3 +uses an event page, Chrome MV3 requires a service worker, so the manifest is +generated per target rather than duplicated. Everything else -- `chrome.*` +namespace, MV3, no Firefox-only APIs -- is already shared. + +It has not been tested against Chrome. Do not assume it works. ## Development @@ -109,54 +135,49 @@ corepack pnpm run dev # rebuild on change make clean ``` -While iterating, reinstalling an `.xpi` for every edit is tedious. Load the -unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load -Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after -each rebuild. That copy disappears on restart, which is the point -- it is for -development, not for daily use. +While iterating, load the unpacked directory rather than reinstalling an `.xpi` +each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** -> +`build/manifest.json`, then press **Reload** there after each rebuild. That copy +disappears on restart, which is the point -- it is for development, not daily +use. -`corepack pnpm run dev` watches and rebuilds, static files included, but -Firefox still needs the Reload click to pick anything up. +`corepack pnpm run dev` watches and rebuilds, static files included, but Firefox +still needs the Reload click to pick anything up. ### Layout | Path | Role | | --- | --- | -| `src/manifest.json` | MV3 manifest. Names no site | +| `src/manifest.json` | MV3 manifest. Requests no host access | +| `src/background.ts` | Registers the content script for the granted origin | | `src/content.ts` | Isolated world. Decides whether to act, then delegates | | `src/injected.ts` | Page world. Does the actual filling and clicking | -| `src/portal.ts` | Every selector the extension knows about the form | +| `src/portal.ts` | Default selectors and the shape of a fill request | | `src/config.ts` | Stored values, the rate limit and the logout cooldown | | `src/format.ts` | Input normalisers for the three masked fields | | `src/options.*`, `src/popup.*` | The two bits of UI | ### Why two scripts instead of one -The portal drives its inputs with Inputmask, which replaces each element's -`value` property with its own accessor. A content script assigning -`input.value` from the isolated world writes through Xrays to the *native* -setter and skips that accessor: the field looks right on screen, but the mask's -buffer is unchanged, and the page's submit handler reads the stale buffer back -out through jQuery. So the filling happens inside the page, through the page's -own jQuery and Inputmask. +These portals drive their inputs with Inputmask, which replaces each element's +`value` property with its own accessor. A content script assigning `input.value` +from the isolated world writes through Xrays to the *native* setter and skips +that accessor: the field looks right on screen, but the mask's buffer is +unchanged, and the page's submit handler reads the stale buffer back out through +jQuery. So the filling happens inside the page, through the page's own jQuery +and Inputmask, in `injected.ts`. -Submitting is a real click on the button, never `form.submit()`. The portal -intercepts the submit event, cancels it, and posts by AJAX with a CSRF token -taken from a meta tag. `form.submit()` would bypass that handler and lose the -token; a click reproduces exactly what a person pressing "Entrar" does. +Submitting is a real click on the button, never `form.submit()`. These portals +intercept the submit event, cancel it, and post by AJAX with a CSRF token taken +from a meta tag. `form.submit()` would bypass that handler and lose the token; a +click reproduces exactly what a person pressing the button does. -### Why the content script matches every URL +### Why the field selectors are configurable -The address is configured at runtime, so it cannot also be a manifest match -pattern -- putting it there is exactly what would name the institution in the -shipped code. The script therefore loads everywhere and stops immediately -unless the page's origin equals the configured one. Origins are compared whole, -so `portal.example.br.evil.tld` does not match. - -The honest cost: the extension holds read access to every page you visit. The -alternative -- registering the content script at runtime with -`scripting.registerContentScripts` and an optional host permission -- avoids -that at the price of a background script and a permission prompt. +Hardcoding them would tie the extension to one institution while pretending to +be general. They live in `portal.ts` as defaults and can be overridden per +installation, so the same build works for any portal of this shape -- and no +institution is named anywhere in the source, the manifest or the build output. ### Why pnpm, not npm diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/esbuild.config.mjs b/esbuild.config.mjs index c3b2a20..7422852 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -21,7 +21,7 @@ import esbuild from 'esbuild'; import process from 'process'; -import { cp, mkdir, readdir } from 'node:fs/promises'; +import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; const banner = `/* * logsdu - fills and submits a three-field academic portal login. @@ -37,16 +37,40 @@ const banner = `/* const prod = process.argv[2] === 'production'; const outdir = 'build'; +// Target browser. Firefox and Chrome disagree on exactly one manifest key, so +// the manifest is written per target rather than duplicated in the tree. +const target = process.env.TARGET === 'chrome' ? 'chrome' : 'firefox'; + +/** + * Write the manifest for the target browser. + * + * Firefox MV3 runs the background as an event page ("scripts"); Chrome MV3 + * requires a service worker and rejects "scripts" outright, so the two cannot + * simply coexist in one file. Chrome also has no use for the gecko block. + */ +async function writeManifest() { + const manifest = JSON.parse(await readFile('src/manifest.json', 'utf8')); + if (target === 'chrome') { + delete manifest.browser_specific_settings; + manifest.background = { service_worker: 'background.js' }; + } + await writeFile( + `${outdir}/manifest.json`, + `${JSON.stringify(manifest, null, '\t')}\n`, + ); +} + // Everything that is not TypeScript is copied verbatim into build/, so that // the directory can be handed straight to about:debugging. async function copyStatic() { await mkdir(outdir, { recursive: true }); for (const name of await readdir('src')) { - if (name.endsWith('.json') || name.endsWith('.html') || name.endsWith('.css')) { + if (name.endsWith('.html') || name.endsWith('.css')) { await cp(`src/${name}`, `${outdir}/${name}`); } } await cp('icons', `${outdir}/icons`, { recursive: true }); + await writeManifest(); } await copyStatic(); @@ -65,10 +89,11 @@ const context = await esbuild.context({ js: banner, }, plugins: [staticPlugin], - // Four independent entry points: no shared runtime, no imports at load + // Five independent entry points: no shared runtime, no imports at load // time. Content scripts and page-world scripts cannot be ES modules, so // every bundle has to stand alone. entryPoints: [ + 'src/background.ts', 'src/content.ts', 'src/injected.ts', 'src/options.ts', @@ -81,7 +106,14 @@ const context = await esbuild.context({ sourcemap: prod ? false : 'inline', treeShaking: true, outdir, - minify: prod, + // Deliberately never minified. addons.mozilla.org requires a separate + // source-code submission for any add-on whose uploaded code is minified or + // otherwise machine-generated, and that obligation would apply to every + // release from now on. The whole extension is a few tens of kilobytes, so + // the saving would not pay for the process, and shipping readable code + // makes the review -- and anyone auditing what handles their credentials -- + // straightforward. + minify: false, }); if (prod) { diff --git a/logsdu-0.2.0.xpi b/logsdu-0.2.0.xpi new file mode 100644 index 0000000..27205f0 Binary files /dev/null and b/logsdu-0.2.0.xpi differ diff --git a/package.json b/package.json index ad78ebf..2b3e7c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "logsdu", - "version": "0.1.0", + "version": "0.2.0", "description": "Browser extension that fills and submits a three-field academic portal login.", "author": "Ruben Carlo Benante ", "type": "module", diff --git a/src/config.test.ts b/src/config.test.ts index 1722ce3..66898bf 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -28,8 +28,11 @@ import { decideSubmit, isConfigured, matchesSite, + sitePattern, + withDefaults, } from './config.ts'; import type { Config, State } from './config.ts'; +import { DEFAULT_SELECTORS } from './portal.ts'; const CONFIG: Config = { url: 'https://portal.example.br/', @@ -37,6 +40,7 @@ const CONFIG: Config = { dn: '01/02/1999', cpf: '123.456.789-01', autoSubmit: true, + selectors: { ...DEFAULT_SELECTORS }, }; const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 }; @@ -51,6 +55,42 @@ test('isConfigured requires all four values', () => { assert.equal(isConfigured({ ...CONFIG, cpf: '' }), false); }); +test('sitePattern pins the grant to one origin', () => { + assert.equal(sitePattern(CONFIG), 'https://portal.example.br/*'); + assert.equal( + sitePattern({ ...CONFIG, url: 'https://portal.example.br/login?a=1' }), + 'https://portal.example.br/*', + 'path and query must not widen or narrow the grant', + ); + assert.equal(sitePattern({ ...CONFIG, url: '' }), null); +}); + +test('withDefaults fills selectors a stored config never had', () => { + // A config written by 0.1.x has no selectors key at all. It must come back + // complete, not half-built, or every lookup silently becomes undefined. + const upgraded = withDefaults({ + url: 'https://portal.example.br/', + ra: '1', + dn: '01/02/1999', + cpf: '123.456.789-01', + }); + assert.deepEqual(upgraded.selectors, DEFAULT_SELECTORS); + assert.equal(upgraded.autoSubmit, true); +}); + +test('withDefaults keeps a partial selector override and backfills the rest', () => { + const custom = withDefaults({ selectors: { form: '#login' } as never }); + assert.equal(custom.selectors.form, '#login'); + assert.equal(custom.selectors.ra, DEFAULT_SELECTORS.ra); + assert.equal(custom.selectors.logout, DEFAULT_SELECTORS.logout); +}); + +test('withDefaults on nothing stored is the empty config', () => { + const empty = withDefaults(undefined); + assert.equal(empty.url, ''); + assert.deepEqual(empty.selectors, DEFAULT_SELECTORS); +}); + test('matchesSite compares origins, not prefixes', () => { assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true); assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true); diff --git a/src/config.ts b/src/config.ts index 4d23482..77db00f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,11 @@ export interface Config { dn: string; cpf: string; autoSubmit: boolean; + /** + * How to find the form on that site. Defaulted, and adjustable from the + * options page for portals whose markup differs. + */ + selectors: Selectors; } /** Bookkeeping that enforces the rate limit and the logout cooldown. */ @@ -61,13 +66,29 @@ export const EMPTY_CONFIG: Config = { dn: '', cpf: '', autoSubmit: true, + selectors: { ...DEFAULT_SELECTORS }, }; const EMPTY_STATE: State = { lastSubmitAt: 0, logoutAt: 0 }; +/** + * Merge stored values over the defaults. + * + * Selectors are merged one level deeper than the rest: a config saved by an + * older version, or one that only overrides the form selector, must still come + * back with every key present rather than a half-built object. + */ +export function withDefaults(stored: Partial | undefined): Config { + return { + ...EMPTY_CONFIG, + ...(stored ?? {}), + selectors: { ...DEFAULT_SELECTORS, ...(stored?.selectors ?? {}) }, + }; +} + export async function loadConfig(): Promise { const stored = await chrome.storage.local.get(CONFIG_KEY); - return { ...EMPTY_CONFIG, ...((stored[CONFIG_KEY] as Partial) ?? {}) }; + return withDefaults(stored[CONFIG_KEY] as Partial | undefined); } export async function saveConfig(config: Config): Promise { diff --git a/src/content.ts b/src/content.ts index d19cc0e..858777c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -33,7 +33,7 @@ import { saveState, } from './config.ts'; import type { Config } from './config.ts'; -import { EVENT_RESULT, FORM, LOGOUT } from './portal.ts'; +import { EVENT_RESULT } from './portal.ts'; import type { FillRequest, FillResult } from './portal.ts'; const FORM_WAIT_MS = 10000; @@ -76,13 +76,13 @@ function fill(request: FillRequest): Promise { } /** Resolve once the login form exists, or null if it never shows up. */ -function awaitForm(): Promise { - const existing = document.querySelector(FORM); +function awaitForm(formSelector: string): Promise { + const existing = document.querySelector(formSelector); if (existing) return Promise.resolve(existing); return new Promise((resolve) => { const observer = new MutationObserver(() => { - const found = document.querySelector(FORM); + const found = document.querySelector(formSelector); if (found) { observer.disconnect(); window.clearTimeout(timer); @@ -128,12 +128,12 @@ function showNotice(text: string): void { * Without this the logout redirect lands on the login page and the extension * immediately logs them back in, which makes logging out impossible. */ -function watchLogout(): void { +function watchLogout(logoutSelector: string): void { document.addEventListener( 'click', (event) => { const target = event.target as Element | null; - if (!target?.closest?.(LOGOUT)) return; + if (!target?.closest?.(logoutSelector)) return; void loadState().then((state) => saveState({ ...state, logoutAt: Date.now() }), ); @@ -143,7 +143,7 @@ function watchLogout(): void { } async function autoLogin(config: Config): Promise { - const form = await awaitForm(); + const form = await awaitForm(config.selectors.form); if (!form) return; const state = await loadState(); @@ -161,6 +161,7 @@ async function autoLogin(config: Config): Promise { dn: config.dn, cpf: config.cpf, submit: decision.submit, + selectors: config.selectors, }); if (result.error) { @@ -177,7 +178,7 @@ async function main(): Promise { if (!isConfigured(config)) return; if (!matchesSite(config, location.href)) return; - watchLogout(); + watchLogout(config.selectors.logout); // The popup's "Preencher agora" button, for when the automatic submit is // off or rate limited. @@ -190,6 +191,7 @@ async function main(): Promise { dn: fresh.dn, cpf: fresh.cpf, submit: (message as { submit?: boolean }).submit === true, + selectors: fresh.selectors, }), ) .then(sendResponse); @@ -200,7 +202,10 @@ async function main(): Promise { // still loading and could still produce one. On the pages behind the login // this returns immediately instead of holding an observer open for ten // seconds on every navigation. - if (document.querySelector(FORM) || document.readyState !== 'complete') { + if ( + document.querySelector(config.selectors.form) || + document.readyState !== 'complete' + ) { await autoLogin(config); } } diff --git a/src/injected.ts b/src/injected.ts index faf3e40..32c204e 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -37,8 +37,8 @@ // before that handler exists would trigger a plain browser form POST without // the CSRF header, which fails. -import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts'; -import type { FillRequest, FillResult } from './portal.ts'; +import { EVENT_RESULT } from './portal.ts'; +import type { FillRequest, FillResult, Selectors } from './portal.ts'; const POLL_INTERVAL_MS = 100; const POLL_TIMEOUT_MS = 15000; @@ -56,10 +56,10 @@ function report(result: FillResult): void { } /** jQuery, the form, and (when submitting) the page's own submit handler. */ -function readiness(needSubmit: boolean): JQueryLike | null { +function readiness(selectors: Selectors, needSubmit: boolean): JQueryLike | null { const $ = (window as unknown as { jQuery?: JQueryLike }).jQuery; if (!$) return null; - const form = $(FORM); + const form = $(selectors.form); if (!form.length) return null; if (needSubmit) { // jQuery keeps its handler registry in the private _data store. If the @@ -71,11 +71,11 @@ function readiness(needSubmit: boolean): JQueryLike | null { return $; } -function waitFor(needSubmit: boolean): Promise { +function waitFor(selectors: Selectors, needSubmit: boolean): Promise { return new Promise((resolve, reject) => { const deadline = Date.now() + POLL_TIMEOUT_MS; const tick = (): void => { - const $ = readiness(needSubmit); + const $ = readiness(selectors, needSubmit); if ($) { resolve($); return; @@ -110,13 +110,14 @@ function setField($: JQueryLike, element: HTMLInputElement, value: string): void } async function run(request: FillRequest): Promise { - const $ = await waitFor(request.submit); - const form = $(FORM); + const { selectors } = request; + const $ = await waitFor(selectors, request.submit); + const form = $(selectors.form); const targets: Array<[string, string]> = [ - [FIELDS.ra, request.ra], - [FIELDS.dn, request.dn], - [FIELDS.cpf, request.cpf], + [selectors.ra, request.ra], + [selectors.dn, request.dn], + [selectors.cpf, request.cpf], ]; for (const [selector, value] of targets) { @@ -135,7 +136,7 @@ async function run(request: FillRequest): Promise { return; } - const button = form.find(SUBMIT)[0] as HTMLButtonElement | undefined; + const button = form.find(selectors.submit)[0] as HTMLButtonElement | undefined; if (!button) { report({ filled: true, submitted: false, error: 'missing submit button' }); return; diff --git a/src/manifest.json b/src/manifest.json index a3dfcb6..ad2c188 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,8 +1,10 @@ { "manifest_version": 3, "name": "logsdu", - "version": "0.1.0", - "description": "Fills and submits a three-field academic portal login.", + "version": "0.2.0", + "description": "Saves and fills logins that password managers cannot: registration number, date of birth and document number.", + "author": "Ruben Carlo Benante (Dr. Beco)", + "homepage_url": "https://code.beco.cc/beco/logsdu", "icons": { "48": "icons/logsdu.svg", "96": "icons/logsdu.svg" diff --git a/src/options.html b/src/options.html index f768dd0..6808f2b 100644 --- a/src/options.html +++ b/src/options.html @@ -39,6 +39,39 @@ Entrar automaticamente (no máximo uma tentativa por hora) +
+ Ajustes avançados: como encontrar o formulário +

+ Seletores CSS usados para localizar os campos na página. Os + valores padrão servem para os portais mais comuns; mude-os + apenas se o preenchimento não funcionar no seu. +

+ + + + + + + + + + + + + + + + + + + +
+ +
+
+
@@ -46,10 +79,18 @@
+

Permissão de acesso

+

+ Ao salvar, o navegador pergunta se a extensão pode ler o endereço que + você informou. Ela não pede acesso a nenhum outro site, e a permissão + pode ser revogada a qualquer momento em + about:addons → Permissões. +

+

Como isso é guardado

- Os quatro valores ficam na memória local desta extensão, neste perfil - do navegador. Não são sincronizados nem enviados para lugar nenhum. A + Os valores ficam na memória local desta extensão, neste perfil do + navegador. Não são sincronizados nem enviados para lugar nenhum. A proteção é a mesma de uma senha guardada no navegador: quem tiver a sua sessão do sistema aberta consegue lê-los.

diff --git a/src/options.ts b/src/options.ts index 2d5b1fe..bceb940 100644 --- a/src/options.ts +++ b/src/options.ts @@ -19,7 +19,13 @@ // * rcb@beco.cc * // ************************************************************************* -import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config.ts'; +import { + EMPTY_CONFIG, + loadConfig, + resetState, + saveConfig, + sitePattern, +} from './config.ts'; import { formatCpf, formatDate, @@ -29,6 +35,8 @@ import { isCompleteRa, toOrigin, } from './format.ts'; +import { DEFAULT_SELECTORS, SELECTOR_KEYS, isValidSelector } from './portal.ts'; +import type { Selectors } from './portal.ts'; function el(id: string): T { const found = document.getElementById(id); @@ -46,6 +54,20 @@ const autoSubmit = el('autoSubmit'); const status = el('status'); const form = el('form'); +const selectorFields = Object.fromEntries( + SELECTOR_KEYS.map((key) => [key, el(`sel-${key}`)]), +) as Record; + +function readSelectors(): Selectors { + return Object.fromEntries( + SELECTOR_KEYS.map((key) => [key, selectorFields[key].value.trim()]), + ) as unknown as Selectors; +} + +function writeSelectors(selectors: Selectors): void { + for (const key of SELECTOR_KEYS) selectorFields[key].value = selectors[key]; +} + function setStatus(message: string, isError = false): void { status.textContent = message; status.classList.toggle('error', isError); @@ -79,6 +101,7 @@ async function load(): Promise { fields.dn.value = config.dn; fields.cpf.value = config.cpf; autoSubmit.checked = config.autoSubmit; + writeSelectors(config.selectors); } /** Mark the offending inputs and return the first complaint, if any. */ @@ -102,6 +125,18 @@ function validate(): string | null { fields.cpf.classList.add('invalid'); return 'CPF incompleto.'; } + + // A selector that the browser cannot parse would never match anything, and + // the failure would show up much later as "nothing happened" on the portal. + for (const key of SELECTOR_KEYS) { + const input = selectorFields[key]; + input.classList.remove('invalid'); + if (!isValidSelector(input.value)) { + input.classList.add('invalid'); + el('advanced').open = true; + return 'Seletor CSS inválido nos ajustes avançados.'; + } + } return null; } @@ -113,25 +148,58 @@ form.addEventListener('submit', (event) => { return; } - void (async () => { - await saveConfig({ - url: fields.url.value.trim(), - ra: fields.ra.value, - dn: fields.dn.value, - cpf: fields.cpf.value, - autoSubmit: autoSubmit.checked, + const config = { + url: fields.url.value.trim(), + ra: fields.ra.value, + dn: fields.dn.value, + cpf: fields.cpf.value, + autoSubmit: autoSubmit.checked, + selectors: readSelectors(), + }; + + const pattern = sitePattern(config); + if (pattern === null) { + setStatus('Endereço inválido.', true); + return; + } + + // permissions.request() must be the FIRST async call in this handler. + // Firefox only honours it while the user gesture from the click is still + // active, and awaiting anything beforehand -- even a storage write -- + // discards the gesture and the prompt is refused. + void chrome.permissions + .request({ origins: [pattern] }) + .then(async (granted) => { + if (!granted) { + setStatus('Permissão negada: a extensão não pode agir nesse site.', true); + return; + } + await saveConfig(config); + // Saving is how you correct a typo, so it also clears the hourly + // limit and the logout cooldown: the next visit may try again. + await resetState(); + setStatus('Salvo.'); + }) + .catch((error: unknown) => { + setStatus(`Falha ao salvar: ${String(error)}`, true); }); - // Saving is how you correct a typo, so it also clears the hourly limit - // and the logout cooldown: the next visit is allowed to try again. - await resetState(); - setStatus('Salvo.'); - })(); +}); + +el('defaults').addEventListener('click', () => { + writeSelectors(DEFAULT_SELECTORS); + setStatus('Padrões restaurados. Salve para aplicar.'); }); el('clear').addEventListener('click', () => { void (async () => { - await saveConfig({ ...EMPTY_CONFIG }); + const previous = sitePattern(await loadConfig()); + await saveConfig({ ...EMPTY_CONFIG, selectors: { ...DEFAULT_SELECTORS } }); await resetState(); + // Hand the site permission back as well. Leaving it granted after the + // user has wiped their data would keep access they no longer use. + if (previous !== null) { + await chrome.permissions.remove({ origins: [previous] }).catch(() => false); + } await load(); setStatus('Dados apagados.'); })(); diff --git a/src/popup.ts b/src/popup.ts index 3d9920b..b7a4ab1 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -23,7 +23,7 @@ // the automatic submit is switched off, rate limited, or suppressed after a // logout. -import { isConfigured, loadConfig, matchesSite } from './config.ts'; +import { isConfigured, loadConfig, matchesSite, sitePattern } from './config.ts'; const state = document.getElementById('state') as HTMLParagraphElement; const detail = document.getElementById('detail') as HTMLParagraphElement; @@ -50,6 +50,18 @@ async function refresh(): Promise { return; } + // Configured but not permitted is its own state, and the one most likely to + // look like a bug: everything is filled in, yet nothing ever happens. + const pattern = sitePattern(config); + const granted = + pattern !== null && (await chrome.permissions.contains({ origins: [pattern] })); + if (!granted) { + state.textContent = 'Sem permissão'; + detail.textContent = + 'A extensão ainda não tem acesso ao site. Abra as opções e salve novamente para conceder.'; + return; + } + const tabId = await activeTabOnSite(); if (tabId === null) { state.textContent = 'Configurado';