Compare commits
8 Commits
42638eaaf9
...
0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5176301116 | |||
| a7531914f9 | |||
| 17ad302a6c | |||
| 9826dc1263 | |||
| 8bf20af4bb | |||
| 4d85187fb5 | |||
| 5bd2552641 | |||
| d01238a9f1 |
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# **************************************************************************
|
||||||
|
# * (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 *
|
||||||
|
# **************************************************************************
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Intellij
|
||||||
|
*.iml
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Unpacked build output. Regenerated by "make", never edited by hand.
|
||||||
|
build
|
||||||
|
|
||||||
|
# dist/ holds the publishable packages and is NOT ignored: 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 it.
|
||||||
|
# Local unsigned builds land there too; "make clean" removes the directory.
|
||||||
|
|
||||||
|
# Exclude sourcemaps
|
||||||
|
*.map
|
||||||
|
|
||||||
|
# Exclude macOS Finder (System Explorer) View States
|
||||||
|
.DS_Store
|
||||||
166
Makefile
Normal file
166
Makefile
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# **************************************************************************
|
||||||
|
# * (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 *
|
||||||
|
# **************************************************************************
|
||||||
|
|
||||||
|
# Makefile for logsdu - build the extension for Firefox and Chrome.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make # everything: build and package both browsers
|
||||||
|
# make firefox # build Firefox only -> build/firefox/
|
||||||
|
# make chrome # build Chrome only -> build/chrome/
|
||||||
|
# make test # run the unit tests
|
||||||
|
# make smoke # check the background bundle works as a service worker
|
||||||
|
# make xpi # package Firefox -> dist/logsdu-<version>-firefox.xpi
|
||||||
|
# make crx # package Chrome -> dist/logsdu-<version>-chrome.zip
|
||||||
|
# make packages # both of the above (same as plain "make")
|
||||||
|
# make clean # remove build/, dist/ and stray packages
|
||||||
|
# make distclean # clean, plus node_modules/
|
||||||
|
#
|
||||||
|
# build/<target>/ holds the unpacked extension for one browser; the two targets
|
||||||
|
# never share a directory, so neither can be left stale by the other. dist/
|
||||||
|
# holds the packages meant to be published, and is kept out of build/ so that
|
||||||
|
# packaging never tries to include its own output.
|
||||||
|
#
|
||||||
|
# Dependencies are installed with pnpm, never npm:
|
||||||
|
# corepack pnpm install
|
||||||
|
#
|
||||||
|
# Load the unpacked build while developing:
|
||||||
|
# Firefox about:debugging -> This Firefox -> Load Temporary Add-on ->
|
||||||
|
# build/firefox/manifest.json (dropped when Firefox restarts)
|
||||||
|
# Chrome chrome://extensions -> Developer mode -> Load unpacked ->
|
||||||
|
# build/chrome/
|
||||||
|
#
|
||||||
|
# Publishing:
|
||||||
|
# Firefox upload dist/*-firefox.xpi at addons.mozilla.org
|
||||||
|
# Chrome upload dist/*-chrome.zip at chrome.google.com/webstore/devconsole
|
||||||
|
#
|
||||||
|
# Every package filename names its browser. The two are not interchangeable --
|
||||||
|
# they differ in the manifest's background key -- and uploading the wrong one
|
||||||
|
# fails in ways that are not obvious from the error.
|
||||||
|
|
||||||
|
EXT_ID := logsdu
|
||||||
|
VERSION := $(shell node -p "require('./package.json').version")
|
||||||
|
FIREFOX_DIR := build/firefox
|
||||||
|
CHROME_DIR := build/chrome
|
||||||
|
DIST := dist
|
||||||
|
XPI := $(DIST)/$(EXT_ID)-$(VERSION)-firefox.xpi
|
||||||
|
CRX := $(DIST)/$(EXT_ID)-$(VERSION)-chrome.zip
|
||||||
|
|
||||||
|
.PHONY: all firefox chrome typecheck test smoke xpi crx packages clean \
|
||||||
|
distclean check-deps
|
||||||
|
|
||||||
|
# The default does the lot: build both browsers and package both. Packaging is
|
||||||
|
# only a zip of a directory that was going to be built anyway, so making it the
|
||||||
|
# default costs nothing and means dist/ is never quietly out of date with src/.
|
||||||
|
all: packages
|
||||||
|
|
||||||
|
# Unit tests for the pure logic: the input formatters and the decision that
|
||||||
|
# says whether a page load may press "Entrar". Run straight through Node's
|
||||||
|
# built-in runner and type stripping, so there is no test framework to install.
|
||||||
|
test:
|
||||||
|
node --test "src/**/*.test.ts"
|
||||||
|
|
||||||
|
# Runs the built background bundle in a service-worker-shaped sandbox, which
|
||||||
|
# is where a Chrome-only breakage would otherwise hide until runtime. The
|
||||||
|
# bundle is identical for both targets, so checking one covers both.
|
||||||
|
smoke: chrome
|
||||||
|
node tools/sw-smoke.mjs $(CHROME_DIR)/background.js
|
||||||
|
|
||||||
|
# Typecheck once. Both build targets depend on it rather than each running tsc,
|
||||||
|
# which halves the work when building both.
|
||||||
|
#
|
||||||
|
# Calls the local toolchain directly, so this works regardless of how pnpm is
|
||||||
|
# provided (corepack vs standalone). Run "corepack pnpm install" first.
|
||||||
|
typecheck: check-deps
|
||||||
|
node_modules/.bin/tsc -noEmit -skipLibCheck
|
||||||
|
|
||||||
|
firefox: typecheck
|
||||||
|
node esbuild.config.mjs production
|
||||||
|
@echo "Firefox build: $(CURDIR)/$(FIREFOX_DIR)"
|
||||||
|
|
||||||
|
# The same sources with Chrome's manifest. Firefox and Chrome disagree on the
|
||||||
|
# background key and on the gecko block, so the manifest is generated per
|
||||||
|
# target rather than forked.
|
||||||
|
chrome: typecheck
|
||||||
|
TARGET=chrome node esbuild.config.mjs production
|
||||||
|
@echo
|
||||||
|
@echo "Chrome build: $(CURDIR)/$(CHROME_DIR)"
|
||||||
|
@echo "Load it with chrome://extensions -> Developer mode -> Load unpacked."
|
||||||
|
@echo "Select the folder itself; Chrome wants the directory holding manifest.json."
|
||||||
|
@echo
|
||||||
|
|
||||||
|
# An .xpi is just a zip of the extension directory, with the manifest at the
|
||||||
|
# top level rather than inside a wrapper folder.
|
||||||
|
xpi: firefox
|
||||||
|
@mkdir -p $(DIST)
|
||||||
|
rm -f $(XPI)
|
||||||
|
cd $(FIREFOX_DIR) && zip -qr $(CURDIR)/$(XPI) .
|
||||||
|
@echo
|
||||||
|
@echo "Built: $(CURDIR)/$(XPI)"
|
||||||
|
@echo
|
||||||
|
@echo "This file is UNSIGNED. Two ways to use it:"
|
||||||
|
@echo
|
||||||
|
@echo " Publish -- upload it at addons.mozilla.org/developers/addon/submit/"
|
||||||
|
@echo " Listed add-ons are signed once review approves them; unlisted"
|
||||||
|
@echo " ones are signed straight away."
|
||||||
|
@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
|
||||||
|
|
||||||
|
# The Chrome Web Store takes a plain zip, and does the packing into .crx itself.
|
||||||
|
crx: chrome
|
||||||
|
@mkdir -p $(DIST)
|
||||||
|
rm -f $(CRX)
|
||||||
|
cd $(CHROME_DIR) && zip -qr $(CURDIR)/$(CRX) .
|
||||||
|
@echo
|
||||||
|
@echo "Built: $(CURDIR)/$(CRX)"
|
||||||
|
@echo "Upload it at chrome.google.com/webstore/devconsole"
|
||||||
|
@echo
|
||||||
|
|
||||||
|
# Both packages. Order no longer matters: each target has its own directory.
|
||||||
|
packages: xpi crx
|
||||||
|
|
||||||
|
# Removes everything the build produces, including packages from earlier
|
||||||
|
# versions, whose filenames carry their own version number and so are never
|
||||||
|
# overwritten by a later build. dist/ is tracked in git, so a clean shows the
|
||||||
|
# packages as deleted until the next "make packages" puts them back.
|
||||||
|
clean:
|
||||||
|
rm -rf build $(DIST)
|
||||||
|
rm -f $(EXT_ID)-*.xpi $(EXT_ID)-*.zip
|
||||||
|
find . -name '*.map' -not -path './node_modules/*' -delete
|
||||||
|
@echo "Removed build/, $(DIST)/ and any stray packages."
|
||||||
|
|
||||||
|
# Everything clean removes, plus the installed dependencies. Recover with
|
||||||
|
# "corepack pnpm install" -- never with npm, see the note in README.md.
|
||||||
|
distclean: clean
|
||||||
|
rm -rf node_modules
|
||||||
|
@echo "Removed node_modules/. Run: corepack pnpm install"
|
||||||
|
|
||||||
|
# Fail with a useful message rather than a confusing "tsc: not found".
|
||||||
|
check-deps:
|
||||||
|
@test -x node_modules/.bin/tsc || { \
|
||||||
|
echo "ERROR: dependencies are not installed."; \
|
||||||
|
echo "Run: corepack pnpm install (do NOT use npm install in this tree)"; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
243
README.md
Normal file
243
README.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# logsdu
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 the submit
|
||||||
|
button, so the normal case is zero clicks.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
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 # bundle and package dist/logsdu-<version>-firefox.xpi
|
||||||
|
```
|
||||||
|
|
||||||
|
`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".
|
||||||
|
|
||||||
|
Dependencies are managed with **pnpm**, never npm. See "Why pnpm" below.
|
||||||
|
|
||||||
|
## Setting it up
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| 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
|
||||||
|
input masks on these forms expect the values in exactly that shape.
|
||||||
|
|
||||||
|
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 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
|
||||||
|
redirect lands on the login page and you would be signed straight back in.
|
||||||
|
- When it fills without submitting, a small note at the bottom of the page says
|
||||||
|
why. Click it to dismiss.
|
||||||
|
- The toolbar popup has a **Preencher agora** button that fills without
|
||||||
|
submitting, for when you want to check the values before sending them.
|
||||||
|
- Automatic submission can be turned off entirely in the options.
|
||||||
|
|
||||||
|
## Where your data goes
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The interface is in Portuguese, matching the portals it was written for.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```
|
||||||
|
make chrome # unpacked Chrome build in build/chrome/
|
||||||
|
make crx # package it as dist/logsdu-<version>-chrome.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Load `build/chrome/` via `chrome://extensions` -> Developer mode -> **Load
|
||||||
|
unpacked** (select the folder itself), or upload the zip at
|
||||||
|
[the Web Store dashboard](https://chrome.google.com/webstore/devconsole).
|
||||||
|
|
||||||
|
One codebase, two manifests. Chrome MV3 requires a background *service worker*
|
||||||
|
and rejects Firefox's event-page `background.scripts`; Firefox needs the gecko
|
||||||
|
block that Chrome has no use for. `esbuild.config.mjs` writes the right manifest
|
||||||
|
per target, so the port is a build flag rather than a fork. Everything else --
|
||||||
|
the `chrome.*` namespace, MV3, the permission model -- is shared.
|
||||||
|
|
||||||
|
Two things that differ in practice, both handled:
|
||||||
|
|
||||||
|
- **Icons must be raster.** Chrome does not accept SVG in `icons`, so the PNGs
|
||||||
|
in `icons/` are generated from `logsdu.svg` and both browsers use those.
|
||||||
|
- **Service workers have no `window` or `document`.** A stray reference through
|
||||||
|
a shared import would break Chrome only, silently, at runtime. `make smoke`
|
||||||
|
runs the built background bundle in a worker-shaped sandbox to catch that.
|
||||||
|
|
||||||
|
What has been verified: Chrome 151 loads the build without errors, and the
|
||||||
|
background bundle registers exactly one content script for exactly the
|
||||||
|
configured origin. What has **not** been verified is a real login against a live
|
||||||
|
portal in Chrome.
|
||||||
|
|
||||||
|
A caveat that applies to both browsers: the page-world filler is injected as a
|
||||||
|
`<script src>` tag, which a site's Content-Security-Policy can refuse. Portals
|
||||||
|
that send no CSP -- the common case for this kind of form -- are unaffected. A
|
||||||
|
portal that does would need the filler registered as a `MAIN` world content
|
||||||
|
script instead.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
corepack pnpm install
|
||||||
|
make # everything: build and package both browsers
|
||||||
|
make firefox # build Firefox only -> build/firefox/
|
||||||
|
make chrome # build Chrome only -> build/chrome/
|
||||||
|
make test # unit tests
|
||||||
|
make smoke # background bundle under a service worker
|
||||||
|
corepack pnpm run dev # rebuild Firefox on change
|
||||||
|
make clean # remove build/, dist/ and stray packages
|
||||||
|
make distclean # clean, plus node_modules/
|
||||||
|
```
|
||||||
|
|
||||||
|
A bare `make` typechecks once, bundles for both browsers into `build/`, and
|
||||||
|
packages both into `dist/`. Packaging is only a zip of a directory that was
|
||||||
|
going to be built anyway, so it costs nothing and keeps `dist/` from drifting
|
||||||
|
out of step with the sources.
|
||||||
|
|
||||||
|
Each browser gets its own directory under `build/`, so the two can coexist and
|
||||||
|
neither is ever left stale by the other. `dist/` holds the packages meant to be
|
||||||
|
published, and is kept out of `build/` so that packaging never tries to include
|
||||||
|
its own output.
|
||||||
|
|
||||||
|
While iterating, load the unpacked directory rather than reinstalling an `.xpi`
|
||||||
|
each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** ->
|
||||||
|
`build/firefox/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.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `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` | 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 |
|
||||||
|
| `tools/sw-smoke.mjs` | Checks the background bundle survives a service worker |
|
||||||
|
|
||||||
|
### Why two scripts instead of one
|
||||||
|
|
||||||
|
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()`. 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 field selectors are configurable
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**Do not run `npm install` or `npm ci` in this repo.** This project is developed
|
||||||
|
on **ZFS**, and npm's installer renames many directories in parallel while
|
||||||
|
hoisting and deduping, which ZFS intermittently fails with:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm error code ENOTEMPTY
|
||||||
|
npm error syscall rename
|
||||||
|
```
|
||||||
|
|
||||||
|
pnpm hard-links packages from a global content-addressable store and does not
|
||||||
|
perform that rename dance, so it is unaffected. Only `npm install` / `npm ci`
|
||||||
|
are the problem -- running *scripts* through npm is fine, since that just spawns
|
||||||
|
tsc and esbuild. The lockfile is `pnpm-lock.yaml`; there is no
|
||||||
|
`package-lock.json` and none should be created.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later. See `LICENSE`.
|
||||||
@@ -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.
|
||||||
@@ -35,24 +35,54 @@ const banner = `/*
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const prod = process.argv[2] === 'production';
|
const prod = process.argv[2] === 'production';
|
||||||
const outdir = 'build';
|
|
||||||
|
|
||||||
// Everything that is not TypeScript is copied verbatim into build/, so that
|
// Target browser. Firefox and Chrome disagree on exactly one manifest key, so
|
||||||
// the directory can be handed straight to about:debugging.
|
// the manifest is written per target rather than duplicated in the tree.
|
||||||
|
const target = process.env.TARGET === 'chrome' ? 'chrome' : 'firefox';
|
||||||
|
|
||||||
|
// Each target gets its own directory. They used to share one, and the result
|
||||||
|
// was that whichever build ran last silently won: loading the other browser's
|
||||||
|
// output then failed with a confusing manifest error. Separate directories
|
||||||
|
// mean both can exist at once and neither can be stale by accident.
|
||||||
|
const outdir = `build/${target}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 the output, so
|
||||||
|
// that the directory can be handed straight to about:debugging or to Chrome's
|
||||||
|
// "Load unpacked".
|
||||||
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();
|
||||||
|
|
||||||
// In watch mode the static files must follow every rebuild, otherwise editing
|
// In watch mode the static files must follow every rebuild, otherwise editing
|
||||||
// manifest.json or a .html file silently changes nothing in build/.
|
// manifest.json or a .html file silently changes nothing in the output.
|
||||||
const staticPlugin = {
|
const staticPlugin = {
|
||||||
name: 'copy-static',
|
name: 'copy-static',
|
||||||
setup(build) {
|
setup(build) {
|
||||||
@@ -65,10 +95,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 +112,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
icons/logsdu-128.png
Normal file
BIN
icons/logsdu-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
icons/logsdu-16.png
Normal file
BIN
icons/logsdu-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
icons/logsdu-32.png
Normal file
BIN
icons/logsdu-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 729 B |
BIN
icons/logsdu-48.png
Normal file
BIN
icons/logsdu-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
icons/logsdu-96.png
Normal file
BIN
icons/logsdu-96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
7
icons/logsdu.svg
Normal file
7
icons/logsdu.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#2f6f4e"/>
|
||||||
|
<path d="M32 14a10 10 0 0 0-10 10v6h6v-6a4 4 0 0 1 8 0v6h6v-6a10 10 0 0 0-10-10z" fill="#e8f3ec"/>
|
||||||
|
<rect x="18" y="30" width="28" height="22" rx="4" fill="#e8f3ec"/>
|
||||||
|
<circle cx="32" cy="39" r="3.5" fill="#2f6f4e"/>
|
||||||
|
<rect x="30.5" y="41" width="3" height="7" rx="1.5" fill="#2f6f4e"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 434 B |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "logsdu",
|
"name": "logsdu",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"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",
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"packageManager": "pnpm@9.15.9",
|
"packageManager": "pnpm@9.15.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"test": "node --test \"src/**/*.test.ts\"",
|
||||||
|
"test:watch": "node --test --watch \"src/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
285
pnpm-lock.yaml
generated
Normal file
285
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
esbuild:
|
||||||
|
specifier: 0.25.5
|
||||||
|
version: 0.25.5
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.8.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.25.5':
|
||||||
|
resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.25.5':
|
||||||
|
resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.25.5':
|
||||||
|
resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.25.5':
|
||||||
|
resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.25.5':
|
||||||
|
resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.25.5':
|
||||||
|
resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.25.5':
|
||||||
|
resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
esbuild@0.25.5:
|
||||||
|
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.25.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild@0.25.5:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.25.5
|
||||||
|
'@esbuild/android-arm': 0.25.5
|
||||||
|
'@esbuild/android-arm64': 0.25.5
|
||||||
|
'@esbuild/android-x64': 0.25.5
|
||||||
|
'@esbuild/darwin-arm64': 0.25.5
|
||||||
|
'@esbuild/darwin-x64': 0.25.5
|
||||||
|
'@esbuild/freebsd-arm64': 0.25.5
|
||||||
|
'@esbuild/freebsd-x64': 0.25.5
|
||||||
|
'@esbuild/linux-arm': 0.25.5
|
||||||
|
'@esbuild/linux-arm64': 0.25.5
|
||||||
|
'@esbuild/linux-ia32': 0.25.5
|
||||||
|
'@esbuild/linux-loong64': 0.25.5
|
||||||
|
'@esbuild/linux-mips64el': 0.25.5
|
||||||
|
'@esbuild/linux-ppc64': 0.25.5
|
||||||
|
'@esbuild/linux-riscv64': 0.25.5
|
||||||
|
'@esbuild/linux-s390x': 0.25.5
|
||||||
|
'@esbuild/linux-x64': 0.25.5
|
||||||
|
'@esbuild/netbsd-arm64': 0.25.5
|
||||||
|
'@esbuild/netbsd-x64': 0.25.5
|
||||||
|
'@esbuild/openbsd-arm64': 0.25.5
|
||||||
|
'@esbuild/openbsd-x64': 0.25.5
|
||||||
|
'@esbuild/sunos-x64': 0.25.5
|
||||||
|
'@esbuild/win32-arm64': 0.25.5
|
||||||
|
'@esbuild/win32-ia32': 0.25.5
|
||||||
|
'@esbuild/win32-x64': 0.25.5
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
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();
|
||||||
150
src/config.test.ts
Normal file
150
src/config.test.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOGOUT_COOLDOWN_MS,
|
||||||
|
SUBMIT_INTERVAL_MS,
|
||||||
|
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/',
|
||||||
|
ra: '2000101010',
|
||||||
|
dn: '01/02/1999',
|
||||||
|
cpf: '123.456.789-01',
|
||||||
|
autoSubmit: true,
|
||||||
|
selectors: { ...DEFAULT_SELECTORS },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 };
|
||||||
|
const NOW = 1_000_000_000_000;
|
||||||
|
|
||||||
|
test('isConfigured requires all four values', () => {
|
||||||
|
assert.equal(isConfigured(CONFIG), true);
|
||||||
|
assert.equal(isConfigured({ ...CONFIG, url: '' }), false);
|
||||||
|
assert.equal(isConfigured({ ...CONFIG, url: 'not a url' }), false);
|
||||||
|
assert.equal(isConfigured({ ...CONFIG, ra: '' }), false);
|
||||||
|
assert.equal(isConfigured({ ...CONFIG, dn: '' }), 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', () => {
|
||||||
|
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true);
|
||||||
|
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matchesSite refuses lookalike hosts and scheme downgrades', () => {
|
||||||
|
// The content script runs on every page, so this guard is the only thing
|
||||||
|
// standing between the stored CPF and an attacker-chosen page.
|
||||||
|
assert.equal(matchesSite(CONFIG, 'https://portal.example.br.evil.tld/'), false);
|
||||||
|
assert.equal(matchesSite(CONFIG, 'https://evil.tld/portal.example.br'), false);
|
||||||
|
assert.equal(matchesSite(CONFIG, 'http://portal.example.br/'), false);
|
||||||
|
assert.equal(matchesSite(CONFIG, 'https://sub.portal.example.br/'), false);
|
||||||
|
assert.equal(matchesSite(CONFIG, 'about:blank'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fresh state submits', () => {
|
||||||
|
assert.deepEqual(decideSubmit(CONFIG, FRESH, NOW), { submit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the toggle wins over everything else', () => {
|
||||||
|
assert.deepEqual(decideSubmit({ ...CONFIG, autoSubmit: false }, FRESH, NOW), {
|
||||||
|
submit: false,
|
||||||
|
reason: 'disabled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one submit per hour, then autofill only', () => {
|
||||||
|
const justTried: State = { ...FRESH, lastSubmitAt: NOW - 1000 };
|
||||||
|
assert.deepEqual(decideSubmit(CONFIG, justTried, NOW), {
|
||||||
|
submit: false,
|
||||||
|
reason: 'rate-limited',
|
||||||
|
});
|
||||||
|
|
||||||
|
const anHourAgo: State = { ...FRESH, lastSubmitAt: NOW - SUBMIT_INTERVAL_MS - 1 };
|
||||||
|
assert.deepEqual(decideSubmit(CONFIG, anHourAgo, NOW), { submit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a recent logout suppresses the submit, and expires', () => {
|
||||||
|
const justLoggedOut: State = { ...FRESH, logoutAt: NOW - 1000 };
|
||||||
|
assert.deepEqual(decideSubmit(CONFIG, justLoggedOut, NOW), {
|
||||||
|
submit: false,
|
||||||
|
reason: 'logout',
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleLogout: State = { ...FRESH, logoutAt: NOW - LOGOUT_COOLDOWN_MS - 1 };
|
||||||
|
assert.deepEqual(decideSubmit(CONFIG, staleLogout, NOW), { submit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout is reported ahead of the rate limit', () => {
|
||||||
|
// Both apply after a login followed by a logout. The logout is the more
|
||||||
|
// useful explanation to show the user.
|
||||||
|
const both: State = { lastSubmitAt: NOW - 1000, logoutAt: NOW - 1000 };
|
||||||
|
assert.deepEqual(decideSubmit(CONFIG, both, NOW), {
|
||||||
|
submit: false,
|
||||||
|
reason: 'logout',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,9 @@
|
|||||||
// * rcb@beco.cc *
|
// * rcb@beco.cc *
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
|
|
||||||
import { toOrigin } from './format';
|
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';
|
||||||
@@ -45,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. */
|
||||||
@@ -59,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> {
|
||||||
@@ -112,6 +135,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' };
|
||||||
|
|||||||
213
src/content.ts
Normal file
213
src/content.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
// The content script is declared against every URL, because the one address
|
||||||
|
// it cares about is configured at runtime and deliberately absent from the
|
||||||
|
// manifest. Everything below is therefore written to bail out as early and as
|
||||||
|
// cheaply as possible: one storage read and an origin comparison, then return.
|
||||||
|
|
||||||
|
import {
|
||||||
|
decideSubmit,
|
||||||
|
isConfigured,
|
||||||
|
loadConfig,
|
||||||
|
loadState,
|
||||||
|
matchesSite,
|
||||||
|
saveState,
|
||||||
|
} from './config.ts';
|
||||||
|
import type { Config } from './config.ts';
|
||||||
|
import { EVENT_RESULT } from './portal.ts';
|
||||||
|
import type { FillRequest, FillResult } from './portal.ts';
|
||||||
|
|
||||||
|
const FORM_WAIT_MS = 10000;
|
||||||
|
const NOTICE_ID = 'logsdu-notice';
|
||||||
|
|
||||||
|
// User-facing strings follow the portal's language.
|
||||||
|
const NOTICE_TEXT: Record<string, string> = {
|
||||||
|
disabled: 'logsdu: campos preenchidos. O login automático está desligado.',
|
||||||
|
'rate-limited':
|
||||||
|
'logsdu: campos preenchidos, sem envio automático (já houve uma tentativa na última hora). Confira os dados e clique em Entrar.',
|
||||||
|
logout: 'logsdu: campos preenchidos, sem envio automático depois do logout.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the values to the page-world filler.
|
||||||
|
*
|
||||||
|
* The payload travels on the script tag's dataset rather than in a CustomEvent
|
||||||
|
* detail: an object created in the isolated world is not reliably readable
|
||||||
|
* from the page, and a string on the element is both simple and synchronous.
|
||||||
|
* The injected script removes the element as its first act.
|
||||||
|
*/
|
||||||
|
function fill(request: FillRequest): Promise<FillResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const onResult = (event: Event): void => {
|
||||||
|
window.removeEventListener(EVENT_RESULT, onResult);
|
||||||
|
const detail = (event as CustomEvent<string>).detail;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(detail) as FillResult);
|
||||||
|
} catch {
|
||||||
|
resolve({ filled: false, submitted: false, error: 'bad result payload' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener(EVENT_RESULT, onResult);
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = chrome.runtime.getURL('injected.js');
|
||||||
|
script.dataset.logsdu = JSON.stringify(request);
|
||||||
|
(document.head ?? document.documentElement).appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve once the login form exists, or null if it never shows up. */
|
||||||
|
function awaitForm(formSelector: string): Promise<HTMLFormElement | null> {
|
||||||
|
const existing = document.querySelector<HTMLFormElement>(formSelector);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const found = document.querySelector<HTMLFormElement>(formSelector);
|
||||||
|
if (found) {
|
||||||
|
observer.disconnect();
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
resolve(found);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
resolve(null);
|
||||||
|
}, FORM_WAIT_MS);
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small, self-removing note explaining why nothing was submitted. */
|
||||||
|
function showNotice(text: string): void {
|
||||||
|
if (document.getElementById(NOTICE_ID)) return;
|
||||||
|
const notice = document.createElement('div');
|
||||||
|
notice.id = NOTICE_ID;
|
||||||
|
notice.textContent = text;
|
||||||
|
notice.style.cssText = [
|
||||||
|
'position:fixed',
|
||||||
|
'z-index:2147483647',
|
||||||
|
'left:50%',
|
||||||
|
'transform:translateX(-50%)',
|
||||||
|
'bottom:16px',
|
||||||
|
'max-width:min(90vw,520px)',
|
||||||
|
'padding:10px 14px',
|
||||||
|
'border-radius:8px',
|
||||||
|
'background:#222',
|
||||||
|
'color:#fff',
|
||||||
|
'font:14px/1.4 system-ui,sans-serif',
|
||||||
|
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
|
||||||
|
].join(';');
|
||||||
|
notice.addEventListener('click', () => notice.remove());
|
||||||
|
document.body?.appendChild(notice);
|
||||||
|
window.setTimeout(() => notice.remove(), 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember that the user asked to be logged out.
|
||||||
|
*
|
||||||
|
* 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(logoutSelector: string): void {
|
||||||
|
document.addEventListener(
|
||||||
|
'click',
|
||||||
|
(event) => {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
if (!target?.closest?.(logoutSelector)) return;
|
||||||
|
void loadState().then((state) =>
|
||||||
|
saveState({ ...state, logoutAt: Date.now() }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoLogin(config: Config): Promise<void> {
|
||||||
|
const form = await awaitForm(config.selectors.form);
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const state = await loadState();
|
||||||
|
const decision = decideSubmit(config, state, Date.now());
|
||||||
|
|
||||||
|
// Record the attempt before it happens, not after. A submit that navigates
|
||||||
|
// away, crashes, or is interrupted still has to count against the hourly
|
||||||
|
// limit, otherwise a failing login could retry on every page load.
|
||||||
|
if (decision.submit) {
|
||||||
|
await saveState({ ...state, lastSubmitAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fill({
|
||||||
|
ra: config.ra,
|
||||||
|
dn: config.dn,
|
||||||
|
cpf: config.cpf,
|
||||||
|
submit: decision.submit,
|
||||||
|
selectors: config.selectors,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.warn('logsdu:', result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!decision.submit && result.filled) {
|
||||||
|
showNotice(NOTICE_TEXT[decision.reason] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = await loadConfig();
|
||||||
|
if (!isConfigured(config)) return;
|
||||||
|
if (!matchesSite(config, location.href)) return;
|
||||||
|
|
||||||
|
watchLogout(config.selectors.logout);
|
||||||
|
|
||||||
|
// The popup's "Preencher agora" button, for when the automatic submit is
|
||||||
|
// off or rate limited.
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if ((message as { type?: string })?.type !== 'fill') return undefined;
|
||||||
|
void loadConfig()
|
||||||
|
.then((fresh) =>
|
||||||
|
fill({
|
||||||
|
ra: fresh.ra,
|
||||||
|
dn: fresh.dn,
|
||||||
|
cpf: fresh.cpf,
|
||||||
|
submit: (message as { submit?: boolean }).submit === true,
|
||||||
|
selectors: fresh.selectors,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then(sendResponse);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only chase the form when it is already here, or when the document is
|
||||||
|
// 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(config.selectors.form) ||
|
||||||
|
document.readyState !== 'complete'
|
||||||
|
) {
|
||||||
|
await autoLogin(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
94
src/format.test.ts
Normal file
94
src/format.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatCpf,
|
||||||
|
formatDate,
|
||||||
|
formatRa,
|
||||||
|
isCompleteCpf,
|
||||||
|
isCompleteDate,
|
||||||
|
toOrigin,
|
||||||
|
} from './format.ts';
|
||||||
|
|
||||||
|
test('formatDate builds dd/mm/yyyy progressively', () => {
|
||||||
|
assert.equal(formatDate('0'), '0');
|
||||||
|
assert.equal(formatDate('01'), '01');
|
||||||
|
assert.equal(formatDate('0102'), '01/02');
|
||||||
|
assert.equal(formatDate('01021999'), '01/02/1999');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDate is idempotent and tolerates a pasted separator', () => {
|
||||||
|
assert.equal(formatDate('01/02/1999'), '01/02/1999');
|
||||||
|
assert.equal(formatDate(formatDate('01021999')), '01/02/1999');
|
||||||
|
assert.equal(formatDate('1-2-1999'), '12/19/99');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatCpf builds 000.000.000-00 progressively', () => {
|
||||||
|
assert.equal(formatCpf('123'), '123');
|
||||||
|
assert.equal(formatCpf('123456'), '123.456');
|
||||||
|
assert.equal(formatCpf('12345678901'), '123.456.789-01');
|
||||||
|
assert.equal(formatCpf('123.456.789-01'), '123.456.789-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatters drop overflow instead of growing without bound', () => {
|
||||||
|
assert.equal(formatCpf('123456789012345'), '123.456.789-01');
|
||||||
|
assert.equal(formatRa('20001010109999'), '2000101010');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCompleteDate rejects impossible calendar dates', () => {
|
||||||
|
assert.equal(isCompleteDate('01/02/1999'), true);
|
||||||
|
assert.equal(isCompleteDate('29/02/2000'), true, 'leap year');
|
||||||
|
assert.equal(isCompleteDate('29/02/1999'), false, 'not a leap year');
|
||||||
|
assert.equal(isCompleteDate('31/04/1999'), false, 'April has 30 days');
|
||||||
|
assert.equal(isCompleteDate('00/01/1999'), false);
|
||||||
|
assert.equal(isCompleteDate('01/13/1999'), false);
|
||||||
|
assert.equal(isCompleteDate('1/2/1999'), false, 'must be zero padded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCompleteCpf counts digits, not characters', () => {
|
||||||
|
assert.equal(isCompleteCpf('123.456.789-01'), true);
|
||||||
|
assert.equal(isCompleteCpf('12345678901'), true);
|
||||||
|
assert.equal(isCompleteCpf('123.456.789-0'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toOrigin assumes https and strips path, query and fragment', () => {
|
||||||
|
assert.equal(toOrigin('https://portal.example.br/'), 'https://portal.example.br');
|
||||||
|
assert.equal(toOrigin('portal.example.br'), 'https://portal.example.br');
|
||||||
|
assert.equal(
|
||||||
|
toOrigin('https://portal.example.br/login?a=1#x'),
|
||||||
|
'https://portal.example.br',
|
||||||
|
);
|
||||||
|
assert.equal(toOrigin(' portal.example.br '), 'https://portal.example.br');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toOrigin keeps the port, which is part of the origin', () => {
|
||||||
|
assert.equal(toOrigin('http://localhost:8080/x'), 'http://localhost:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toOrigin rejects what cannot be a site', () => {
|
||||||
|
assert.equal(toOrigin(''), null);
|
||||||
|
assert.equal(toOrigin(' '), null);
|
||||||
|
assert.equal(toOrigin('javascript:alert(1)'), null);
|
||||||
|
assert.equal(toOrigin('file:///etc/passwd'), null);
|
||||||
|
});
|
||||||
@@ -84,7 +84,7 @@ export function isCompleteCpf(value: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Origin of a user-typed site address, or null if it cannot be parsed.
|
* Origin of a user-typed site address, or null if it cannot be parsed.
|
||||||
* Accepts input without a scheme ("saladigital.example.com") by assuming
|
* Accepts input without a scheme ("portal.example.br") by assuming
|
||||||
* https, which is what someone pasting an address from the URL bar expects.
|
* https, which is what someone pasting an address from the URL bar expects.
|
||||||
*/
|
*/
|
||||||
export function toOrigin(value: string): string | null {
|
export function toOrigin(value: string): string | null {
|
||||||
|
|||||||
@@ -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';
|
import { EVENT_RESULT } from './portal.ts';
|
||||||
import type { FillRequest, FillResult } from './portal';
|
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,11 +1,16 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "logsdu",
|
"name": "logsdu",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"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": {
|
||||||
|
"16": "icons/logsdu-16.png",
|
||||||
|
"32": "icons/logsdu-32.png",
|
||||||
"48": "icons/logsdu-48.png",
|
"48": "icons/logsdu-48.png",
|
||||||
"96": "icons/logsdu-96.png"
|
"96": "icons/logsdu-96.png",
|
||||||
|
"128": "icons/logsdu-128.png"
|
||||||
},
|
},
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
@@ -16,16 +21,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"],
|
||||||
@@ -38,6 +38,10 @@
|
|||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_title": "logsdu"
|
"default_title": "logsdu",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/logsdu-16.png",
|
||||||
|
"32": "icons/logsdu-32.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/options.html
Normal file
100
src/options.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<!--
|
||||||
|
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
|
||||||
|
* Licensed under the GNU General Public License v3.0 or later.
|
||||||
|
* See https://www.gnu.org/licenses/ and the LICENSE file.
|
||||||
|
-->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>logsdu</title>
|
||||||
|
<link rel="stylesheet" href="ui.css" />
|
||||||
|
</head>
|
||||||
|
<body class="page">
|
||||||
|
<h1>logsdu</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Guarde os dados de acesso uma vez. Ao abrir a página de login, o
|
||||||
|
preenchimento e o envio acontecem sozinhos.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="form" autocomplete="off">
|
||||||
|
<label for="url">Endereço do portal</label>
|
||||||
|
<input id="url" type="text" inputmode="url" placeholder="https://portal.exemplo.br/" />
|
||||||
|
<p class="hint">
|
||||||
|
O endereço fica somente aqui, na memória local da extensão. Nada
|
||||||
|
dele aparece no código instalado.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="ra">Matrícula / Código</label>
|
||||||
|
<input id="ra" type="text" inputmode="numeric" placeholder="2000101010" />
|
||||||
|
|
||||||
|
<label for="dn">Data de nascimento</label>
|
||||||
|
<input id="dn" type="text" inputmode="numeric" placeholder="01/01/2000" />
|
||||||
|
|
||||||
|
<label for="cpf">CPF</label>
|
||||||
|
<input id="cpf" type="text" inputmode="numeric" placeholder="000.000.000-00" />
|
||||||
|
|
||||||
|
<label class="check">
|
||||||
|
<input id="autoSubmit" type="checkbox" />
|
||||||
|
<span>Entrar automaticamente (no máximo uma tentativa por hora)</span>
|
||||||
|
</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">
|
||||||
|
<button id="save" type="submit">Salvar</button>
|
||||||
|
<button id="clear" type="button" class="ghost">Apagar dados</button>
|
||||||
|
<span id="status" role="status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<p class="hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
208
src/options.ts
Normal file
208
src/options.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMPTY_CONFIG,
|
||||||
|
loadConfig,
|
||||||
|
resetState,
|
||||||
|
saveConfig,
|
||||||
|
sitePattern,
|
||||||
|
} from './config.ts';
|
||||||
|
import {
|
||||||
|
formatCpf,
|
||||||
|
formatDate,
|
||||||
|
formatRa,
|
||||||
|
isCompleteCpf,
|
||||||
|
isCompleteDate,
|
||||||
|
isCompleteRa,
|
||||||
|
toOrigin,
|
||||||
|
} 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 {
|
||||||
|
const found = document.getElementById(id);
|
||||||
|
if (!found) throw new Error(`missing element #${id}`);
|
||||||
|
return found as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
url: el<HTMLInputElement>('url'),
|
||||||
|
ra: el<HTMLInputElement>('ra'),
|
||||||
|
dn: el<HTMLInputElement>('dn'),
|
||||||
|
cpf: el<HTMLInputElement>('cpf'),
|
||||||
|
};
|
||||||
|
const autoSubmit = el<HTMLInputElement>('autoSubmit');
|
||||||
|
const status = el<HTMLSpanElement>('status');
|
||||||
|
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 {
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.toggle('error', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reformat as the user types, keeping the caret at the end.
|
||||||
|
*
|
||||||
|
* Anchoring the caret is only correct because these masks are append-only in
|
||||||
|
* practice: you type or paste a number left to right. It avoids the caret
|
||||||
|
* jumping to position zero after every keystroke.
|
||||||
|
*/
|
||||||
|
function liveFormat(input: HTMLInputElement, format: (v: string) => string): void {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const atEnd = input.selectionStart === input.value.length;
|
||||||
|
const formatted = format(input.value);
|
||||||
|
if (formatted === input.value) return;
|
||||||
|
input.value = formatted;
|
||||||
|
if (atEnd) input.setSelectionRange(formatted.length, formatted.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
liveFormat(fields.ra, formatRa);
|
||||||
|
liveFormat(fields.dn, formatDate);
|
||||||
|
liveFormat(fields.cpf, formatCpf);
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const config = await loadConfig();
|
||||||
|
fields.url.value = config.url;
|
||||||
|
fields.ra.value = config.ra;
|
||||||
|
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. */
|
||||||
|
function validate(): string | null {
|
||||||
|
for (const input of Object.values(fields)) input.classList.remove('invalid');
|
||||||
|
|
||||||
|
const origin = toOrigin(fields.url.value);
|
||||||
|
if (origin === null) {
|
||||||
|
fields.url.classList.add('invalid');
|
||||||
|
return 'Endereço inválido.';
|
||||||
|
}
|
||||||
|
if (!isCompleteRa(fields.ra.value)) {
|
||||||
|
fields.ra.classList.add('invalid');
|
||||||
|
return 'Informe a matrícula.';
|
||||||
|
}
|
||||||
|
if (!isCompleteDate(fields.dn.value)) {
|
||||||
|
fields.dn.classList.add('invalid');
|
||||||
|
return 'Data de nascimento incompleta ou inexistente.';
|
||||||
|
}
|
||||||
|
if (!isCompleteCpf(fields.cpf.value)) {
|
||||||
|
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<HTMLDetailsElement>('advanced').open = true;
|
||||||
|
return 'Seletor CSS inválido nos ajustes avançados.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const complaint = validate();
|
||||||
|
if (complaint) {
|
||||||
|
setStatus(complaint, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('defaults').addEventListener('click', () => {
|
||||||
|
writeSelectors(DEFAULT_SELECTORS);
|
||||||
|
setStatus('Padrões restaurados. Salve para aplicar.');
|
||||||
|
});
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('clear').addEventListener('click', () => {
|
||||||
|
void (async () => {
|
||||||
|
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.');
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
void load();
|
||||||
22
src/popup.html
Normal file
22
src/popup.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!--
|
||||||
|
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
|
||||||
|
* Licensed under the GNU General Public License v3.0 or later.
|
||||||
|
* See https://www.gnu.org/licenses/ and the LICENSE file.
|
||||||
|
-->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>logsdu</title>
|
||||||
|
<link rel="stylesheet" href="ui.css" />
|
||||||
|
</head>
|
||||||
|
<body class="popup">
|
||||||
|
<p class="state" id="state">...</p>
|
||||||
|
<p class="hint" id="detail"></p>
|
||||||
|
<div class="row">
|
||||||
|
<button id="fill" type="button" disabled>Preencher agora</button>
|
||||||
|
<button id="options" type="button" class="ghost">Configurar</button>
|
||||||
|
</div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
91
src/popup.ts
Normal file
91
src/popup.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
// The manual escape hatch: fill (and optionally submit) on demand, for when
|
||||||
|
// the automatic submit is switched off, rate limited, or suppressed after a
|
||||||
|
// logout.
|
||||||
|
|
||||||
|
import { isConfigured, loadConfig, matchesSite, sitePattern } from './config.ts';
|
||||||
|
|
||||||
|
const state = document.getElementById('state') as HTMLParagraphElement;
|
||||||
|
const detail = document.getElementById('detail') as HTMLParagraphElement;
|
||||||
|
const fillButton = document.getElementById('fill') as HTMLButtonElement;
|
||||||
|
const optionsButton = document.getElementById('options') as HTMLButtonElement;
|
||||||
|
|
||||||
|
optionsButton.addEventListener('click', () => {
|
||||||
|
void chrome.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function activeTabOnSite(): Promise<number | null> {
|
||||||
|
const config = await loadConfig();
|
||||||
|
if (!isConfigured(config)) return null;
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab?.id || !tab.url) return null;
|
||||||
|
return matchesSite(config, tab.url) ? tab.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
const config = await loadConfig();
|
||||||
|
if (!isConfigured(config)) {
|
||||||
|
state.textContent = 'Não configurado';
|
||||||
|
detail.textContent = 'Informe o endereço do portal e os três dados de acesso.';
|
||||||
|
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';
|
||||||
|
detail.textContent = 'Esta aba não é o portal configurado.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.textContent = 'Pronto';
|
||||||
|
detail.textContent = config.autoSubmit
|
||||||
|
? 'Login automático ligado.'
|
||||||
|
: 'Login automático desligado: preencha e clique em Entrar.';
|
||||||
|
fillButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fillButton.addEventListener('click', () => {
|
||||||
|
void (async () => {
|
||||||
|
const tabId = await activeTabOnSite();
|
||||||
|
if (tabId === null) return;
|
||||||
|
fillButton.disabled = true;
|
||||||
|
// Fill only. Pressing "Entrar" stays with the user here, which is the
|
||||||
|
// point of a manual button.
|
||||||
|
await chrome.tabs.sendMessage(tabId, { type: 'fill', submit: false });
|
||||||
|
window.close();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
void refresh();
|
||||||
@@ -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
|
*
|
||||||
* inputs are id="CPF" and id="DTNASC". The three login inputs have no id at
|
* Portals of this kind routinely carry a second "forgot your registration
|
||||||
* all, so an unscoped lookup for a CPF field finds the modal's copy and
|
* number" form in a modal on the same page, using the same field names or
|
||||||
* writes the value into the wrong form.
|
* ids. An unscoped lookup finds that copy and writes into the wrong form.
|
||||||
*/
|
*/
|
||||||
export const FORM = 'form[name="LoginAP"]';
|
form: string;
|
||||||
|
/** Registration number or code, relative to the form. */
|
||||||
|
ra: string;
|
||||||
|
/** Birth date, relative to the form. */
|
||||||
|
dn: string;
|
||||||
|
/** Document number, relative to the form. */
|
||||||
|
cpf: string;
|
||||||
|
/** The button that submits the login, relative to the form. */
|
||||||
|
submit: string;
|
||||||
|
/**
|
||||||
|
* The logout control on the pages behind the login. Clicking it is the
|
||||||
|
* signal that the user wants to stay out, which suppresses the auto-submit
|
||||||
|
* that would otherwise fire when the logout redirect lands back here.
|
||||||
|
*/
|
||||||
|
logout: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** The three credential inputs, relative to FORM. */
|
export const 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 {
|
||||||
|
|||||||
149
src/ui.css
Normal file
149
src/ui.css
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
|
||||||
|
* Licensed under the GNU General Public License v3.0 or later.
|
||||||
|
* See https://www.gnu.org/licenses/ and the LICENSE file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #ffffff;
|
||||||
|
--fg: #1b1b1b;
|
||||||
|
--muted: #5c5c5c;
|
||||||
|
--line: #d6d6d6;
|
||||||
|
--accent: #2f6f4e;
|
||||||
|
--field: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #1e1e1e;
|
||||||
|
--fg: #ededed;
|
||||||
|
--muted: #a8a8a8;
|
||||||
|
--line: #3d3d3d;
|
||||||
|
--accent: #6cc294;
|
||||||
|
--field: #2a2a2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font: 15px/1.5 system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.25rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
width: 20rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 2rem 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--field);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text']:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.invalid {
|
||||||
|
border-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.error {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup p {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup .state {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
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 {
|
||||||
|
|||||||
136
tools/sw-smoke.mjs
Normal file
136
tools/sw-smoke.mjs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
// Smoke test for the background bundle under Chrome's execution model.
|
||||||
|
//
|
||||||
|
// Chrome MV3 runs the background as a service worker, where there is no
|
||||||
|
// `window` and no `document`, and the global object is `self`. Firefox runs
|
||||||
|
// the same file as an event page, where those do exist -- so a reference that
|
||||||
|
// creeps in through a shared import breaks Chrome only, and breaks it silently
|
||||||
|
// at runtime rather than at build time.
|
||||||
|
//
|
||||||
|
// This evaluates the built bundle in a worker-shaped sandbox with a stubbed
|
||||||
|
// extension API, and asserts that it registers exactly one content script, for
|
||||||
|
// exactly the configured origin.
|
||||||
|
//
|
||||||
|
// make smoke
|
||||||
|
// node tools/sw-smoke.mjs [path/to/background.js]
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const BUNDLE = process.argv[2] ?? 'build/chrome/background.js';
|
||||||
|
const CONFIGURED_ORIGIN = 'https://portal.example.br';
|
||||||
|
|
||||||
|
const permissionChecks = [];
|
||||||
|
const registered = [];
|
||||||
|
const listeners = {
|
||||||
|
onInstalled: 0,
|
||||||
|
onStartup: 0,
|
||||||
|
onRemoved: 0,
|
||||||
|
onChanged: 0,
|
||||||
|
onMessage: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chrome = {
|
||||||
|
runtime: {
|
||||||
|
onInstalled: { addListener: () => listeners.onInstalled++ },
|
||||||
|
onStartup: { addListener: () => listeners.onStartup++ },
|
||||||
|
onMessage: { addListener: () => listeners.onMessage++ },
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
onRemoved: { addListener: () => listeners.onRemoved++ },
|
||||||
|
contains: async (p) => {
|
||||||
|
permissionChecks.push(p.origins);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: async () => ({
|
||||||
|
config: {
|
||||||
|
url: `${CONFIGURED_ORIGIN}/`,
|
||||||
|
ra: '2000101010',
|
||||||
|
dn: '01/02/1999',
|
||||||
|
cpf: '123.456.789-01',
|
||||||
|
autoSubmit: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
set: async () => {},
|
||||||
|
},
|
||||||
|
onChanged: { addListener: () => listeners.onChanged++ },
|
||||||
|
},
|
||||||
|
scripting: {
|
||||||
|
getRegisteredContentScripts: async () => [],
|
||||||
|
unregisterContentScripts: async () => {},
|
||||||
|
registerContentScripts: async (scripts) => registered.push(...scripts),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately no window and no document: referencing either must fail here
|
||||||
|
// exactly as it would inside a service worker.
|
||||||
|
const sandbox = { chrome, console, setTimeout, clearTimeout, queueMicrotask, URL };
|
||||||
|
sandbox.self = sandbox;
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
vm.runInContext(readFileSync(BUNDLE, 'utf8'), sandbox, { filename: BUNDLE });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`FAIL: ${BUNDLE} threw on evaluation: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the top-level resync() a turn of the event loop to settle.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
for (const [name, count] of Object.entries(listeners)) {
|
||||||
|
if (count !== 1) failures.push(`${name} registered ${count} times, expected 1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registered.length !== 1) {
|
||||||
|
failures.push(`registered ${registered.length} content scripts, expected 1`);
|
||||||
|
} else {
|
||||||
|
const script = registered[0];
|
||||||
|
const expected = `${CONFIGURED_ORIGIN}/*`;
|
||||||
|
if (script.matches?.length !== 1 || script.matches[0] !== expected) {
|
||||||
|
failures.push(`matches ${JSON.stringify(script.matches)}, expected ["${expected}"]`);
|
||||||
|
}
|
||||||
|
if (script.js?.[0] !== 'content.js') {
|
||||||
|
failures.push(`js ${JSON.stringify(script.js)}, expected ["content.js"]`);
|
||||||
|
}
|
||||||
|
if (script.persistAcrossSessions !== false) {
|
||||||
|
failures.push('persistAcrossSessions must be false, or startup registers twice');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionChecks.length !== 1) {
|
||||||
|
failures.push(`checked permissions ${permissionChecks.length} times, expected 1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
for (const failure of failures) console.error(`FAIL: ${failure}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ok - background bundle runs as a service worker');
|
||||||
|
console.log(`ok - registers content.js for ${CONFIGURED_ORIGIN}/* and nothing else`);
|
||||||
@@ -8,12 +8,15 @@
|
|||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"lib": ["ES2021", "DOM"]
|
"lib": ["ES2021", "DOM"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user