Compare commits
5 Commits
436130ecb9
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bf20af4bb | |||
| 4d85187fb5 | |||
| 5bd2552641 | |||
| d01238a9f1 | |||
| 42638eaaf9 |
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# **************************************************************************
|
||||||
|
# * (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
|
||||||
|
|
||||||
|
# Build output. The unpacked extension and the signing package are release
|
||||||
|
# artifacts, not sources.
|
||||||
|
build
|
||||||
|
logsdu-*.zip
|
||||||
|
*.xpi
|
||||||
|
|
||||||
|
# Exclude sourcemaps
|
||||||
|
*.map
|
||||||
|
|
||||||
|
# Exclude macOS Finder (System Explorer) View States
|
||||||
|
.DS_Store
|
||||||
84
Makefile
Normal file
84
Makefile
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# **************************************************************************
|
||||||
|
# * (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 unpacked extension into build/.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make # typecheck and bundle into build/
|
||||||
|
# make test # run the unit tests
|
||||||
|
# make xpi # build, then package build/ as logsdu-<version>.xpi
|
||||||
|
# make clean # remove build/ and the package
|
||||||
|
#
|
||||||
|
# Dependencies are installed with pnpm, never npm:
|
||||||
|
# corepack pnpm install
|
||||||
|
#
|
||||||
|
# Permanent install (Firefox ESR, Developer Edition or Nightly):
|
||||||
|
# set xpinstall.signatures.required=false in about:config, then
|
||||||
|
# about:addons -> gear -> Install Add-on From File -> pick the .xpi
|
||||||
|
#
|
||||||
|
# Release Firefox refuses unsigned add-ons whatever that pref says. There the
|
||||||
|
# same .xpi has to go through addons.mozilla.org as an unlisted add-on first,
|
||||||
|
# which signs it automatically without publishing or reviewing it.
|
||||||
|
#
|
||||||
|
# Throwaway install for development: about:debugging -> This Firefox ->
|
||||||
|
# Load Temporary Add-on -> pick build/manifest.json (dropped on restart).
|
||||||
|
|
||||||
|
EXT_ID := logsdu
|
||||||
|
VERSION := $(shell node -p "require('./package.json').version")
|
||||||
|
XPI := $(EXT_ID)-$(VERSION).xpi
|
||||||
|
|
||||||
|
.PHONY: all build test xpi clean check-deps
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Call the local toolchain directly, so this works regardless of how pnpm is
|
||||||
|
# provided (corepack vs standalone). Run "corepack pnpm install" first.
|
||||||
|
build: check-deps
|
||||||
|
node_modules/.bin/tsc -noEmit -skipLibCheck
|
||||||
|
node esbuild.config.mjs production
|
||||||
|
|
||||||
|
# An .xpi is just a zip of the extension directory, with the manifest at the
|
||||||
|
# top level rather than inside a wrapper folder. The same file installs
|
||||||
|
# directly on ESR and uploads to AMO for signing.
|
||||||
|
xpi: build
|
||||||
|
rm -f $(XPI)
|
||||||
|
cd build && zip -qr ../$(XPI) .
|
||||||
|
@echo "Package: $(XPI)"
|
||||||
|
@echo "Install: about:addons -> gear -> Install Add-on From File"
|
||||||
|
@echo "Needs xpinstall.signatures.required=false on ESR/Developer/Nightly."
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf build
|
||||||
|
rm -f $(EXT_ID)-*.xpi $(EXT_ID)-*.zip
|
||||||
|
|
||||||
|
# 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; \
|
||||||
|
}
|
||||||
180
README.md
Normal file
180
README.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# logsdu
|
||||||
|
|
||||||
|
A Firefox extension that logs you into an academic portal whose sign-in form is
|
||||||
|
three fields -- registration number, birth date and national ID -- rather than
|
||||||
|
the usual user and password.
|
||||||
|
|
||||||
|
That shape defeats password managers. The form is marked `autocomplete="off"`,
|
||||||
|
none of the three inputs is a `password` field, and Bitwarden, Firefox and
|
||||||
|
Chrome all decline to remember it. Since the values never change and the
|
||||||
|
institution does not let you pick a different login method, the only option
|
||||||
|
left is copying three values by hand, every time.
|
||||||
|
|
||||||
|
logsdu stores them once and fills them in. By default it also presses "Entrar",
|
||||||
|
so the normal case is zero clicks.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below).
|
||||||
|
Node 22+ ships `corepack`, so pnpm does not have to be installed globally.
|
||||||
|
|
||||||
|
```
|
||||||
|
corepack pnpm install # first time, or after a dependency change
|
||||||
|
make xpi # typecheck, bundle, and package logsdu-<version>.xpi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install it permanently. Firefox will not load an unsigned add-on unless
|
||||||
|
you tell it to, and only the ESR, Developer Edition and Nightly builds accept
|
||||||
|
being told:
|
||||||
|
|
||||||
|
1. Open `about:config`, accept the warning
|
||||||
|
2. Set `xpinstall.signatures.required` to **false**
|
||||||
|
3. Open `about:addons`, click the **gear** icon, choose **Install Add-on From
|
||||||
|
File**, and pick `logsdu-<version>.xpi`
|
||||||
|
|
||||||
|
It survives restarts. On **release** Firefox that pref is ignored -- see
|
||||||
|
"Release Firefox" below.
|
||||||
|
|
||||||
|
Finally, open the extension's options page, fill in the four values, and save.
|
||||||
|
|
||||||
|
## The four values
|
||||||
|
|
||||||
|
| Field | Example | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored |
|
||||||
|
| Registration number | `2000101010` | Digits only |
|
||||||
|
| Birth date | `01/01/2000` | Reformatted as you type |
|
||||||
|
| National ID | `000.000.000-00` | Reformatted as you type |
|
||||||
|
|
||||||
|
Paste raw digits if you like -- the options page inserts the separators, because
|
||||||
|
the portal's input masks expect the values in exactly that shape.
|
||||||
|
|
||||||
|
**The portal address is configuration, not code.** No institution is named
|
||||||
|
anywhere in the extension: not in the manifest, not in the source, not in the
|
||||||
|
build output. Be clear about what that does and does not buy you. It stops
|
||||||
|
someone who reads the extension from learning which portal it is for. It does
|
||||||
|
not hide anything from someone who can read the extension's storage -- and that
|
||||||
|
is the same access that would expose your national ID and birth date anyway.
|
||||||
|
|
||||||
|
## How it behaves
|
||||||
|
|
||||||
|
- **Fills and submits** on the login page, with no interaction.
|
||||||
|
- **At most one automatic submit per hour.** After an attempt, the extension
|
||||||
|
drops back to filling only, so a wrong value cannot resubmit itself on every
|
||||||
|
page load and lock you out. Correct the values and save; saving clears the
|
||||||
|
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.
|
||||||
|
|
||||||
|
## How the values are stored
|
||||||
|
|
||||||
|
In `storage.local`: private to this browser profile, never synced, never sent
|
||||||
|
anywhere. That is the same protection a browser-saved password gets, and it has
|
||||||
|
the same limit -- anyone with your unlocked account can read it. If that is not
|
||||||
|
good enough for your threat model, this extension is the wrong tool.
|
||||||
|
|
||||||
|
## Release Firefox
|
||||||
|
|
||||||
|
`xpinstall.signatures.required` only works on ESR, Developer Edition and
|
||||||
|
Nightly. Release Firefox ignores it and refuses unsigned add-ons outright, so
|
||||||
|
there the same `.xpi` has to be signed first: upload it to
|
||||||
|
[addons.mozilla.org](https://addons.mozilla.org) as an **unlisted** add-on.
|
||||||
|
Signing is automated -- nothing is published publicly or reviewed by hand -- and
|
||||||
|
you install the signed file it hands back.
|
||||||
|
|
||||||
|
The `browser_specific_settings.gecko.id` in the manifest is what gives the
|
||||||
|
add-on a stable identity across both routes, so settings survive an upgrade
|
||||||
|
from one to the other.
|
||||||
|
|
||||||
|
## Chrome
|
||||||
|
|
||||||
|
Not yet. The source deliberately avoids anything Firefox-specific: it uses the
|
||||||
|
`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build
|
||||||
|
should be a manifest question rather than a rewrite. It has not been tried, so
|
||||||
|
do not assume it works.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
corepack pnpm install
|
||||||
|
make test # unit tests
|
||||||
|
make # typecheck and bundle into build/
|
||||||
|
corepack pnpm run dev # rebuild on change
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
While iterating, reinstalling an `.xpi` for every edit is tedious. Load the
|
||||||
|
unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load
|
||||||
|
Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after
|
||||||
|
each rebuild. That copy disappears on restart, which is the point -- it is for
|
||||||
|
development, not for daily use.
|
||||||
|
|
||||||
|
`corepack pnpm run dev` watches and rebuilds, static files included, but
|
||||||
|
Firefox still needs the Reload click to pick anything up.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/manifest.json` | MV3 manifest. Names no site |
|
||||||
|
| `src/content.ts` | Isolated world. Decides whether to act, then delegates |
|
||||||
|
| `src/injected.ts` | Page world. Does the actual filling and clicking |
|
||||||
|
| `src/portal.ts` | Every selector the extension knows about the form |
|
||||||
|
| `src/config.ts` | Stored values, the rate limit and the logout cooldown |
|
||||||
|
| `src/format.ts` | Input normalisers for the three masked fields |
|
||||||
|
| `src/options.*`, `src/popup.*` | The two bits of UI |
|
||||||
|
|
||||||
|
### Why two scripts instead of one
|
||||||
|
|
||||||
|
The portal drives its inputs with Inputmask, which replaces each element's
|
||||||
|
`value` property with its own accessor. A content script assigning
|
||||||
|
`input.value` from the isolated world writes through Xrays to the *native*
|
||||||
|
setter and skips that accessor: the field looks right on screen, but the mask's
|
||||||
|
buffer is unchanged, and the page's submit handler reads the stale buffer back
|
||||||
|
out through jQuery. So the filling happens inside the page, through the page's
|
||||||
|
own jQuery and Inputmask.
|
||||||
|
|
||||||
|
Submitting is a real click on the button, never `form.submit()`. The portal
|
||||||
|
intercepts the submit event, cancels it, and posts by AJAX with a CSRF token
|
||||||
|
taken from a meta tag. `form.submit()` would bypass that handler and lose the
|
||||||
|
token; a click reproduces exactly what a person pressing "Entrar" does.
|
||||||
|
|
||||||
|
### Why the content script matches every URL
|
||||||
|
|
||||||
|
The address is configured at runtime, so it cannot also be a manifest match
|
||||||
|
pattern -- putting it there is exactly what would name the institution in the
|
||||||
|
shipped code. The script therefore loads everywhere and stops immediately
|
||||||
|
unless the page's origin equals the configured one. Origins are compared whole,
|
||||||
|
so `portal.example.br.evil.tld` does not match.
|
||||||
|
|
||||||
|
The honest cost: the extension holds read access to every page you visit. The
|
||||||
|
alternative -- registering the content script at runtime with
|
||||||
|
`scripting.registerContentScripts` and an optional host permission -- avoids
|
||||||
|
that at the price of a background script and a permission prompt.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
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 |
@@ -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: {}
|
||||||
110
src/config.test.ts
Normal file
110
src/config.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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,
|
||||||
|
} from './config.ts';
|
||||||
|
import type { Config, State } from './config.ts';
|
||||||
|
|
||||||
|
const CONFIG: Config = {
|
||||||
|
url: 'https://portal.example.br/',
|
||||||
|
ra: '2000101010',
|
||||||
|
dn: '01/02/1999',
|
||||||
|
cpf: '123.456.789-01',
|
||||||
|
autoSubmit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
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('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',
|
||||||
|
});
|
||||||
|
});
|
||||||
133
src/config.ts
Normal file
133
src/config.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 { toOrigin } from './format.ts';
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'config';
|
||||||
|
const STATE_KEY = 'state';
|
||||||
|
|
||||||
|
/** At most one automatic submit per hour. */
|
||||||
|
export const SUBMIT_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** How long an explicit logout suppresses the automatic submit. */
|
||||||
|
export const LOGOUT_COOLDOWN_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four values the user configures. The site address is one of them so
|
||||||
|
* that no institution is named anywhere in the shipped code.
|
||||||
|
*
|
||||||
|
* This is stored unencrypted in extension-local storage, which is the same
|
||||||
|
* protection a browser-saved password gets: private to this profile, never
|
||||||
|
* synced, never transmitted, but readable by anyone with your unlocked
|
||||||
|
* account.
|
||||||
|
*/
|
||||||
|
export interface Config {
|
||||||
|
url: string;
|
||||||
|
ra: string;
|
||||||
|
dn: string;
|
||||||
|
cpf: string;
|
||||||
|
autoSubmit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bookkeeping that enforces the rate limit and the logout cooldown. */
|
||||||
|
export interface State {
|
||||||
|
lastSubmitAt: number;
|
||||||
|
logoutAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_CONFIG: Config = {
|
||||||
|
url: '',
|
||||||
|
ra: '',
|
||||||
|
dn: '',
|
||||||
|
cpf: '',
|
||||||
|
autoSubmit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_STATE: State = { lastSubmitAt: 0, logoutAt: 0 };
|
||||||
|
|
||||||
|
export async function loadConfig(): Promise<Config> {
|
||||||
|
const stored = await chrome.storage.local.get(CONFIG_KEY);
|
||||||
|
return { ...EMPTY_CONFIG, ...((stored[CONFIG_KEY] as Partial<Config>) ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConfig(config: Config): Promise<void> {
|
||||||
|
await chrome.storage.local.set({ [CONFIG_KEY]: config });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadState(): Promise<State> {
|
||||||
|
const stored = await chrome.storage.local.get(STATE_KEY);
|
||||||
|
return { ...EMPTY_STATE, ...((stored[STATE_KEY] as Partial<State>) ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveState(state: State): Promise<void> {
|
||||||
|
await chrome.storage.local.set({ [STATE_KEY]: state });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the rate limit, so fixing a typo lets you retry straight away. */
|
||||||
|
export async function resetState(): Promise<void> {
|
||||||
|
await saveState(EMPTY_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfigured(config: Config): boolean {
|
||||||
|
return (
|
||||||
|
toOrigin(config.url) !== null &&
|
||||||
|
config.ra !== '' &&
|
||||||
|
config.dn !== '' &&
|
||||||
|
config.cpf !== ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a given page belongs to the configured site.
|
||||||
|
*
|
||||||
|
* The content script is declared against every URL, so this is the guard that
|
||||||
|
* makes it a no-op everywhere else. Origins are compared rather than prefixes
|
||||||
|
* so that a lookalike host cannot match.
|
||||||
|
*/
|
||||||
|
export function matchesSite(config: Config, href: string): boolean {
|
||||||
|
const configured = toOrigin(config.url);
|
||||||
|
if (configured === null) return false;
|
||||||
|
try {
|
||||||
|
return new URL(href).origin === configured;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmitDecision =
|
||||||
|
| { submit: true }
|
||||||
|
| { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' };
|
||||||
|
|
||||||
|
/** Decide whether this page load is allowed to press "Entrar". */
|
||||||
|
export function decideSubmit(
|
||||||
|
config: Config,
|
||||||
|
state: State,
|
||||||
|
now: number,
|
||||||
|
): SubmitDecision {
|
||||||
|
if (!config.autoSubmit) return { submit: false, reason: 'disabled' };
|
||||||
|
if (now - state.logoutAt < LOGOUT_COOLDOWN_MS) {
|
||||||
|
return { submit: false, reason: 'logout' };
|
||||||
|
}
|
||||||
|
if (now - state.lastSubmitAt < SUBMIT_INTERVAL_MS) {
|
||||||
|
return { submit: false, reason: 'rate-limited' };
|
||||||
|
}
|
||||||
|
return { submit: true };
|
||||||
|
}
|
||||||
208
src/content.ts
Normal file
208
src/content.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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
// 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, FORM, LOGOUT } 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(): Promise<HTMLFormElement | null> {
|
||||||
|
const existing = document.querySelector<HTMLFormElement>(FORM);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const found = document.querySelector<HTMLFormElement>(FORM);
|
||||||
|
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(): void {
|
||||||
|
document.addEventListener(
|
||||||
|
'click',
|
||||||
|
(event) => {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
if (!target?.closest?.(LOGOUT)) return;
|
||||||
|
void loadState().then((state) =>
|
||||||
|
saveState({ ...state, logoutAt: Date.now() }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoLogin(config: Config): Promise<void> {
|
||||||
|
const form = await awaitForm();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.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(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 {
|
||||||
|
|||||||
165
src/injected.ts
Normal file
165
src/injected.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 *
|
||||||
|
// *************************************************************************
|
||||||
|
|
||||||
|
// This half runs in the PAGE world, not the content-script world, and that is
|
||||||
|
// the whole point of it existing as a separate file.
|
||||||
|
//
|
||||||
|
// The portal's three inputs are driven by Inputmask, which replaces the
|
||||||
|
// elements' `value` property with its own accessor. A content script writing
|
||||||
|
// `input.value` from the isolated world goes through Xrays straight to the
|
||||||
|
// native setter, bypassing that accessor: the pixels change, but Inputmask's
|
||||||
|
// internal buffer does not, and the page's own submit handler reads the stale
|
||||||
|
// buffer back through jQuery's .val(). Filling from inside the page, using the
|
||||||
|
// page's own jQuery and Inputmask instances, is the only way the values the
|
||||||
|
// user sees are the values that get posted.
|
||||||
|
//
|
||||||
|
// It also lets us wait for the real precondition. The portal loads its scripts
|
||||||
|
// through Cloudflare Rocket Loader, so at document_idle jQuery may not exist
|
||||||
|
// yet and the form's submit handler is certainly not bound. Clicking "Entrar"
|
||||||
|
// before that handler exists would trigger a plain browser form POST without
|
||||||
|
// the CSRF header, which fails.
|
||||||
|
|
||||||
|
import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts';
|
||||||
|
import type { FillRequest, FillResult } from './portal.ts';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 100;
|
||||||
|
const POLL_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
type JQueryLike = any;
|
||||||
|
|
||||||
|
function report(result: FillResult): void {
|
||||||
|
// The detail is a string because it has to survive the trip back to the
|
||||||
|
// isolated world; structured objects created in the page cannot always be
|
||||||
|
// read from a content script.
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(EVENT_RESULT, { detail: JSON.stringify(result) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** jQuery, the form, and (when submitting) the page's own submit handler. */
|
||||||
|
function readiness(needSubmit: boolean): JQueryLike | null {
|
||||||
|
const $ = (window as unknown as { jQuery?: JQueryLike }).jQuery;
|
||||||
|
if (!$) return null;
|
||||||
|
const form = $(FORM);
|
||||||
|
if (!form.length) return null;
|
||||||
|
if (needSubmit) {
|
||||||
|
// jQuery keeps its handler registry in the private _data store. If the
|
||||||
|
// submit handler is not bound yet, clicking would bypass the AJAX login
|
||||||
|
// the page actually implements.
|
||||||
|
const events = typeof $._data === 'function' ? $._data(form[0], 'events') : null;
|
||||||
|
if (!events || !events.submit) return null;
|
||||||
|
}
|
||||||
|
return $;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitFor(needSubmit: boolean): Promise<JQueryLike> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||||
|
const tick = (): void => {
|
||||||
|
const $ = readiness(needSubmit);
|
||||||
|
if ($) {
|
||||||
|
resolve($);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
reject(new Error('login form or page scripts never became ready'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.setTimeout(tick, POLL_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set one masked input.
|
||||||
|
*
|
||||||
|
* Inputmask 5 hangs an `inputmask` object off the element; using its setValue
|
||||||
|
* keeps the mask's buffer and the displayed text in agreement. The jQuery path
|
||||||
|
* is the fallback for the case where the mask has not been applied to that
|
||||||
|
* field.
|
||||||
|
*/
|
||||||
|
function setField($: JQueryLike, element: HTMLInputElement, value: string): void {
|
||||||
|
const mask = (element as unknown as { inputmask?: { setValue?: (v: string) => void } })
|
||||||
|
.inputmask;
|
||||||
|
if (mask && typeof mask.setValue === 'function') {
|
||||||
|
mask.setValue(value);
|
||||||
|
} else {
|
||||||
|
$(element).val(value);
|
||||||
|
}
|
||||||
|
$(element).trigger('input').trigger('change');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(request: FillRequest): Promise<void> {
|
||||||
|
const $ = await waitFor(request.submit);
|
||||||
|
const form = $(FORM);
|
||||||
|
|
||||||
|
const targets: Array<[string, string]> = [
|
||||||
|
[FIELDS.ra, request.ra],
|
||||||
|
[FIELDS.dn, request.dn],
|
||||||
|
[FIELDS.cpf, request.cpf],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [selector, value] of targets) {
|
||||||
|
// Scoped to the form: an unscoped CPF lookup would hit the "forgot your
|
||||||
|
// registration number" modal on the same page.
|
||||||
|
const field = form.find(selector);
|
||||||
|
if (!field.length) {
|
||||||
|
report({ filled: false, submitted: false, error: `missing field ${selector}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setField($, field[0] as HTMLInputElement, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.submit) {
|
||||||
|
report({ filled: true, submitted: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = form.find(SUBMIT)[0] as HTMLButtonElement | undefined;
|
||||||
|
if (!button) {
|
||||||
|
report({ filled: true, submitted: false, error: 'missing submit button' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A native click, not form.submit(). The page calls preventDefault() on the
|
||||||
|
// submit event and posts by AJAX with a CSRF header taken from a meta tag;
|
||||||
|
// form.submit() would skip that handler entirely, while a click reproduces
|
||||||
|
// exactly what the user pressing "Entrar" does, CSRF included.
|
||||||
|
report({ filled: true, submitted: true });
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payload arrives on the script tag's own dataset, which sidesteps
|
||||||
|
// cross-world structured cloning. Read it and drop the element immediately, so
|
||||||
|
// the credentials are in the DOM for as short a time as possible.
|
||||||
|
const self = document.currentScript as HTMLScriptElement | null;
|
||||||
|
const payload = self?.dataset.logsdu ?? '';
|
||||||
|
self?.remove();
|
||||||
|
|
||||||
|
try {
|
||||||
|
void run(JSON.parse(payload) as FillRequest).catch((error: unknown) => {
|
||||||
|
report({ filled: false, submitted: false, error: String(error) });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
report({ filled: false, submitted: false, error: String(error) });
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Fills and submits a three-field academic portal login.",
|
"description": "Fills and submits a three-field academic portal login.",
|
||||||
"icons": {
|
"icons": {
|
||||||
"48": "icons/logsdu-48.png",
|
"48": "icons/logsdu.svg",
|
||||||
"96": "icons/logsdu-96.png"
|
"96": "icons/logsdu.svg"
|
||||||
},
|
},
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_title": "logsdu"
|
"default_title": "logsdu",
|
||||||
|
"default_icon": "icons/logsdu.svg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/options.html
Normal file
59
src/options.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!--
|
||||||
|
* (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>
|
||||||
|
|
||||||
|
<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>Como isso é guardado</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Os quatro valores ficam na memória local desta extensão, neste perfil
|
||||||
|
do navegador. Não são sincronizados nem enviados para lugar nenhum. A
|
||||||
|
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>
|
||||||
140
src/options.ts
Normal file
140
src/options.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 } from './config.ts';
|
||||||
|
import {
|
||||||
|
formatCpf,
|
||||||
|
formatDate,
|
||||||
|
formatRa,
|
||||||
|
isCompleteCpf,
|
||||||
|
isCompleteDate,
|
||||||
|
isCompleteRa,
|
||||||
|
toOrigin,
|
||||||
|
} from './format.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');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const complaint = validate();
|
||||||
|
if (complaint) {
|
||||||
|
setStatus(complaint, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await saveConfig({
|
||||||
|
url: fields.url.value.trim(),
|
||||||
|
ra: fields.ra.value,
|
||||||
|
dn: fields.dn.value,
|
||||||
|
cpf: fields.cpf.value,
|
||||||
|
autoSubmit: autoSubmit.checked,
|
||||||
|
});
|
||||||
|
// Saving is how you correct a typo, so it also clears the hourly limit
|
||||||
|
// and the logout cooldown: the next visit is allowed to try again.
|
||||||
|
await resetState();
|
||||||
|
setStatus('Salvo.');
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('clear').addEventListener('click', () => {
|
||||||
|
void (async () => {
|
||||||
|
await saveConfig({ ...EMPTY_CONFIG });
|
||||||
|
await resetState();
|
||||||
|
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>
|
||||||
79
src/popup.ts
Normal file
79
src/popup.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// *************************************************************************
|
||||||
|
// * (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 } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -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