Compare commits
5
Commits
14f6fd6a8e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528eaafb18 | ||
|
|
8a13fb527e | ||
|
|
8d42faf707 | ||
|
|
e432585f04 | ||
|
|
fa33c6e179 |
@@ -161,22 +161,25 @@ is claimed or a scan does not complete.
|
|||||||
### Installing the agent
|
### Installing the agent
|
||||||
|
|
||||||
`/fpagent/install.sh` is a public installer (also served for `curl | bash`). It
|
`/fpagent/install.sh` is a public installer (also served for `curl | bash`). It
|
||||||
fetches the newest bundle from the private
|
fetches the newest bundle for the host architecture — x86_64 or 32-bit i386,
|
||||||
|
preferring the bundle built for the machine's Ubuntu codename — from the private
|
||||||
[`fingerprint-agent`](https://sources.yrka.app/YOUreka/fingerprint-agent/releases)
|
[`fingerprint-agent`](https://sources.yrka.app/YOUreka/fingerprint-agent/releases)
|
||||||
forge, verifies its sha256, then runs the bundle's own installer, which handles
|
forge, verifies its sha256, then runs the bundle's own installer, which handles
|
||||||
libfprint, the udev rule, `plugdev` and the systemd service. On a field laptop:
|
libfprint, the udev rule, `plugdev` and the systemd service. On a field laptop:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --enable-service
|
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash
|
||||||
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # report only
|
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # report only
|
||||||
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --tag v1.0.0 --enable-service
|
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --tag v1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Rerunning it is the upgrade path: it installs the newest release (or `--tag`
|
Rerunning it is the upgrade path: it installs the newest release (or `--tag`
|
||||||
for a pinned one), the bundle installer reports the version change, and with
|
for a pinned one), the bundle installer reports the version change, and it
|
||||||
`--enable-service` it restarts a running service so the new binary takes effect.
|
restarts a running service so the new binary takes effect. The version is the
|
||||||
The version is the git hash carried in the asset name, stamped next to the
|
git hash carried in the asset name, stamped next to the binary
|
||||||
binary (`/usr/local/bin/fpagent.version`) and compared on the next run.
|
(`/usr/local/bin/fpagent.version`) and compared on the next run. The systemd
|
||||||
|
service and the self-update timer are on by default; pass `--no-service` or
|
||||||
|
`--no-updates` to leave them off.
|
||||||
|
|
||||||
The forge is private, so the operator's laptop needs no credentials: the app
|
The forge is private, so the operator's laptop needs no credentials: the app
|
||||||
reads the release with `GITEA_RELEASE_INSTALL_KEY` and proxies it through
|
reads the release with `GITEA_RELEASE_INSTALL_KEY` and proxies it through
|
||||||
|
|||||||
+3
-2
@@ -25,5 +25,6 @@
|
|||||||
|
|
||||||
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
|
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
|
||||||
(66/66 pass.)
|
(66/66 pass.)
|
||||||
- [ ] 3.2 Field pass against a real domain (a2loft deployed): look up a
|
- [x] 3.2 Field pass against a real domain (a2loft deployed): look up a
|
||||||
reference and save both fingerprints with no OTP.
|
reference and save both fingerprints with no OTP. Verified in production:
|
||||||
|
the lookup and both fingerprint saves succeeded with no OTP.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-06
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Design: fpagent-installer-multiarch
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The hosted installer (`tools/public/fpagent/install.sh`) runs on the field
|
||||||
|
laptop and proxies the private release through `tools.yrka.app`. It resolves
|
||||||
|
the release's asset names with a small `python3` snippet and hardcodes the
|
||||||
|
x86_64 suffix. fingerprint-agent v1.5.0 added an i386 build:
|
||||||
|
|
||||||
|
- online: `fpagent-<version>-linux-x86_64.tar.gz`, `fpagent-<version>-linux-i386.tar.gz`
|
||||||
|
- offline: `fpagent-<version>-ubuntu-<release>-x86_64.tar.gz` (x86_64 only)
|
||||||
|
|
||||||
|
The bundle's own installer already refuses a binary whose ELF class does not
|
||||||
|
match the host, so the hosted installer only has to pick the right asset.
|
||||||
|
|
||||||
|
## Goals / Non-goals
|
||||||
|
|
||||||
|
- Goal: on any supported host (x86_64, i386) pick the release bundle built for
|
||||||
|
that architecture, and record its version.
|
||||||
|
- Non-goal: changing `tools/server.js`. It proxies whatever the release lists,
|
||||||
|
and asset filtering/naming is unchanged.
|
||||||
|
- Non-goal: i386 offline (codename) bundles. fingerprint-agent ships those
|
||||||
|
x86_64-only; i386 hosts fall back to the generic online bundle whose
|
||||||
|
installer apt-get's libfprint.
|
||||||
|
- Non-goal: adding a production environment knob for the architecture. The
|
||||||
|
host's own `uname -m` is the source of truth.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Architecture from `uname -m`, resolved once
|
||||||
|
|
||||||
|
Map `x86_64|amd64` → `x86_64`, `i386|i486|i586|i686` → `i386`, anything else →
|
||||||
|
fail before any download. This mirrors `deploy/install.sh` in fingerprint-agent
|
||||||
|
so the hosted and bundled installers agree on what is supported.
|
||||||
|
|
||||||
|
### Selection order: codename bundle, then generic, both arch-scoped
|
||||||
|
|
||||||
|
With `codename` from `/etc/os-release`:
|
||||||
|
|
||||||
|
1. `-ubuntu-<codename>-<arch>.tar.gz`
|
||||||
|
2. `-linux-<arch>.tar.gz`
|
||||||
|
|
||||||
|
The codename attempt is skipped when the host has no codename. Because offline
|
||||||
|
bundles are x86_64-only, an i386 host simply finds no codename asset and falls
|
||||||
|
to step 2. The suffix match keeps one architecture's bundle from ever matching
|
||||||
|
another's (`-linux-i386` does not end with `-linux-x86_64`).
|
||||||
|
|
||||||
|
### Version regex derived from the architecture
|
||||||
|
|
||||||
|
The version is the git hash embedded in the asset name. The existing regex only
|
||||||
|
listed the x86_64 endings, so it produced an empty version for an i386 bundle
|
||||||
|
and nothing was recorded. The regex must accept the same set of endings the
|
||||||
|
selection can produce: `linux-x86_64`, `linux-i386` and
|
||||||
|
`ubuntu-<codename>-x86_64`.
|
||||||
|
|
||||||
|
### Tests stub `uname`
|
||||||
|
|
||||||
|
The installer takes the architecture from the host, so a test cannot assert the
|
||||||
|
i386 path on an x86_64 CI box by running the real `uname`. A test prepends a
|
||||||
|
temp directory holding a fake `uname` to `PATH`, which is contained and needs no
|
||||||
|
production-only override. Assertions are made on the gateway requests the script
|
||||||
|
makes (which asset it asks for), the way the existing `--tag` test does.
|
||||||
|
|
||||||
|
## Risks / trade-offs
|
||||||
|
|
||||||
|
- A 64-bit userland on a 32-bit kernel is exotic; `uname -m` is the documented
|
||||||
|
choice for the bundled installer, so the hosted one follows it rather than
|
||||||
|
trying a second heuristic. `ponytail:` if such hosts appear, add a
|
||||||
|
`getconf LONG_BIT` fallback in both installers at once.
|
||||||
|
- The generated `install.sh --help` text duplicates the bundle installer's
|
||||||
|
flags list. It can drift again; keeping it short and pointing at the bundle
|
||||||
|
installer's own help limits the cost.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Proposal: fpagent-installer-multiarch
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The hosted installer only ever picks the x86_64 bundle. fingerprint-agent
|
||||||
|
v1.5.0 publishes a 32-bit `-linux-i386` bundle and its own installer accepts
|
||||||
|
i386 hosts, so a 32-bit field laptop running the public one-liner fails with
|
||||||
|
"no compatible bundle" and installs nothing, even though a matching release
|
||||||
|
asset exists.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- The installer detects the host architecture (`uname -m` → `x86_64` or
|
||||||
|
`i386`) and selects the matching bundle: the distro-codename bundle when one
|
||||||
|
exists for that architecture, else the generic `-linux-<arch>` bundle.
|
||||||
|
- An unsupported architecture fails with a message that names the host machine,
|
||||||
|
and installs nothing.
|
||||||
|
- The version parsed from the asset name accepts the `-linux-i386` suffix, so a
|
||||||
|
32-bit install records and reports its version the same way x86_64 does.
|
||||||
|
- The installer's help text matches the bundle installer: the systemd service
|
||||||
|
and the self-update timer are on by default, so `--no-service`,
|
||||||
|
`--no-updates` and `--update-base` are documented instead of
|
||||||
|
`--enable-service` being described as required to get a service.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `fpagent-installer`: bundle selection becomes architecture-aware (not just
|
||||||
|
distro-codename aware), the no-compatible-bundle failure names the host
|
||||||
|
architecture, and the version is derived for every published architecture.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/public/fpagent/install.sh`: architecture detection, bundle pick,
|
||||||
|
version regex, help text.
|
||||||
|
- `tools/test/fpagent-install.test.js`: architecture selection and version
|
||||||
|
parsing tests.
|
||||||
|
- `README.md`: the installer section (flags and architectures).
|
||||||
|
- Depends on the `fingerprint-agent` bundle naming
|
||||||
|
`fpagent-<version>-linux-<arch>.tar.gz`, where `<arch>` is `x86_64` or
|
||||||
|
`i386` (v1.5.0 and newer). No change to `tools/server.js`: the server proxies
|
||||||
|
whatever assets the release lists.
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public installer endpoint
|
||||||
|
|
||||||
|
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||||
|
reachable without authentication. When run, the script SHALL determine the
|
||||||
|
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||||
|
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||||
|
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||||
|
bundle built for the host's distro codename when one exists for that
|
||||||
|
architecture, and otherwise use the generic bundle for that architecture. The
|
||||||
|
script SHALL be served uncached.
|
||||||
|
|
||||||
|
#### Scenario: Installer is fetched anonymously
|
||||||
|
|
||||||
|
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||||
|
- **THEN** the app returns the installer script and does not redirect to login
|
||||||
|
|
||||||
|
#### Scenario: Host-appropriate bundle is chosen
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||||
|
codename and a generic bundle
|
||||||
|
- **THEN** the installer uses the codename bundle
|
||||||
|
|
||||||
|
#### Scenario: Generic bundle is the fallback
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||||
|
codename
|
||||||
|
- **THEN** the installer uses the generic bundle
|
||||||
|
|
||||||
|
#### Scenario: Bundle matches the host architecture
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||||
|
bundle for another architecture
|
||||||
|
- **THEN** the installer chooses the bundle for the host architecture
|
||||||
|
- **AND** it never chooses a bundle for another architecture
|
||||||
|
|
||||||
|
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||||
|
generic 32-bit bundle
|
||||||
|
- **THEN** the installer uses that generic 32-bit bundle
|
||||||
|
|
||||||
|
#### Scenario: Checksum is verified before extraction
|
||||||
|
|
||||||
|
- **WHEN** the installer downloads a bundle and its published checksum
|
||||||
|
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||||
|
when the checksum does not match
|
||||||
|
|
||||||
|
#### Scenario: Options are forwarded to the bundled installer
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||||
|
`--check`
|
||||||
|
- **THEN** it passes those options through to the extracted bundle's installer
|
||||||
|
|
||||||
|
#### Scenario: No compatible bundle
|
||||||
|
|
||||||
|
- **WHEN** the latest release contains no bundle for the host architecture
|
||||||
|
- **THEN** the installer exits with a failure message that names the host
|
||||||
|
architecture and installs nothing
|
||||||
|
|
||||||
|
#### Scenario: Unsupported architecture
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||||
|
x86_64 nor i386
|
||||||
|
- **THEN** it exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Installed version is tracked and upgrades are reported
|
||||||
|
|
||||||
|
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||||
|
version, for every architecture it can install. It SHALL hand that version to
|
||||||
|
the bundle installer, which SHALL record it beside the installed binary and
|
||||||
|
SHALL report an upgrade with the previous and new versions on the next run.
|
||||||
|
Uninstalling SHALL remove the recorded version.
|
||||||
|
|
||||||
|
#### Scenario: Version is derived from the asset name
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Version is derived for a 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a generic bundle named
|
||||||
|
`fpagent-<hash>-linux-i386.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Upgrade is reported
|
||||||
|
|
||||||
|
- **WHEN** the recorded version differs from the version being installed
|
||||||
|
- **THEN** the bundle installer reports the change from the old to the new
|
||||||
|
version
|
||||||
|
|
||||||
|
#### Scenario: First install records the version
|
||||||
|
|
||||||
|
- **WHEN** no version has been recorded yet
|
||||||
|
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||||
|
|
||||||
|
#### Scenario: Uninstall removes the recorded version
|
||||||
|
|
||||||
|
- **WHEN** the installer is asked to uninstall
|
||||||
|
- **THEN** the recorded version is removed
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 1. Hosted installer (`tools/public/fpagent/install.sh`): resolve the host
|
||||||
|
architecture from `uname -m` (`x86_64`/`i386`, else die) before any download,
|
||||||
|
and pass it to the asset picker. Verify: `bash -n install.sh`; a stubbed
|
||||||
|
`uname` printing `i686` reaches the metadata fetch, and one printing `aarch64`
|
||||||
|
dies without fetching.
|
||||||
|
- [x] 2. Hosted installer: pick `-ubuntu-<codename>-<arch>.tar.gz` first, then
|
||||||
|
`-linux-<arch>.tar.gz`, and make the "no compatible bundle" message name the
|
||||||
|
host architecture. Verify: with a release offering only
|
||||||
|
`fpagent-<hash>-linux-i386.tar.gz`, a stubbed i686 host requests that asset;
|
||||||
|
with only `-linux-x86_64`, the same host fails and installs nothing.
|
||||||
|
- [x] 3. Hosted installer: derive the version from the asset name for every
|
||||||
|
supported architecture (`linux-x86_64`, `linux-i386`,
|
||||||
|
`ubuntu-<codename>-x86_64`). Verify: an i386 asset name yields the git hash,
|
||||||
|
not an empty string.
|
||||||
|
- [x] 4. Hosted installer: correct the help text — service and self-update are
|
||||||
|
on by default; list `--no-service`, `--no-updates`, `--update-base`, and keep
|
||||||
|
the note that options are forwarded to the bundle installer.
|
||||||
|
Verify: `install.sh --help` mentions the three flags and no longer implies
|
||||||
|
`--enable-service` is required.
|
||||||
|
- [x] 5. Tests (`tools/test/fpagent-install.test.js`): stub `uname` on `PATH`
|
||||||
|
and assert i386 selection, cross-architecture rejection, and i386 version
|
||||||
|
parsing. Verify: `node --test tools/test/fpagent-install.test.js` passes.
|
||||||
|
- [x] 6. Docs (`README.md`): installer section notes x86_64 and i386 bundles
|
||||||
|
and the on-by-default service/self-update. Verify: reading the section, no
|
||||||
|
flag claims contradict `install.sh --help`.
|
||||||
@@ -114,16 +114,16 @@ and SHALL display the agent version reported on connect.
|
|||||||
|
|
||||||
### Requirement: Two-finger enrollment flow
|
### Requirement: Two-finger enrollment flow
|
||||||
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
||||||
up staff by reference (OTP-validated through the a2loft proxy), displaying
|
up staff by reference (token-validated through the a2loft proxy), displaying
|
||||||
the staff details, capturing the right thumb and right index finger, and
|
the staff details, capturing the right thumb and right index finger, and
|
||||||
saving both fingerprints through the a2loft proxy with an OTP. The wizard
|
saving both fingerprints through the a2loft proxy authenticated with the
|
||||||
SHALL offer per-finger recapture until save, SHALL require both fingers
|
session token. The wizard SHALL offer per-finger recapture until save, SHALL
|
||||||
before save, and SHALL reset domain-independent state (reference, staff
|
require both fingers before save, and SHALL reset domain-independent state
|
||||||
details, captured artifacts) when starting a new enrollment.
|
(reference, staff details, captured artifacts) when starting a new
|
||||||
|
enrollment.
|
||||||
|
|
||||||
#### Scenario: Complete enrollment
|
#### Scenario: Complete enrollment
|
||||||
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
||||||
with an OTP
|
|
||||||
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
||||||
wizard reports success
|
wizard reports success
|
||||||
|
|
||||||
@@ -137,24 +137,6 @@ details, captured artifacts) when starting a new enrollment.
|
|||||||
- **THEN** the reference, staff details, and captured finger artifacts are
|
- **THEN** the reference, staff details, and captured finger artifacts are
|
||||||
cleared while the permitted domain list persists
|
cleared while the permitted domain list persists
|
||||||
|
|
||||||
### Requirement: OTP validated on every a2loft call
|
|
||||||
The wizard SHALL collect an OTP for each a2loft call — staff lookup and
|
|
||||||
fingerprint save — and pass it to the same-origin proxy; a2loft validates it.
|
|
||||||
Submission SHALL be blocked while the OTP is missing.
|
|
||||||
|
|
||||||
#### Scenario: Lookup with OTP
|
|
||||||
- **WHEN** the operator submits a reference with an OTP
|
|
||||||
- **THEN** the wizard calls the staff-lookup proxy with that OTP and
|
|
||||||
displays the returned staff details
|
|
||||||
|
|
||||||
#### Scenario: Save requires a fresh OTP
|
|
||||||
- **WHEN** the operator saves the captured fingerprints
|
|
||||||
- **THEN** the wizard sends the OTP entered for the save call
|
|
||||||
|
|
||||||
#### Scenario: Missing OTP blocks submission
|
|
||||||
- **WHEN** the OTP field is empty for a lookup or save
|
|
||||||
- **THEN** the corresponding submit control is disabled
|
|
||||||
|
|
||||||
### Requirement: Scanner availability is shown and gates capture
|
### Requirement: Scanner availability is shown and gates capture
|
||||||
The wizard SHALL consume fpagent's `fprint-device-status`
|
The wizard SHALL consume fpagent's `fprint-device-status`
|
||||||
`{available, device, captureAgentId}` event and SHALL distinguish the agent
|
`{available, device, captureAgentId}` event and SHALL distinguish the agent
|
||||||
@@ -195,3 +177,23 @@ guarantee a capture will succeed.
|
|||||||
- **WHEN** the agent socket disconnects
|
- **WHEN** the agent socket disconnects
|
||||||
- **THEN** the wizard shows the agent offline and does not present the reader as
|
- **THEN** the wizard shows the agent offline and does not present the reader as
|
||||||
available
|
available
|
||||||
|
|
||||||
|
### Requirement: Token authenticated on every a2loft call
|
||||||
|
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
|
||||||
|
save — with the signed-in operator's PocketBase session token, sent by the
|
||||||
|
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
|
||||||
|
NOT collect an OTP.
|
||||||
|
|
||||||
|
#### Scenario: Lookup uses the session token
|
||||||
|
- **WHEN** the operator submits a reference
|
||||||
|
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
|
||||||
|
session token
|
||||||
|
|
||||||
|
#### Scenario: Save uses the session token
|
||||||
|
- **WHEN** the operator saves the captured fingerprints
|
||||||
|
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
|
||||||
|
the session token
|
||||||
|
|
||||||
|
#### Scenario: No OTP field
|
||||||
|
- **WHEN** the wizard page is served
|
||||||
|
- **THEN** it offers no OTP input for lookup or save
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ proxies the private release and serves the installer.
|
|||||||
### Requirement: Public installer endpoint
|
### Requirement: Public installer endpoint
|
||||||
|
|
||||||
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||||
reachable without authentication. When run, the script SHALL fetch the latest
|
reachable without authentication. When run, the script SHALL determine the
|
||||||
release metadata, choose a bundle for the host, verify the bundle's published
|
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||||
sha256 checksum, extract it, and hand off to the extracted bundle's own
|
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||||
installer. The script SHALL be served uncached.
|
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||||
|
bundle built for the host's distro codename when one exists for that
|
||||||
|
architecture, and otherwise use the generic bundle for that architecture. The
|
||||||
|
script SHALL be served uncached.
|
||||||
|
|
||||||
#### Scenario: Installer is fetched anonymously
|
#### Scenario: Installer is fetched anonymously
|
||||||
|
|
||||||
@@ -33,6 +36,19 @@ installer. The script SHALL be served uncached.
|
|||||||
codename
|
codename
|
||||||
- **THEN** the installer uses the generic bundle
|
- **THEN** the installer uses the generic bundle
|
||||||
|
|
||||||
|
#### Scenario: Bundle matches the host architecture
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||||
|
bundle for another architecture
|
||||||
|
- **THEN** the installer chooses the bundle for the host architecture
|
||||||
|
- **AND** it never chooses a bundle for another architecture
|
||||||
|
|
||||||
|
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||||
|
generic 32-bit bundle
|
||||||
|
- **THEN** the installer uses that generic 32-bit bundle
|
||||||
|
|
||||||
#### Scenario: Checksum is verified before extraction
|
#### Scenario: Checksum is verified before extraction
|
||||||
|
|
||||||
- **WHEN** the installer downloads a bundle and its published checksum
|
- **WHEN** the installer downloads a bundle and its published checksum
|
||||||
@@ -41,14 +57,21 @@ installer. The script SHALL be served uncached.
|
|||||||
|
|
||||||
#### Scenario: Options are forwarded to the bundled installer
|
#### Scenario: Options are forwarded to the bundled installer
|
||||||
|
|
||||||
- **WHEN** the installer is invoked with options such as `--enable-service` or
|
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||||
`--check`
|
`--check`
|
||||||
- **THEN** it passes those options through to the extracted bundle's installer
|
- **THEN** it passes those options through to the extracted bundle's installer
|
||||||
|
|
||||||
#### Scenario: No compatible bundle
|
#### Scenario: No compatible bundle
|
||||||
|
|
||||||
- **WHEN** the latest release contains no bundle for the host architecture
|
- **WHEN** the latest release contains no bundle for the host architecture
|
||||||
- **THEN** the installer exits with a failure message and installs nothing
|
- **THEN** the installer exits with a failure message that names the host
|
||||||
|
architecture and installs nothing
|
||||||
|
|
||||||
|
#### Scenario: Unsupported architecture
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||||
|
x86_64 nor i386
|
||||||
|
- **THEN** it exits with a failure message and installs nothing
|
||||||
|
|
||||||
### Requirement: Release metadata endpoint
|
### Requirement: Release metadata endpoint
|
||||||
|
|
||||||
@@ -175,16 +198,23 @@ tag that is not a valid release tag without contacting the forge.
|
|||||||
|
|
||||||
### Requirement: Installed version is tracked and upgrades are reported
|
### Requirement: Installed version is tracked and upgrades are reported
|
||||||
|
|
||||||
The installer SHALL treat the git hash in the bundle's asset name as the version.
|
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||||
It SHALL hand that version to the bundle installer, which SHALL record it beside
|
version, for every architecture it can install. It SHALL hand that version to
|
||||||
the installed binary and SHALL report an upgrade with the previous and new
|
the bundle installer, which SHALL record it beside the installed binary and
|
||||||
versions on the next run. Uninstalling SHALL remove the recorded version.
|
SHALL report an upgrade with the previous and new versions on the next run.
|
||||||
|
Uninstalling SHALL remove the recorded version.
|
||||||
|
|
||||||
#### Scenario: Version is derived from the asset name
|
#### Scenario: Version is derived from the asset name
|
||||||
|
|
||||||
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||||
- **THEN** it uses `<hash>` as the version
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Version is derived for a 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a generic bundle named
|
||||||
|
`fpagent-<hash>-linux-i386.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
#### Scenario: Upgrade is reported
|
#### Scenario: Upgrade is reported
|
||||||
|
|
||||||
- **WHEN** the recorded version differs from the version being installed
|
- **WHEN** the recorded version differs from the version being installed
|
||||||
|
|||||||
@@ -60,24 +60,33 @@ only for permitted operators.
|
|||||||
|
|
||||||
### Requirement: Saving requires a live gate and an OTP
|
### Requirement: Saving requires a live gate and an OTP
|
||||||
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
||||||
permission live (bypassing the cache) and SHALL require an OTP from the
|
permission live (bypassing the cache) and SHALL authenticate the operator with
|
||||||
operator, forwarding the session phone as the validating identity together
|
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
|
||||||
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
|
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
|
||||||
SHALL not reach a2loft.
|
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
|
||||||
|
back to the queue item's label, and SHALL match a path-safe pattern before it
|
||||||
|
is used in the request. A denied save SHALL not reach a2loft.
|
||||||
|
|
||||||
#### Scenario: Successful save
|
#### Scenario: Successful save
|
||||||
- **WHEN** a permitted operator saves with an OTP
|
- **WHEN** a permitted operator saves
|
||||||
- **THEN** the route verifies the live gate and forwards the picture to
|
- **THEN** the route verifies the live gate and forwards the picture to
|
||||||
a2loft with the session phone, OTP, and picture domain
|
a2loft with the session token as a bearer credential and the profile's
|
||||||
|
resolved domain
|
||||||
|
|
||||||
#### Scenario: Denied save does not reach a2loft
|
#### Scenario: Denied save does not reach a2loft
|
||||||
- **WHEN** the live crop check denies, or the OTP is missing
|
- **WHEN** the live crop check denies, or the request carries no message body
|
||||||
- **THEN** the route answers 403/400 and makes no a2loft call
|
- **THEN** the route answers 403/400 and makes no a2loft call
|
||||||
|
|
||||||
|
#### Scenario: Unsafe domain values are refused
|
||||||
|
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
|
||||||
|
domain
|
||||||
|
- **THEN** the save is refused with 400 and a2loft is not contacted
|
||||||
|
|
||||||
### Requirement: Unified login — no separate artist login
|
### Requirement: Unified login — no separate artist login
|
||||||
The cropper SHALL NOT implement its own login. The operator's identity comes
|
The cropper SHALL NOT implement its own login. The operator's identity comes
|
||||||
from the YOUreka session (phone prefilled), the picture's metadata carries the
|
from the YOUreka session and is presented to a2loft as the session's PocketBase
|
||||||
domain, and the only credential the operator supplies is the save-time OTP.
|
token; the picture's own metadata carries the domain. The cropper collects no
|
||||||
|
other credential — there is no save-time OTP.
|
||||||
|
|
||||||
#### Scenario: Phone prefilled from the session
|
#### Scenario: Phone prefilled from the session
|
||||||
- **WHEN** a signed-in operator opens the cropper
|
- **WHEN** a signed-in operator opens the cropper
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
|
|||||||
- **WHEN** a user opens the app root URL
|
- **WHEN** a user opens the app root URL
|
||||||
- **THEN** the page shows a link to the Image Cropper pointing at
|
- **THEN** the page shows a link to the Image Cropper pointing at
|
||||||
`/cropper` and a link to `/fingerprint`
|
`/cropper` and a link to `/fingerprint`
|
||||||
|
|
||||||
### Requirement: YOUreka Auth login establishes a cookie session
|
### Requirement: YOUreka Auth login establishes a cookie session
|
||||||
The app SHALL authenticate operators through the YOUreka portal web-auth
|
The app SHALL authenticate operators through the YOUreka portal web-auth
|
||||||
flow: create a web auth session at the portal with a local callback URL,
|
flow: create a web auth session at the portal with a local callback URL,
|
||||||
@@ -100,17 +101,21 @@ portal.
|
|||||||
### Requirement: a2loft proxies inject operator identity
|
### Requirement: a2loft proxies inject operator identity
|
||||||
The app SHALL proxy staff lookup and fingerprint-save calls to
|
The app SHALL proxy staff lookup and fingerprint-save calls to
|
||||||
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
||||||
attach the operator's phone from the server-side session and forward the
|
authenticate the operator with the signed-in session's PocketBase token, sent
|
||||||
OTP supplied by the page as query parameters (`phone`, `otp`), preserving
|
as an `Authorization: Bearer` credential, preserving the a2loft request and
|
||||||
the a2loft request and response shapes. The proxies SHALL require a
|
response shapes. The proxies SHALL require a session; a request without one
|
||||||
session; the OTP SHALL NOT be accepted without one.
|
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
|
||||||
|
require an OTP.
|
||||||
|
|
||||||
#### Scenario: Staff lookup via proxy
|
#### Scenario: Staff lookup via proxy
|
||||||
- **WHEN** the page requests staff details for a domain and reference with
|
- **WHEN** the page requests staff details for a domain and reference
|
||||||
an OTP
|
- **THEN** the app calls a2loft's identity endpoint with the session token as a
|
||||||
- **THEN** the app calls a2loft's identity endpoint with the operator's
|
bearer credential and returns a2loft's response unchanged
|
||||||
phone from the session and the supplied OTP, and returns a2loft's
|
|
||||||
response unchanged
|
#### Scenario: Fingerprint save via proxy
|
||||||
|
- **WHEN** the page saves fingerprints for a domain and reference
|
||||||
|
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
|
||||||
|
token as a bearer credential
|
||||||
|
|
||||||
#### Scenario: Unauthenticated proxy call rejected
|
#### Scenario: Unauthenticated proxy call rejected
|
||||||
- **WHEN** a proxy route is called without a valid session
|
- **WHEN** a proxy route is called without a valid session
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
# own installer, which handles libfprint, the udev rule, plugdev and the
|
# own installer, which handles libfprint, the udev rule, plugdev and the
|
||||||
# systemd service. Every flag is forwarded to that installer, e.g.:
|
# systemd service. Every flag is forwarded to that installer, e.g.:
|
||||||
#
|
#
|
||||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --enable-service
|
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --service-user alice
|
||||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check
|
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check
|
||||||
#
|
#
|
||||||
# The private release is reached through the tools app, so no credentials are
|
# The private release is reached through the tools app, so no credentials are
|
||||||
@@ -35,12 +35,18 @@ Options:
|
|||||||
Options below are forwarded to the bundled installer:
|
Options below are forwarded to the bundled installer:
|
||||||
--check preflight and report only; change nothing
|
--check preflight and report only; change nothing
|
||||||
--offline never touch the network; fail if dependencies are missing
|
--offline never touch the network; fail if dependencies are missing
|
||||||
--enable-service install and enable a systemd service (starts at boot)
|
--no-service do not install the systemd service (also disables updates)
|
||||||
|
--no-updates do not install the self-update timer
|
||||||
|
--update-base URL release base for self-update
|
||||||
|
(default: https://tools.yrka.app/fpagent)
|
||||||
--service-user NAME user for the service (default: the invoking user)
|
--service-user NAME user for the service (default: the invoking user)
|
||||||
--install-path PATH binary destination (default: /usr/local/bin/fpagent)
|
--install-path PATH binary destination (default: /usr/local/bin/fpagent)
|
||||||
--uninstall remove the binary, the udev rule and the service
|
--uninstall remove the binary, the udev rule and the service
|
||||||
-h, --help the installer's help
|
-h, --help the installer's help
|
||||||
|
|
||||||
|
The systemd service and the self-update timer are installed and enabled by
|
||||||
|
default; --no-service and --no-updates turn them off.
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
FPAGENT_BASE_URL override the download base (default: ${BASE})
|
FPAGENT_BASE_URL override the download base (default: ${BASE})
|
||||||
EOF
|
EOF
|
||||||
@@ -85,6 +91,15 @@ find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \
|
|||||||
# online bundle (whose installer apt-get's the dependency).
|
# online bundle (whose installer apt-get's the dependency).
|
||||||
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
|
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
|
||||||
|
|
||||||
|
# Releases are named for the target architecture; only these two are published.
|
||||||
|
# Mirrors the bundled installer's own preflight so both reject the same hosts.
|
||||||
|
host_machine="$(uname -m)"
|
||||||
|
case "$host_machine" in
|
||||||
|
x86_64 | amd64) arch="x86_64" ;;
|
||||||
|
i386 | i486 | i586 | i686) arch="i386" ;;
|
||||||
|
*) die "unsupported architecture: ${host_machine} (x86_64 or i386 required)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
if [ "$RELEASE_TAG" = "latest" ]; then
|
if [ "$RELEASE_TAG" = "latest" ]; then
|
||||||
meta_url="${BASE}/releases/latest"
|
meta_url="${BASE}/releases/latest"
|
||||||
else
|
else
|
||||||
@@ -96,25 +111,25 @@ meta="$(curl -fsSL "$meta_url")" || die "could not fetch the release list from $
|
|||||||
|
|
||||||
readout="$(python3 -c '
|
readout="$(python3 -c '
|
||||||
import json, re, sys
|
import json, re, sys
|
||||||
codename = sys.argv[1]
|
codename, arch = sys.argv[1], sys.argv[2]
|
||||||
release = json.loads(sys.argv[2])
|
release = json.loads(sys.argv[3])
|
||||||
assets = [a["name"] for a in release.get("assets", [])]
|
assets = [a["name"] for a in release.get("assets", [])]
|
||||||
|
|
||||||
def pick(suffix):
|
def pick(suffix):
|
||||||
return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "")
|
return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "")
|
||||||
|
|
||||||
name = pick("-ubuntu-" + codename + "-x86_64.tar.gz") if codename else ""
|
name = pick("-ubuntu-" + codename + "-" + arch + ".tar.gz") if codename else ""
|
||||||
if not name:
|
if not name:
|
||||||
name = pick("-linux-x86_64.tar.gz")
|
name = pick("-linux-" + arch + ".tar.gz")
|
||||||
# The git hash is baked into the asset name (bundle.sh --version) and into
|
# The git hash is baked into the asset name (bundle.sh --version) and into
|
||||||
# the binary itself; it is the version we record and later compare against.
|
# the binary itself; it is the version we record and later compare against.
|
||||||
m = re.match(r"^fpagent-(.+)-(?:linux-x86_64|ubuntu-[a-z0-9]+-x86_64)\.tar\.gz$", name)
|
m = re.match(r"^fpagent-(.+)-(?:linux|ubuntu-[a-z0-9]+)-" + re.escape(arch) + r"\.tar\.gz$", name)
|
||||||
version = m.group(1) if m else ""
|
version = m.group(1) if m else ""
|
||||||
print(release.get("tag", "") + "\t" + name + "\t" + version)
|
print(release.get("tag", "") + "\t" + name + "\t" + version)
|
||||||
' "$codename" "$meta")" || die "could not parse the release metadata"
|
' "$codename" "$arch" "$meta")" || die "could not parse the release metadata"
|
||||||
|
|
||||||
IFS=$'\t' read -r tag asset version <<<"$readout"
|
IFS=$'\t' read -r tag asset version <<<"$readout"
|
||||||
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (x86_64 required)"
|
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (${arch} required)"
|
||||||
|
|
||||||
say " release ${tag}"
|
say " release ${tag}"
|
||||||
say " bundle ${asset}"
|
say " bundle ${asset}"
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ const RELEASE = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A directory holding a fake `uname` that reports the given machine, so the
|
||||||
|
// installer's architecture-sensitive picker runs on any CI host. PATH is
|
||||||
|
// prepended with it; everything else resolves as usual.
|
||||||
|
function fakeUname(machine) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-uname-'));
|
||||||
|
fs.writeFileSync(path.join(dir, 'uname'), `#!/bin/sh\nprintf '%s\\n' '${machine}'\n`, {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
// Fake Gitea: refuses without the token, serves the release and its assets.
|
// Fake Gitea: refuses without the token, serves the release and its assets.
|
||||||
async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) {
|
async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -231,6 +242,98 @@ test('install.sh is served publicly at /fpagent/install.sh', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('install.sh picks the bundle for the host architecture', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gateway = await startServer((req, res) => {
|
||||||
|
calls.push(req.url);
|
||||||
|
if (req.url === '/releases/latest') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
tag: 'v9.9.9',
|
||||||
|
assets: [
|
||||||
|
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz' },
|
||||||
|
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz.sha256' },
|
||||||
|
{ name: 'fpagent-cafebabe-linux-i386.tar.gz' },
|
||||||
|
{ name: 'fpagent-cafebabe-linux-i386.tar.gz.sha256' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
const unameDir = fakeUname('i686');
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
|
||||||
|
try {
|
||||||
|
let out = '';
|
||||||
|
try {
|
||||||
|
// Async so the in-process gateway can answer while the child runs.
|
||||||
|
const r = await execFileAsync('bash', [SCRIPT], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${unameDir}:${process.env.PATH}`,
|
||||||
|
TMPDIR: tmp,
|
||||||
|
FPAGENT_BASE_URL: urlOf(gateway),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
out = r.stdout + r.stderr;
|
||||||
|
} catch (e) {
|
||||||
|
out = `${e.stdout ?? ''}${e.stderr ?? ''}`; // the asset download 404s
|
||||||
|
}
|
||||||
|
assert.match(out, /cafebabe/, 'the i386 version should come from the asset name');
|
||||||
|
assert.ok(
|
||||||
|
calls.some((u) => u.includes('fpagent-cafebabe-linux-i386.tar.gz')),
|
||||||
|
'should request the i386 bundle'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!calls.some((u) => u.includes('x86_64')),
|
||||||
|
'should never request another architecture\u2019s bundle'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
fs.rmSync(unameDir, { recursive: true, force: true });
|
||||||
|
await stopServer(gateway);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('install.sh refuses an unsupported architecture before fetching', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gateway = await startServer((req, res) => {
|
||||||
|
calls.push(req.url);
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
const unameDir = fakeUname('aarch64');
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
|
||||||
|
try {
|
||||||
|
let out = '';
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const r = await execFileAsync('bash', [SCRIPT], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${unameDir}:${process.env.PATH}`,
|
||||||
|
TMPDIR: tmp,
|
||||||
|
FPAGENT_BASE_URL: urlOf(gateway),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
out = r.stdout + r.stderr;
|
||||||
|
} catch (e) {
|
||||||
|
failed = true;
|
||||||
|
out = `${e.stdout ?? ''}${e.stderr ?? ''}`;
|
||||||
|
}
|
||||||
|
assert.equal(failed, true, 'the installer should reject an unsupported host');
|
||||||
|
assert.match(out, /unsupported architecture: aarch64/);
|
||||||
|
assert.equal(calls.length, 0, 'should fail before touching the network');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
fs.rmSync(unameDir, { recursive: true, force: true });
|
||||||
|
await stopServer(gateway);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('install.sh is valid bash', () => {
|
test('install.sh is valid bash', () => {
|
||||||
execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' });
|
execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user