version 0.2 ready for upload
This commit is contained in:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -29,11 +29,17 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
# Build output. The unpacked extension and the signing package are release
|
# Unpacked build output. Regenerated by "make", never edited by hand.
|
||||||
# artifacts, not sources.
|
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# Intermediate packages.
|
||||||
logsdu-*.zip
|
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
|
# Exclude sourcemaps
|
||||||
*.map
|
*.map
|
||||||
|
|||||||
20
Makefile
20
Makefile
@@ -45,7 +45,7 @@ EXT_ID := logsdu
|
|||||||
VERSION := $(shell node -p "require('./package.json').version")
|
VERSION := $(shell node -p "require('./package.json').version")
|
||||||
XPI := $(EXT_ID)-$(VERSION).xpi
|
XPI := $(EXT_ID)-$(VERSION).xpi
|
||||||
|
|
||||||
.PHONY: all build test xpi clean check-deps
|
.PHONY: all build chrome test xpi clean check-deps
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@@ -61,6 +61,13 @@ build: check-deps
|
|||||||
node_modules/.bin/tsc -noEmit -skipLibCheck
|
node_modules/.bin/tsc -noEmit -skipLibCheck
|
||||||
node esbuild.config.mjs production
|
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
|
# 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
|
# top level rather than inside a wrapper folder. The same file installs
|
||||||
# directly on ESR and uploads to AMO for signing.
|
# directly on ESR and uploads to AMO for signing.
|
||||||
@@ -70,12 +77,15 @@ xpi: build
|
|||||||
@echo
|
@echo
|
||||||
@echo "Built: $(CURDIR)/$(XPI)"
|
@echo "Built: $(CURDIR)/$(XPI)"
|
||||||
@echo
|
@echo
|
||||||
@echo "To install, in Firefox:"
|
@echo "This file is UNSIGNED. Two ways to use it:"
|
||||||
@echo " 1. about:config -> set xpinstall.signatures.required = false"
|
@echo
|
||||||
@echo " (only ESR, Developer Edition and Nightly honour this)"
|
@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 " 2. about:addons -> gear icon -> Install Add-on From File"
|
||||||
@echo " 3. paste this path into the file picker:"
|
@echo " 3. paste this path into the file picker:"
|
||||||
@echo
|
|
||||||
@echo " $(CURDIR)/$(XPI)"
|
@echo " $(CURDIR)/$(XPI)"
|
||||||
@echo
|
@echo
|
||||||
|
|
||||||
|
|||||||
193
README.md
193
README.md
@@ -1,67 +1,81 @@
|
|||||||
# logsdu
|
# logsdu
|
||||||
|
|
||||||
A Firefox extension that logs you into an academic portal whose sign-in form is
|
A Firefox extension for logins that password managers cannot save: the ones
|
||||||
three fields -- registration number, birth date and national ID -- rather than
|
made of a registration number, a date of birth and a document number, instead
|
||||||
the usual user and password.
|
of a username and a password.
|
||||||
|
|
||||||
That shape defeats password managers. The form is marked `autocomplete="off"`,
|
Academic portals do this a lot. The form is usually marked `autocomplete="off"`,
|
||||||
none of the three inputs is a `password` field, and Bitwarden, Firefox and
|
none of the fields is a `password` field, and Bitwarden, Firefox and Chrome all
|
||||||
Chrome all decline to remember it. Since the values never change and the
|
decline to remember it. When the values never change and the institution offers
|
||||||
institution does not let you pick a different login method, the only option
|
no other way in, the only option left is copying three values by hand, every
|
||||||
left is copying three values by hand, every time.
|
single time -- including the registration number nobody has memorised.
|
||||||
|
|
||||||
logsdu stores them once and fills them in. By default it also presses "Entrar",
|
logsdu stores them once and fills them in. By default it also presses the submit
|
||||||
so the normal case is zero clicks.
|
button, so the normal case is zero clicks.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below).
|
Once it is published, from addons.mozilla.org. Until then, build it yourself:
|
||||||
Node 22+ ships `corepack`, so pnpm does not have to be installed globally.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
corepack pnpm install # first time, or after a dependency change
|
corepack pnpm install # first time, or after a dependency change
|
||||||
make xpi # typecheck, bundle, and package logsdu-<version>.xpi
|
make xpi # typecheck, bundle, and package logsdu-<version>.xpi
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install it permanently. Firefox will not load an unsigned add-on unless
|
`make xpi` prints the full path of the file and how to install it. The package
|
||||||
you tell it to, and only the ESR, Developer Edition and Nightly builds accept
|
it produces is unsigned, and Firefox only accepts unsigned add-ons on the ESR,
|
||||||
being told:
|
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
|
Dependencies are managed with **pnpm**, never npm. See "Why pnpm" below.
|
||||||
2. Set `xpinstall.signatures.required` to **false**
|
|
||||||
3. Open `about:addons`, click the **gear** icon, choose **Install Add-on From
|
|
||||||
File**, and pick `logsdu-<version>.xpi`
|
|
||||||
|
|
||||||
It survives restarts. On **release** Firefox that pref is ignored -- see
|
## Setting it up
|
||||||
"Release Firefox" below.
|
|
||||||
|
|
||||||
Finally, open the extension's options page, fill in the four values, and save.
|
Open the extension's options page and fill in four values:
|
||||||
|
|
||||||
## The four values
|
|
||||||
|
|
||||||
| Field | Example | Notes |
|
| Field | Example | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored |
|
| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored |
|
||||||
| Registration number | `2000101010` | Digits only |
|
| Registration number | `2000101010` | Digits only |
|
||||||
| Birth date | `01/01/2000` | Reformatted as you type |
|
| Date of birth | `01/01/2000` | Reformatted as you type |
|
||||||
| National ID | `000.000.000-00` | 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
|
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
|
When you press Save, Firefox asks whether logsdu may access the address you
|
||||||
anywhere in the extension: not in the manifest, not in the source, not in the
|
entered. That prompt names one site. Accept it and the extension starts working
|
||||||
build output. Be clear about what that does and does not buy you. It stops
|
there; decline and nothing is stored as usable.
|
||||||
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
|
If the portal's markup differs from the common shape, open **Ajustes avançados**
|
||||||
is the same access that would expose your national ID and birth date anyway.
|
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
|
## How it behaves
|
||||||
|
|
||||||
- **Fills and submits** on the login page, with no interaction.
|
- **Fills and submits** on the login page, with no interaction.
|
||||||
- **At most one automatic submit per hour.** After an attempt, the extension
|
- **At most one automatic submit per hour.** After an attempt it drops back to
|
||||||
drops back to filling only, so a wrong value cannot resubmit itself on every
|
filling only, so a wrong value cannot resubmit itself on every page load and
|
||||||
page load and lock you out. Correct the values and save; saving clears the
|
lock you out of your account. Correct the values and save; saving clears the
|
||||||
timer, so the next visit tries again immediately.
|
timer, so the next visit tries again immediately.
|
||||||
- **Logging out keeps you logged out.** Clicking the portal's logout control
|
- **Logging out keeps you logged out.** Clicking the portal's logout control
|
||||||
suppresses the automatic submit for five minutes -- otherwise the logout
|
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.
|
submitting, for when you want to check the values before sending them.
|
||||||
- Automatic submission can be turned off entirely in the options.
|
- 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
|
Nowhere. It is written to `storage.local`: private to your browser profile,
|
||||||
anywhere. That is the same protection a browser-saved password gets, and it has
|
never synced, never transmitted. The extension makes no network requests of its
|
||||||
the same limit -- anyone with your unlocked account can read it. If that is not
|
own and contains no analytics.
|
||||||
good enough for your threat model, this extension is the wrong tool.
|
|
||||||
|
|
||||||
## 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
|
The interface is in Portuguese, matching the portals it was written for.
|
||||||
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 `browser_specific_settings.gecko.id` in the manifest is what gives the
|
## Publishing
|
||||||
add-on a stable identity across both routes, so settings survive an upgrade
|
|
||||||
from one to the other.
|
`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
|
## Chrome
|
||||||
|
|
||||||
Not yet. The source deliberately avoids anything Firefox-specific: it uses the
|
`make chrome` builds it. The only difference is the background key: Firefox MV3
|
||||||
`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build
|
uses an event page, Chrome MV3 requires a service worker, so the manifest is
|
||||||
should be a manifest question rather than a rewrite. It has not been tried, so
|
generated per target rather than duplicated. Everything else -- `chrome.*`
|
||||||
do not assume it works.
|
namespace, MV3, no Firefox-only APIs -- is already shared.
|
||||||
|
|
||||||
|
It has not been tested against Chrome. Do not assume it works.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -109,54 +135,49 @@ corepack pnpm run dev # rebuild on change
|
|||||||
make clean
|
make clean
|
||||||
```
|
```
|
||||||
|
|
||||||
While iterating, reinstalling an `.xpi` for every edit is tedious. Load the
|
While iterating, load the unpacked directory rather than reinstalling an `.xpi`
|
||||||
unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load
|
each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** ->
|
||||||
Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after
|
`build/manifest.json`, then press **Reload** there after each rebuild. That copy
|
||||||
each rebuild. That copy disappears on restart, which is the point -- it is for
|
disappears on restart, which is the point -- it is for development, not daily
|
||||||
development, not for daily use.
|
use.
|
||||||
|
|
||||||
`corepack pnpm run dev` watches and rebuilds, static files included, but
|
`corepack pnpm run dev` watches and rebuilds, static files included, but Firefox
|
||||||
Firefox still needs the Reload click to pick anything up.
|
still needs the Reload click to pick anything up.
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
| Path | Role |
|
| 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/content.ts` | Isolated world. Decides whether to act, then delegates |
|
||||||
| `src/injected.ts` | Page world. Does the actual filling and clicking |
|
| `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/config.ts` | Stored values, the rate limit and the logout cooldown |
|
||||||
| `src/format.ts` | Input normalisers for the three masked fields |
|
| `src/format.ts` | Input normalisers for the three masked fields |
|
||||||
| `src/options.*`, `src/popup.*` | The two bits of UI |
|
| `src/options.*`, `src/popup.*` | The two bits of UI |
|
||||||
|
|
||||||
### Why two scripts instead of one
|
### Why two scripts instead of one
|
||||||
|
|
||||||
The portal drives its inputs with Inputmask, which replaces each element's
|
These portals drive their inputs with Inputmask, which replaces each element's
|
||||||
`value` property with its own accessor. A content script assigning
|
`value` property with its own accessor. A content script assigning `input.value`
|
||||||
`input.value` from the isolated world writes through Xrays to the *native*
|
from the isolated world writes through Xrays to the *native* setter and skips
|
||||||
setter and skips that accessor: the field looks right on screen, but the mask's
|
that accessor: the field looks right on screen, but the mask's buffer is
|
||||||
buffer is unchanged, and the page's submit handler reads the stale buffer back
|
unchanged, and the page's submit handler reads the stale buffer back out through
|
||||||
out through jQuery. So the filling happens inside the page, through the page's
|
jQuery. So the filling happens inside the page, through the page's own jQuery
|
||||||
own jQuery and Inputmask.
|
and Inputmask, in `injected.ts`.
|
||||||
|
|
||||||
Submitting is a real click on the button, never `form.submit()`. The portal
|
Submitting is a real click on the button, never `form.submit()`. These portals
|
||||||
intercepts the submit event, cancels it, and posts by AJAX with a CSRF token
|
intercept the submit event, cancel it, and post by AJAX with a CSRF token taken
|
||||||
taken from a meta tag. `form.submit()` would bypass that handler and lose the
|
from a meta tag. `form.submit()` would bypass that handler and lose the token; a
|
||||||
token; a click reproduces exactly what a person pressing "Entrar" does.
|
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
|
Hardcoding them would tie the extension to one institution while pretending to
|
||||||
pattern -- putting it there is exactly what would name the institution in the
|
be general. They live in `portal.ts` as defaults and can be overridden per
|
||||||
shipped code. The script therefore loads everywhere and stops immediately
|
installation, so the same build works for any portal of this shape -- and no
|
||||||
unless the page's origin equals the configured one. Origins are compared whole,
|
institution is named anywhere in the source, the manifest or the build output.
|
||||||
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.
|
|
||||||
|
|
||||||
### Why pnpm, not npm
|
### Why pnpm, not npm
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
import esbuild from 'esbuild';
|
import esbuild from 'esbuild';
|
||||||
import process from 'process';
|
import process from 'process';
|
||||||
import { cp, mkdir, readdir } from 'node:fs/promises';
|
import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
const banner = `/*
|
const banner = `/*
|
||||||
* logsdu - fills and submits a three-field academic portal login.
|
* logsdu - fills and submits a three-field academic portal login.
|
||||||
@@ -37,16 +37,40 @@ const banner = `/*
|
|||||||
const prod = process.argv[2] === 'production';
|
const prod = process.argv[2] === 'production';
|
||||||
const outdir = 'build';
|
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
|
// Everything that is not TypeScript is copied verbatim into build/, so that
|
||||||
// the directory can be handed straight to about:debugging.
|
// the directory can be handed straight to about:debugging.
|
||||||
async function copyStatic() {
|
async function copyStatic() {
|
||||||
await mkdir(outdir, { recursive: true });
|
await mkdir(outdir, { recursive: true });
|
||||||
for (const name of await readdir('src')) {
|
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(`src/${name}`, `${outdir}/${name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await cp('icons', `${outdir}/icons`, { recursive: true });
|
await cp('icons', `${outdir}/icons`, { recursive: true });
|
||||||
|
await writeManifest();
|
||||||
}
|
}
|
||||||
|
|
||||||
await copyStatic();
|
await copyStatic();
|
||||||
@@ -65,10 +89,11 @@ const context = await esbuild.context({
|
|||||||
js: banner,
|
js: banner,
|
||||||
},
|
},
|
||||||
plugins: [staticPlugin],
|
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
|
// time. Content scripts and page-world scripts cannot be ES modules, so
|
||||||
// every bundle has to stand alone.
|
// every bundle has to stand alone.
|
||||||
entryPoints: [
|
entryPoints: [
|
||||||
|
'src/background.ts',
|
||||||
'src/content.ts',
|
'src/content.ts',
|
||||||
'src/injected.ts',
|
'src/injected.ts',
|
||||||
'src/options.ts',
|
'src/options.ts',
|
||||||
@@ -81,7 +106,14 @@ const context = await esbuild.context({
|
|||||||
sourcemap: prod ? false : 'inline',
|
sourcemap: prod ? false : 'inline',
|
||||||
treeShaking: true,
|
treeShaking: true,
|
||||||
outdir,
|
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) {
|
if (prod) {
|
||||||
|
|||||||
BIN
logsdu-0.2.0.xpi
Normal file
BIN
logsdu-0.2.0.xpi
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "logsdu",
|
"name": "logsdu",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Browser extension that fills and submits a three-field academic portal login.",
|
"description": "Browser extension that fills and submits a three-field academic portal login.",
|
||||||
"author": "Ruben Carlo Benante <rcb@beco.cc>",
|
"author": "Ruben Carlo Benante <rcb@beco.cc>",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ import {
|
|||||||
decideSubmit,
|
decideSubmit,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
matchesSite,
|
matchesSite,
|
||||||
|
sitePattern,
|
||||||
|
withDefaults,
|
||||||
} from './config.ts';
|
} from './config.ts';
|
||||||
import type { Config, State } from './config.ts';
|
import type { Config, State } from './config.ts';
|
||||||
|
import { DEFAULT_SELECTORS } from './portal.ts';
|
||||||
|
|
||||||
const CONFIG: Config = {
|
const CONFIG: Config = {
|
||||||
url: 'https://portal.example.br/',
|
url: 'https://portal.example.br/',
|
||||||
@@ -37,6 +40,7 @@ const CONFIG: Config = {
|
|||||||
dn: '01/02/1999',
|
dn: '01/02/1999',
|
||||||
cpf: '123.456.789-01',
|
cpf: '123.456.789-01',
|
||||||
autoSubmit: true,
|
autoSubmit: true,
|
||||||
|
selectors: { ...DEFAULT_SELECTORS },
|
||||||
};
|
};
|
||||||
|
|
||||||
const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 };
|
const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 };
|
||||||
@@ -51,6 +55,42 @@ test('isConfigured requires all four values', () => {
|
|||||||
assert.equal(isConfigured({ ...CONFIG, cpf: '' }), false);
|
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', () => {
|
test('matchesSite compares origins, not prefixes', () => {
|
||||||
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true);
|
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true);
|
||||||
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true);
|
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true);
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export interface Config {
|
|||||||
dn: string;
|
dn: string;
|
||||||
cpf: string;
|
cpf: string;
|
||||||
autoSubmit: boolean;
|
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. */
|
/** Bookkeeping that enforces the rate limit and the logout cooldown. */
|
||||||
@@ -61,13 +66,29 @@ export const EMPTY_CONFIG: Config = {
|
|||||||
dn: '',
|
dn: '',
|
||||||
cpf: '',
|
cpf: '',
|
||||||
autoSubmit: true,
|
autoSubmit: true,
|
||||||
|
selectors: { ...DEFAULT_SELECTORS },
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_STATE: State = { lastSubmitAt: 0, logoutAt: 0 };
|
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<Config> | undefined): Config {
|
||||||
|
return {
|
||||||
|
...EMPTY_CONFIG,
|
||||||
|
...(stored ?? {}),
|
||||||
|
selectors: { ...DEFAULT_SELECTORS, ...(stored?.selectors ?? {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadConfig(): Promise<Config> {
|
export async function loadConfig(): Promise<Config> {
|
||||||
const stored = await chrome.storage.local.get(CONFIG_KEY);
|
const stored = await chrome.storage.local.get(CONFIG_KEY);
|
||||||
return { ...EMPTY_CONFIG, ...((stored[CONFIG_KEY] as Partial<Config>) ?? {}) };
|
return withDefaults(stored[CONFIG_KEY] as Partial<Config> | undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveConfig(config: Config): Promise<void> {
|
export async function saveConfig(config: Config): Promise<void> {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
saveState,
|
saveState,
|
||||||
} from './config.ts';
|
} from './config.ts';
|
||||||
import type { Config } 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';
|
import type { FillRequest, FillResult } from './portal.ts';
|
||||||
|
|
||||||
const FORM_WAIT_MS = 10000;
|
const FORM_WAIT_MS = 10000;
|
||||||
@@ -76,13 +76,13 @@ function fill(request: FillRequest): Promise<FillResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve once the login form exists, or null if it never shows up. */
|
/** Resolve once the login form exists, or null if it never shows up. */
|
||||||
function awaitForm(): Promise<HTMLFormElement | null> {
|
function awaitForm(formSelector: string): Promise<HTMLFormElement | null> {
|
||||||
const existing = document.querySelector<HTMLFormElement>(FORM);
|
const existing = document.querySelector<HTMLFormElement>(formSelector);
|
||||||
if (existing) return Promise.resolve(existing);
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
const found = document.querySelector<HTMLFormElement>(FORM);
|
const found = document.querySelector<HTMLFormElement>(formSelector);
|
||||||
if (found) {
|
if (found) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
window.clearTimeout(timer);
|
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
|
* Without this the logout redirect lands on the login page and the extension
|
||||||
* immediately logs them back in, which makes logging out impossible.
|
* immediately logs them back in, which makes logging out impossible.
|
||||||
*/
|
*/
|
||||||
function watchLogout(): void {
|
function watchLogout(logoutSelector: string): void {
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'click',
|
'click',
|
||||||
(event) => {
|
(event) => {
|
||||||
const target = event.target as Element | null;
|
const target = event.target as Element | null;
|
||||||
if (!target?.closest?.(LOGOUT)) return;
|
if (!target?.closest?.(logoutSelector)) return;
|
||||||
void loadState().then((state) =>
|
void loadState().then((state) =>
|
||||||
saveState({ ...state, logoutAt: Date.now() }),
|
saveState({ ...state, logoutAt: Date.now() }),
|
||||||
);
|
);
|
||||||
@@ -143,7 +143,7 @@ function watchLogout(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function autoLogin(config: Config): Promise<void> {
|
async function autoLogin(config: Config): Promise<void> {
|
||||||
const form = await awaitForm();
|
const form = await awaitForm(config.selectors.form);
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const state = await loadState();
|
const state = await loadState();
|
||||||
@@ -161,6 +161,7 @@ async function autoLogin(config: Config): Promise<void> {
|
|||||||
dn: config.dn,
|
dn: config.dn,
|
||||||
cpf: config.cpf,
|
cpf: config.cpf,
|
||||||
submit: decision.submit,
|
submit: decision.submit,
|
||||||
|
selectors: config.selectors,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -177,7 +178,7 @@ async function main(): Promise<void> {
|
|||||||
if (!isConfigured(config)) return;
|
if (!isConfigured(config)) return;
|
||||||
if (!matchesSite(config, location.href)) return;
|
if (!matchesSite(config, location.href)) return;
|
||||||
|
|
||||||
watchLogout();
|
watchLogout(config.selectors.logout);
|
||||||
|
|
||||||
// The popup's "Preencher agora" button, for when the automatic submit is
|
// The popup's "Preencher agora" button, for when the automatic submit is
|
||||||
// off or rate limited.
|
// off or rate limited.
|
||||||
@@ -190,6 +191,7 @@ async function main(): Promise<void> {
|
|||||||
dn: fresh.dn,
|
dn: fresh.dn,
|
||||||
cpf: fresh.cpf,
|
cpf: fresh.cpf,
|
||||||
submit: (message as { submit?: boolean }).submit === true,
|
submit: (message as { submit?: boolean }).submit === true,
|
||||||
|
selectors: fresh.selectors,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.then(sendResponse);
|
.then(sendResponse);
|
||||||
@@ -200,7 +202,10 @@ async function main(): Promise<void> {
|
|||||||
// still loading and could still produce one. On the pages behind the login
|
// still loading and could still produce one. On the pages behind the login
|
||||||
// this returns immediately instead of holding an observer open for ten
|
// this returns immediately instead of holding an observer open for ten
|
||||||
// seconds on every navigation.
|
// seconds on every navigation.
|
||||||
if (document.querySelector(FORM) || document.readyState !== 'complete') {
|
if (
|
||||||
|
document.querySelector(config.selectors.form) ||
|
||||||
|
document.readyState !== 'complete'
|
||||||
|
) {
|
||||||
await autoLogin(config);
|
await autoLogin(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
// before that handler exists would trigger a plain browser form POST without
|
// before that handler exists would trigger a plain browser form POST without
|
||||||
// the CSRF header, which fails.
|
// the CSRF header, which fails.
|
||||||
|
|
||||||
import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts';
|
import { EVENT_RESULT } from './portal.ts';
|
||||||
import type { FillRequest, FillResult } from './portal.ts';
|
import type { FillRequest, FillResult, Selectors } from './portal.ts';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 100;
|
const POLL_INTERVAL_MS = 100;
|
||||||
const POLL_TIMEOUT_MS = 15000;
|
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. */
|
/** 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;
|
const $ = (window as unknown as { jQuery?: JQueryLike }).jQuery;
|
||||||
if (!$) return null;
|
if (!$) return null;
|
||||||
const form = $(FORM);
|
const form = $(selectors.form);
|
||||||
if (!form.length) return null;
|
if (!form.length) return null;
|
||||||
if (needSubmit) {
|
if (needSubmit) {
|
||||||
// jQuery keeps its handler registry in the private _data store. If the
|
// jQuery keeps its handler registry in the private _data store. If the
|
||||||
@@ -71,11 +71,11 @@ function readiness(needSubmit: boolean): JQueryLike | null {
|
|||||||
return $;
|
return $;
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitFor(needSubmit: boolean): Promise<JQueryLike> {
|
function waitFor(selectors: Selectors, needSubmit: boolean): Promise<JQueryLike> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||||
const tick = (): void => {
|
const tick = (): void => {
|
||||||
const $ = readiness(needSubmit);
|
const $ = readiness(selectors, needSubmit);
|
||||||
if ($) {
|
if ($) {
|
||||||
resolve($);
|
resolve($);
|
||||||
return;
|
return;
|
||||||
@@ -110,13 +110,14 @@ function setField($: JQueryLike, element: HTMLInputElement, value: string): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run(request: FillRequest): Promise<void> {
|
async function run(request: FillRequest): Promise<void> {
|
||||||
const $ = await waitFor(request.submit);
|
const { selectors } = request;
|
||||||
const form = $(FORM);
|
const $ = await waitFor(selectors, request.submit);
|
||||||
|
const form = $(selectors.form);
|
||||||
|
|
||||||
const targets: Array<[string, string]> = [
|
const targets: Array<[string, string]> = [
|
||||||
[FIELDS.ra, request.ra],
|
[selectors.ra, request.ra],
|
||||||
[FIELDS.dn, request.dn],
|
[selectors.dn, request.dn],
|
||||||
[FIELDS.cpf, request.cpf],
|
[selectors.cpf, request.cpf],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [selector, value] of targets) {
|
for (const [selector, value] of targets) {
|
||||||
@@ -135,7 +136,7 @@ async function run(request: FillRequest): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = form.find(SUBMIT)[0] as HTMLButtonElement | undefined;
|
const button = form.find(selectors.submit)[0] as HTMLButtonElement | undefined;
|
||||||
if (!button) {
|
if (!button) {
|
||||||
report({ filled: true, submitted: false, error: 'missing submit button' });
|
report({ filled: true, submitted: false, error: 'missing submit button' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "logsdu",
|
"name": "logsdu",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Fills and submits a three-field academic portal login.",
|
"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": {
|
"icons": {
|
||||||
"48": "icons/logsdu.svg",
|
"48": "icons/logsdu.svg",
|
||||||
"96": "icons/logsdu.svg"
|
"96": "icons/logsdu.svg"
|
||||||
|
|||||||
@@ -39,6 +39,39 @@
|
|||||||
<span>Entrar automaticamente (no máximo uma tentativa por hora)</span>
|
<span>Entrar automaticamente (no máximo uma tentativa por hora)</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<details id="advanced">
|
||||||
|
<summary>Ajustes avançados: como encontrar o formulário</summary>
|
||||||
|
<p class="hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="sel-form">Formulário de login</label>
|
||||||
|
<input id="sel-form" type="text" spellcheck="false" />
|
||||||
|
|
||||||
|
<label for="sel-ra">Campo da matrícula</label>
|
||||||
|
<input id="sel-ra" type="text" spellcheck="false" />
|
||||||
|
|
||||||
|
<label for="sel-dn">Campo da data de nascimento</label>
|
||||||
|
<input id="sel-dn" type="text" spellcheck="false" />
|
||||||
|
|
||||||
|
<label for="sel-cpf">Campo do CPF</label>
|
||||||
|
<input id="sel-cpf" type="text" spellcheck="false" />
|
||||||
|
|
||||||
|
<label for="sel-submit">Botão de entrar</label>
|
||||||
|
<input id="sel-submit" type="text" spellcheck="false" />
|
||||||
|
|
||||||
|
<label for="sel-logout">Botão de sair</label>
|
||||||
|
<input id="sel-logout" type="text" spellcheck="false" />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button id="defaults" type="button" class="ghost">
|
||||||
|
Restaurar padrões
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="save" type="submit">Salvar</button>
|
<button id="save" type="submit">Salvar</button>
|
||||||
<button id="clear" type="button" class="ghost">Apagar dados</button>
|
<button id="clear" type="button" class="ghost">Apagar dados</button>
|
||||||
@@ -46,10 +79,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<h2>Permissão de acesso</h2>
|
||||||
|
<p class="hint">
|
||||||
|
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
|
||||||
|
<code>about:addons</code> → Permissões.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Como isso é guardado</h2>
|
<h2>Como isso é guardado</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Os quatro valores ficam na memória local desta extensão, neste perfil
|
Os valores ficam na memória local desta extensão, neste perfil do
|
||||||
do navegador. Não são sincronizados nem enviados para lugar nenhum. A
|
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
|
proteção é a mesma de uma senha guardada no navegador: quem tiver a
|
||||||
sua sessão do sistema aberta consegue lê-los.
|
sua sessão do sistema aberta consegue lê-los.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -19,7 +19,13 @@
|
|||||||
// * rcb@beco.cc *
|
// * rcb@beco.cc *
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
|
|
||||||
import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config.ts';
|
import {
|
||||||
|
EMPTY_CONFIG,
|
||||||
|
loadConfig,
|
||||||
|
resetState,
|
||||||
|
saveConfig,
|
||||||
|
sitePattern,
|
||||||
|
} from './config.ts';
|
||||||
import {
|
import {
|
||||||
formatCpf,
|
formatCpf,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -29,6 +35,8 @@ import {
|
|||||||
isCompleteRa,
|
isCompleteRa,
|
||||||
toOrigin,
|
toOrigin,
|
||||||
} from './format.ts';
|
} from './format.ts';
|
||||||
|
import { DEFAULT_SELECTORS, SELECTOR_KEYS, isValidSelector } from './portal.ts';
|
||||||
|
import type { Selectors } from './portal.ts';
|
||||||
|
|
||||||
function el<T extends HTMLElement>(id: string): T {
|
function el<T extends HTMLElement>(id: string): T {
|
||||||
const found = document.getElementById(id);
|
const found = document.getElementById(id);
|
||||||
@@ -46,6 +54,20 @@ const autoSubmit = el<HTMLInputElement>('autoSubmit');
|
|||||||
const status = el<HTMLSpanElement>('status');
|
const status = el<HTMLSpanElement>('status');
|
||||||
const form = el<HTMLFormElement>('form');
|
const form = el<HTMLFormElement>('form');
|
||||||
|
|
||||||
|
const selectorFields = Object.fromEntries(
|
||||||
|
SELECTOR_KEYS.map((key) => [key, el<HTMLInputElement>(`sel-${key}`)]),
|
||||||
|
) as Record<keyof Selectors, HTMLInputElement>;
|
||||||
|
|
||||||
|
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 {
|
function setStatus(message: string, isError = false): void {
|
||||||
status.textContent = message;
|
status.textContent = message;
|
||||||
status.classList.toggle('error', isError);
|
status.classList.toggle('error', isError);
|
||||||
@@ -79,6 +101,7 @@ async function load(): Promise<void> {
|
|||||||
fields.dn.value = config.dn;
|
fields.dn.value = config.dn;
|
||||||
fields.cpf.value = config.cpf;
|
fields.cpf.value = config.cpf;
|
||||||
autoSubmit.checked = config.autoSubmit;
|
autoSubmit.checked = config.autoSubmit;
|
||||||
|
writeSelectors(config.selectors);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark the offending inputs and return the first complaint, if any. */
|
/** Mark the offending inputs and return the first complaint, if any. */
|
||||||
@@ -102,6 +125,18 @@ function validate(): string | null {
|
|||||||
fields.cpf.classList.add('invalid');
|
fields.cpf.classList.add('invalid');
|
||||||
return 'CPF incompleto.';
|
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<HTMLDetailsElement>('advanced').open = true;
|
||||||
|
return 'Seletor CSS inválido nos ajustes avançados.';
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,25 +148,58 @@ form.addEventListener('submit', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
const config = {
|
||||||
await saveConfig({
|
|
||||||
url: fields.url.value.trim(),
|
url: fields.url.value.trim(),
|
||||||
ra: fields.ra.value,
|
ra: fields.ra.value,
|
||||||
dn: fields.dn.value,
|
dn: fields.dn.value,
|
||||||
cpf: fields.cpf.value,
|
cpf: fields.cpf.value,
|
||||||
autoSubmit: autoSubmit.checked,
|
autoSubmit: autoSubmit.checked,
|
||||||
});
|
selectors: readSelectors(),
|
||||||
// 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.
|
|
||||||
|
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();
|
await resetState();
|
||||||
setStatus('Salvo.');
|
setStatus('Salvo.');
|
||||||
})();
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
setStatus(`Falha ao salvar: ${String(error)}`, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('defaults').addEventListener('click', () => {
|
||||||
|
writeSelectors(DEFAULT_SELECTORS);
|
||||||
|
setStatus('Padrões restaurados. Salve para aplicar.');
|
||||||
});
|
});
|
||||||
|
|
||||||
el<HTMLButtonElement>('clear').addEventListener('click', () => {
|
el<HTMLButtonElement>('clear').addEventListener('click', () => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await saveConfig({ ...EMPTY_CONFIG });
|
const previous = sitePattern(await loadConfig());
|
||||||
|
await saveConfig({ ...EMPTY_CONFIG, selectors: { ...DEFAULT_SELECTORS } });
|
||||||
await resetState();
|
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();
|
await load();
|
||||||
setStatus('Dados apagados.');
|
setStatus('Dados apagados.');
|
||||||
})();
|
})();
|
||||||
|
|||||||
14
src/popup.ts
14
src/popup.ts
@@ -23,7 +23,7 @@
|
|||||||
// the automatic submit is switched off, rate limited, or suppressed after a
|
// the automatic submit is switched off, rate limited, or suppressed after a
|
||||||
// logout.
|
// logout.
|
||||||
|
|
||||||
import { isConfigured, loadConfig, matchesSite } from './config.ts';
|
import { isConfigured, loadConfig, matchesSite, sitePattern } from './config.ts';
|
||||||
|
|
||||||
const state = document.getElementById('state') as HTMLParagraphElement;
|
const state = document.getElementById('state') as HTMLParagraphElement;
|
||||||
const detail = document.getElementById('detail') as HTMLParagraphElement;
|
const detail = document.getElementById('detail') as HTMLParagraphElement;
|
||||||
@@ -50,6 +50,18 @@ async function refresh(): Promise<void> {
|
|||||||
return;
|
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();
|
const tabId = await activeTabOnSite();
|
||||||
if (tabId === null) {
|
if (tabId === null) {
|
||||||
state.textContent = 'Configurado';
|
state.textContent = 'Configurado';
|
||||||
|
|||||||
Reference in New Issue
Block a user