Windows installer: payload hash-locking, SBOM, client-IP handling, operator docs
4
.github/workflows/ci.yml
vendored
@@ -29,9 +29,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
python-version: '3.14'
|
||||
cache: pip
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-dev.txt
|
||||
- run: python -m pytest -q
|
||||
|
||||
naming:
|
||||
|
||||
50
CHANGELOG.md
@@ -12,6 +12,54 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Added
|
||||
|
||||
- Air-gapped Windows installer (`deploy/windows/installer/`). One `.exe` per
|
||||
site, built from that site's plugin profile, containing Python, the wheels,
|
||||
the SPA, the IIS modules and optionally MySQL. Operator docs:
|
||||
`docs/INSTALL-WINDOWS.md` and `docs/OPERATE-WINDOWS.md`, both shipped onto the
|
||||
server. `docs/INSTALL-WINDOWS-IIS.md` and `docs/DEPLOY-WINDOWS-IIS.md` are now
|
||||
reference-only, for hand-built servers.
|
||||
- `bundle-lock.json`: an exact sha256 + size record of the installer's
|
||||
third-party payload (wheels, Python installer, IIS MSIs). Verified as set
|
||||
equality by both builders and again on the server before anything runs; there
|
||||
is no install-time override. Regenerate with `refresh-bundle-lock.ps1`.
|
||||
- CycloneDX SBOM (`sbom.cdx.json`) generated on every build from
|
||||
`requirements.txt` and `package-lock.json`, covering both ecosystems, staged
|
||||
into the application tree so an air-gapped server can answer "do we carry this
|
||||
component" locally: `shopdb-admin.ps1 verify -Path <name>`.
|
||||
- `shopdb-admin.ps1`, the operator console: status, start/stop/restart, logs,
|
||||
health check, backup, plugins, verify. `check -Json` emits secret-free
|
||||
structured state for pasting into a support ticket or an AI assistant.
|
||||
- `build-installer.ps1`, the whole build natively on Windows, so a work PC needs
|
||||
no Bash. Shares `scripts/resolve_plugin_closure.py` with `build-site.sh`.
|
||||
- URL Rewrite is bundled and the wizard asks where client IPs come from
|
||||
(`-ClientIpSource direct|proxy`). Without the rule IIS sends no
|
||||
`X-Forwarded-For` at all and every client reads as 127.0.0.1.
|
||||
|
||||
### Changed
|
||||
|
||||
- `requirements.txt` and `requirements-dev.txt` are compiled `--universal
|
||||
--generate-hashes`. Installs run under `pip --require-hashes`, so a wheel whose
|
||||
sha256 is not listed is refused. The dev lockfile is constrained to the
|
||||
production pins; the two had drifted.
|
||||
- Python 3.14 across the Dockerfile, CI, `web.config` and the docs, which
|
||||
previously declared four different versions.
|
||||
- The naming/style gate covers Markdown, JSON and YAML, not just code.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `plugins/employees` imported `shopdb.core.models` directly, failing the
|
||||
contract-surface test on `main` since the dashboard employee-name resolver
|
||||
landed.
|
||||
- `scripts/build-site.sh` copied all of `deploy/` into its own output directory,
|
||||
which recursed when the output was staged inside it - the documented Windows
|
||||
build could not complete.
|
||||
- Plugin baseline migrations inherited the MySQL server's default charset: the
|
||||
utf8mb4 compiler hook lived in `migrations/env.py` and so covered the core
|
||||
chain only. It is now `shopdb/utils/mysql_charset.py`, imported by both.
|
||||
`flask db-utils preflight` reports the database's default charset.
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable site timezone: a `site_timezone` site setting (default
|
||||
`America/New_York`, public-readable), editable in Settings > Site >
|
||||
Localization. Notification start/end times are entered and displayed in this
|
||||
@@ -53,7 +101,7 @@ ADR-007 and ADR-002.
|
||||
- Shopfloor kiosk header text is readable (light on the dark navy header).
|
||||
|
||||
|
||||
### Added
|
||||
### Added (continued)
|
||||
|
||||
- Collector-driven PC -> printer relationships. The computers collector schema
|
||||
gained optional `defaultprinter` (string) and `printers` (array of strings)
|
||||
|
||||
@@ -32,7 +32,7 @@ RUN npm run build
|
||||
# Output lands in /build/dist (Vite default), copied into the final stage below.
|
||||
|
||||
# ---- Stage 2: Python application image ----
|
||||
FROM python:3.12-slim AS base
|
||||
FROM python:3.14-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
||||
58
README.md
@@ -20,7 +20,7 @@ ShopDB tracks and manages:
|
||||
## Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Python 3.12 with Flask
|
||||
- Python 3.14 with Flask
|
||||
- SQLAlchemy ORM
|
||||
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
|
||||
- JWT authentication
|
||||
@@ -36,26 +36,26 @@ ShopDB tracks and manages:
|
||||
|
||||
```
|
||||
shopdb-flask/
|
||||
├── shopdb/ # Flask application
|
||||
│ ├── core/
|
||||
│ │ ├── api/ # REST API endpoints
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Validation schemas
|
||||
│ │ └── services/ # Business logic
|
||||
│ ├── plugins/ # Plugin system
|
||||
│ └── utils/ # Shared utilities
|
||||
├── frontend/ # Vue 3 application
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ ├── router/ # Route definitions
|
||||
│ │ └── stores/ # Pinia stores
|
||||
│ └── public/ # Static assets
|
||||
├── plugins/ # Bundled and external plugins
|
||||
├── migrations/ # Alembic migration chain (flask db upgrade)
|
||||
├── scripts/ # Import and utility scripts
|
||||
└── tests/ # Test suite
|
||||
+-- shopdb/ # Flask application
|
||||
| +-- core/
|
||||
| | +-- api/ # REST API endpoints
|
||||
| | +-- models/ # SQLAlchemy models
|
||||
| | +-- schemas/ # Validation schemas
|
||||
| | `-- services/ # Business logic
|
||||
| +-- plugins/ # Plugin system
|
||||
| `-- utils/ # Shared utilities
|
||||
+-- frontend/ # Vue 3 application
|
||||
| +-- src/
|
||||
| | +-- api/ # API client
|
||||
| | +-- components/ # Reusable components
|
||||
| | +-- views/ # Page components
|
||||
| | +-- router/ # Route definitions
|
||||
| | `-- stores/ # Pinia stores
|
||||
| `-- public/ # Static assets
|
||||
+-- plugins/ # Bundled and external plugins
|
||||
+-- migrations/ # Alembic migration chain (flask db upgrade)
|
||||
+-- scripts/ # Import and utility scripts
|
||||
`-- tests/ # Test suite
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
@@ -98,7 +98,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12
|
||||
- Python 3.14
|
||||
- Node.js 18+
|
||||
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
|
||||
|
||||
@@ -174,8 +174,15 @@ token plus the IMPORT-API page in the project wiki drive the whole migration
|
||||
through documented endpoints (`X-Import-Mode` preserves original timestamps).
|
||||
`scripts/site_imports/wjf/` is the West Jefferson reference loader.
|
||||
|
||||
For the full per-site deployment runbook see the DEPLOY page in the project wiki;
|
||||
for every environment variable and Setting key see the CONFIG page in the project wiki.
|
||||
### Which deployment route
|
||||
|
||||
| Target | Use |
|
||||
|---|---|
|
||||
| **Windows Server + IIS** (how sister sites run) | **the INSTALL-WINDOWS page in the project wiki** - one installer `.exe`, offline, no manual IIS work. Day 2: the OPERATE-WINDOWS page in the project wiki |
|
||||
| Linux / Docker, air-gapped | the DEPLOY-AIRGAP page in the project wiki |
|
||||
| Linux / Docker, connected | the DEPLOY page in the project wiki |
|
||||
|
||||
For every environment variable and Setting key see the CONFIG page in the project wiki.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -217,7 +224,7 @@ Query parameters for list endpoints:
|
||||
|
||||
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
|
||||
|
||||
The image bundles twelve plugins; only the ones a site installs are loaded:
|
||||
The image bundles thirteen plugins; only the ones a site installs are loaded:
|
||||
|
||||
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
|
||||
- **employees** - Employee directory
|
||||
@@ -227,6 +234,7 @@ The image bundles twelve plugins; only the ones a site installs are loaded:
|
||||
- **knowledgebase** - Documentation and troubleshooting guides
|
||||
- **network** - Network devices and subnets
|
||||
- **notifications** - Shopfloor notifications and recognition feed
|
||||
- **printedparts** - 3D-printed part catalogue, kiosk issue tracking and stock alerts
|
||||
- **printers** - Extended printer management with Zabbix integration
|
||||
- **slides** - TV/kiosk slideshows
|
||||
- **usb** - CMMC USB check-in/out tracking
|
||||
|
||||
6
deploy/windows/installer/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Build output and staged payload - regenerable, and ~220MB.
|
||||
bundle/
|
||||
Output/
|
||||
# Generated by build-installer.sh from the staged bundle.
|
||||
plugins.iss
|
||||
version.iss
|
||||
245
deploy/windows/installer/README.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Windows installer
|
||||
|
||||
Builds a single self-contained `.exe` that installs ShopDB-Flask on an
|
||||
**air-gapped** Windows Server. Nothing here ever touches the network at install
|
||||
time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it.
|
||||
|
||||
Lives with the application on purpose. The installer depends on app internals -
|
||||
`flask plugin` verbs, `site-profile.json`, `MOUNT_PATH`, the plugin registry -
|
||||
so a separate repo would drift out of step with the thing it installs.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `shopdb-preflight.ps1` | Stage 1. Read-only. Changes nothing, reports what this server is missing. |
|
||||
| `shopdb-install.ps1` | Stages 0 and 2-5 plus `uninstall`. All the actual work. |
|
||||
| `shopdb-admin.ps1` | Operator console installed alongside the app: status, restart, logs, backup, plugins. |
|
||||
| `ShopDBFlask.iss` | Inno Setup wizard. A thin wrapper - it collects input and runs the stages. |
|
||||
| `build-installer.sh` | Stages the bundle from a site profile. |
|
||||
| `make-branding.py` | Generates wizard artwork and the icon from `frontend/public/*.svg`. |
|
||||
| `*.bmp`, `shopdb.ico` | Generated artwork, committed so a Windows build box needs no Python. |
|
||||
| `build-installer.ps1` | The same build, natively on Windows. No Bash needed. |
|
||||
| `bundle-lock.json` | The exact third-party payload this installer ships. Reviewed by commit. |
|
||||
| `bundle-lock.ps1` | Creates and checks that lock. Also runs on the target server. |
|
||||
| `refresh-bundle-lock.ps1` | Regenerates the lock, after showing what changed. |
|
||||
| `verify_bundle_lock.py` | The same check for the Bash builder, so Linux needs no pwsh. |
|
||||
|
||||
## Building
|
||||
|
||||
Two builders, same result. Use whichever machine you are on - `build-installer.ps1`
|
||||
does the whole job natively so a Windows work PC needs no Bash.
|
||||
|
||||
```bash
|
||||
# Linux / WSL
|
||||
./build-installer.sh ../../site-profile.example.json
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
|
||||
```
|
||||
|
||||
Both stage the app tree, build the SPA twice, write `plugins.iss`, copy the
|
||||
installer scripts **from this directory**, and then verify the third-party
|
||||
payload against `bundle-lock.json`. They exit non-zero if it does not match.
|
||||
|
||||
The payload itself is added by hand, and no longer needs Windows to produce:
|
||||
|
||||
```
|
||||
bundle/wheels/ pip download -r requirements.txt --only-binary=:all: \
|
||||
--platform win_amd64 --python-version 314 \
|
||||
--implementation cp --abi cp314 -d wheels
|
||||
bundle/python/ python-3.14.x-amd64.exe
|
||||
bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi
|
||||
bundle/urlrewrite/ rewrite_amd64.msi (client-IP rule; see below)
|
||||
bundle/mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)
|
||||
```
|
||||
|
||||
Then compile on Windows:
|
||||
|
||||
```
|
||||
iscc ShopDBFlask.iss
|
||||
```
|
||||
|
||||
**Inno Setup 6.6.0 or newer.** The wizard uses the built-in `windows11` custom
|
||||
style, which earlier versions reject. The script fails at compile time with that
|
||||
sentence rather than with a bare "WizardStyle is invalid".
|
||||
|
||||
The wheelhouse is **cp314-locked**. A different Python minor version means a
|
||||
different wheelhouse; the installer will not use a Python it did not install.
|
||||
|
||||
## What is pinned, and where
|
||||
|
||||
Three layers, because no one of them covers the whole problem.
|
||||
|
||||
| Layer | Covers | Enforced |
|
||||
|---|---|---|
|
||||
| `requirements.txt` sha256 per package | every wheel is genuinely what upstream published | `pip --require-hashes` at install; aborts on mismatch |
|
||||
| `bundle-lock.json` | the EXACT payload: wheels, Python installer, MSIs | both builders, and again on the server before anything runs |
|
||||
| git | the application tree | code review |
|
||||
|
||||
`--require-hashes` alone is not enough. pip lists every artifact of a pinned
|
||||
version - `cffi 2.1.0` has 100 hashes - so it proves the wheel is genuine, not
|
||||
that it is the wheel this bundle was built and tested with. It also ignores
|
||||
extra files in the wheelhouse, and says nothing about the Python installer or
|
||||
the MSIs, all of which run as SYSTEM on the target server.
|
||||
|
||||
So `bundle-lock.json` records an exact file set with a sha256 and a byte size
|
||||
each, and verification is **set equality**: a missing file, an unexpected extra
|
||||
file, or changed content all fail. There is no install-time override.
|
||||
|
||||
The lockfile is `--universal`, so one file serves Linux (dev, Docker, CI) and the
|
||||
Windows wheelhouse. A Linux-only resolve had silently omitted `colorama`, a
|
||||
win32-only dependency of `click` - which in hash-checking mode is a hard error
|
||||
rather than a quiet omission.
|
||||
|
||||
### Verifying the installer itself
|
||||
|
||||
`bundle-lock.json` is **inside** the thing it describes, so it proves the payload
|
||||
was not altered between build and install - not that the `.exe` you received is
|
||||
the one that was built. That needs something out of band. Two options, in order
|
||||
of preference:
|
||||
|
||||
1. **Authenticode-sign the `.exe`** with a GE code-signing certificate. Windows
|
||||
then shows a real publisher instead of "Unknown", which is also what stops an
|
||||
operator learning to click through the SmartScreen warning.
|
||||
2. **Publish a sha256 per release** through a different channel than the file
|
||||
itself, and have the receiving site check it:
|
||||
`Get-FileHash ShopDBFlask_Installer_*.exe -Algorithm SHA256`
|
||||
|
||||
Neither is wired up yet. Until one is, an installer is only as trustworthy as
|
||||
the share it arrived on.
|
||||
|
||||
### Changing what ships
|
||||
|
||||
```powershell
|
||||
.\refresh-bundle-lock.ps1 # show what changed, write nothing
|
||||
.\refresh-bundle-lock.ps1 -Yes # write it
|
||||
```
|
||||
|
||||
Then **commit `bundle-lock.json`**. That commit is the review - it is the only
|
||||
place a change to what runs as SYSTEM on a customer's server becomes visible to
|
||||
a human. `refresh-bundle-lock.ps1` refuses to overwrite an existing lock until
|
||||
you have seen the diff, for that reason.
|
||||
|
||||
To stage a bundle before its lock exists: `ALLOW_UNLOCKED=1` (Bash) or
|
||||
`-AllowUnlocked` (PowerShell). Bundles built that way must not be shipped.
|
||||
|
||||
Verifying a live server, months later and offline:
|
||||
|
||||
```powershell
|
||||
shopdb-admin.ps1 verify
|
||||
```
|
||||
|
||||
## Servers this installer did not build
|
||||
|
||||
It is built for greenfield: its own Python, its own venv, its own IIS objects.
|
||||
Its upgrade path assumes the thing being upgraded came out of a previous run.
|
||||
Two guards keep it from damaging a server that was deployed by hand.
|
||||
|
||||
**Python minor version.** An existing venv is reused, which is right for a repair
|
||||
or an upgrade. It is wrong when the venv belongs to a different Python - the
|
||||
wheelhouse is tagged for one minor version, so pip would die at the first
|
||||
compiled package, *after* Python was installed and the app tree replaced. The
|
||||
installer compares the two up front and stops with both version numbers.
|
||||
|
||||
**IIS objects.** Switching deployment method removes the other method's artifact,
|
||||
which is correct when the installer owns both and dangerous when it does not: a
|
||||
wrong `-MountAlias` would delete a live mount with no prompt. It now refuses
|
||||
unless there is a version stamp proving it made the install, or you pass
|
||||
`-AdoptExisting`. The refusal lists exactly what it would have removed.
|
||||
|
||||
An existing `web.config` is never overwritten in either case.
|
||||
|
||||
For the West Jefferson production server specifically, this is a **migration, not
|
||||
an upgrade** - prod runs Python 3.13 against a hand-built deployment, so it needs
|
||||
a deliberate window, a database backup, and web.config reconciled by hand.
|
||||
|
||||
## Bill of materials
|
||||
|
||||
Every build stages a CycloneDX 1.6 SBOM at `sbom.cdx.json`, inside the
|
||||
application tree, so it installs onto the server with the app. Both ecosystems,
|
||||
in one document:
|
||||
|
||||
- **Python** - every pin in `requirements.txt`, with the sha256 the installer
|
||||
enforces. Environment markers are ignored: a `sys_platform == 'win32'`
|
||||
dependency still installs on the target.
|
||||
- **npm** - every package in `frontend/package-lock.json`. Build-only packages
|
||||
are marked `scope: excluded` rather than dropped, so "not here" is
|
||||
distinguishable from "not looked for".
|
||||
|
||||
It ships to the server because an air-gapped site cannot be scanned from
|
||||
anywhere else. When a CVE lands, the answer is already on the box:
|
||||
|
||||
```powershell
|
||||
shopdb-admin.ps1 verify # counts, and which bundle this is
|
||||
shopdb-admin.ps1 verify -Path leaflet # is that component here, at what version
|
||||
```
|
||||
|
||||
Generated by `scripts/generate_sbom.py` from files that are already pinned and
|
||||
committed, so it is a translation rather than a scan - no network, no extra
|
||||
toolchain on the build box, and byte-identical output for the same inputs. It is
|
||||
deliberately not in `bundle-lock.json`: its provenance is git, not the payload.
|
||||
|
||||
## Client IP addresses
|
||||
|
||||
IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
|
||||
from loopback. Without a rule, **every client reads as 127.0.0.1** - so the
|
||||
GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host
|
||||
login rate limiting all stop working, silently.
|
||||
|
||||
The wizard asks, because the two answers are mutually exclusive:
|
||||
|
||||
- **Clients connect directly** (`-ClientIpSource direct`, the default) - installs
|
||||
URL Rewrite from the bundle and sets `X-Forwarded-For` from `REMOTE_ADDR`.
|
||||
Overwriting the header is what stops a client spoofing its own.
|
||||
- **A proxy sits in front** (`-ClientIpSource proxy`) - leaves the rule off.
|
||||
Behind ARR or a load balancer `REMOTE_ADDR` is the *proxy*, so applying the
|
||||
rule would discard the real client IP.
|
||||
|
||||
An existing `web.config` is never overwritten - it is the one file on a server
|
||||
that legitimately carries hand-edits. The installer reports what it found
|
||||
instead.
|
||||
|
||||
## Deployment methods
|
||||
|
||||
Chosen in the wizard, and the bundle carries a SPA build for each because Vite
|
||||
compiles the base path in - it cannot be switched at install time.
|
||||
|
||||
- **Its own site** on a port (default 8090).
|
||||
- **Subpath** under an existing site, e.g. `http://<server-fqdn>/shopdb/`. Needs
|
||||
no new DNS record. Three things must agree - the IIS application alias,
|
||||
`MOUNT_PATH` in `.env`, and the SPA's build-time base - so the alias is fixed
|
||||
per bundle (`SUBPATH_ALIAS`, default `shopdb`) and the installer refuses if the
|
||||
bundle's build does not match what was asked for.
|
||||
|
||||
Switching between methods removes the other one's IIS artifact and reconciles
|
||||
`MOUNT_PATH` and `CORS_ORIGINS`, so a server never ends up with both.
|
||||
|
||||
## Upgrades
|
||||
|
||||
Run a newer installer over an existing install. It:
|
||||
|
||||
- backs the database up first, **verifies** the dump is complete, and refuses to
|
||||
migrate if it cannot;
|
||||
- restores from that backup if migrations fail, and reports honestly that DDL
|
||||
the failed migration committed cannot be undone;
|
||||
- refuses to run a bundle older than what is installed;
|
||||
- keeps `.env` unless new credentials are supplied, and copies it aside first;
|
||||
- stops the app pool before replacing files, then starts it again.
|
||||
|
||||
Whether a run is an upgrade is decided by probing the **target database**, not by
|
||||
whether the app directory exists - a rebuilt server pointed at an existing
|
||||
database is an upgrade, and treating it as fresh would drop tables.
|
||||
|
||||
## Testing notes
|
||||
|
||||
Verified end to end on Windows Server 2025 against both a bundled MySQL 8.0 and
|
||||
an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and
|
||||
rollback, uninstall, and both deployment methods including switching between
|
||||
them.
|
||||
|
||||
**Not yet verified:** a fully air-gapped run with the network disabled at the
|
||||
hypervisor, and any load in a real browser (all HTTP checks so far used curl,
|
||||
which sends no `Origin` header - so `CORS_ORIGINS` is untested in anger).
|
||||
1174
deploy/windows/installer/ShopDBFlask.iss
Normal file
301
deploy/windows/installer/build-installer.ps1
Normal file
@@ -0,0 +1,301 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage a lean per-site installer bundle on Windows, and verify its third-party
|
||||
payload against bundle-lock.json.
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows equivalent of build-installer.sh, for a work PC with no Bash. It
|
||||
does the whole job natively - it does NOT shell out to build-site.sh - so the
|
||||
only tools needed are Python, Node and (to compile) Inno Setup.
|
||||
|
||||
What it does, in order:
|
||||
|
||||
1. Resolves the site's plugin closure from its profile.
|
||||
2. Builds the SPA twice, because Vite compiles the base path in and it
|
||||
therefore cannot be chosen at install time: once for /<alias> (subpath
|
||||
deployment) and once for / (its own site).
|
||||
3. Stages the backend tree: core, the chosen plugins only, and the runtime
|
||||
files a deployable tree needs.
|
||||
4. Writes plugins.iss so the wizard's plugin page matches the payload.
|
||||
5. Copies the installer scripts from THIS directory.
|
||||
6. Verifies wheels\, python\ and the MSIs against bundle-lock.json, and
|
||||
FAILS if the bundle is not exactly what the lock describes.
|
||||
|
||||
Step 6 is the point of the script. A bundle that does not match its lock is
|
||||
not shipped, and "the wheelhouse was missing" is caught here rather than by
|
||||
an operator halfway through installing on a server with no network.
|
||||
|
||||
.PARAMETER Profile
|
||||
Path to the site profile (see deploy\site-profile.example.json).
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
The repository. Defaults to four levels up from this script, which is correct
|
||||
for a normal checkout.
|
||||
|
||||
.PARAMETER SkipFrontend
|
||||
Reuse the SPA builds already staged in the bundle. For iterating on the
|
||||
installer itself, where two Vite builds per run is most of the wall clock.
|
||||
Never use it for a bundle you intend to ship.
|
||||
|
||||
.PARAMETER AllowUnlocked
|
||||
Stage the bundle and report payload problems WITHOUT failing. For assembling
|
||||
a bundle before its lock exists. A bundle built this way must not be shipped;
|
||||
run refresh-bundle-lock.ps1, commit the lock, then build again without this.
|
||||
|
||||
.EXAMPLE
|
||||
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
|
||||
.\build-installer.ps1 -Profile C:\sites\wjf.json -SkipFrontend
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $Profile,
|
||||
[string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path,
|
||||
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
|
||||
[string] $SubpathAlias = 'shopdb',
|
||||
[switch] $SkipFrontend,
|
||||
[switch] $AllowUnlocked
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
|
||||
|
||||
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||
function Step { param($m) Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Die { param($m, $fix = '') Write-Host ''; Write-Host " $m" -ForegroundColor Red
|
||||
if ($fix) { Write-Host " $fix" -ForegroundColor Yellow }; exit 1 }
|
||||
|
||||
function Invoke-Tool {
|
||||
# Runs a build tool and stops on a non-zero exit. npm writes progress to
|
||||
# stderr on SUCCESS, so stderr alone must never be treated as failure.
|
||||
param([string] $Exe, [string[]] $Arguments, [string] $WorkDir, [string] $What)
|
||||
Push-Location $WorkDir
|
||||
try {
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Exe @Arguments 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
|
||||
$code = $LASTEXITCODE
|
||||
$ErrorActionPreference = $prev
|
||||
if ($code -ne 0) { Die "$What failed (exit $code)" }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Profile)) { Die "profile not found: $Profile" }
|
||||
if (-not (Test-Path $RepoRoot)) { Die "repo not found: $RepoRoot" }
|
||||
$Profile = (Resolve-Path $Profile).Path
|
||||
$RepoRoot = (Resolve-Path $RepoRoot).Path
|
||||
$AppOut = Join-Path $BundleRoot 'app'
|
||||
|
||||
$python = (Get-Command python -ErrorAction SilentlyContinue)
|
||||
if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue }
|
||||
if (-not $python) { Die 'Python not found on PATH' 'Install Python 3.14 and re-open the shell.' }
|
||||
$npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
|
||||
if (-not $npm -and -not $SkipFrontend) { Die 'npm not found on PATH' 'Install Node, or pass -SkipFrontend to reuse the staged SPA.' }
|
||||
|
||||
Say ''
|
||||
Say " repo : $RepoRoot"
|
||||
Say " profile : $Profile"
|
||||
Say " bundle : $BundleRoot"
|
||||
|
||||
# --- 1. plugin closure ------------------------------------------------------
|
||||
# Same resolver the Linux builder uses, so both produce the same set from one
|
||||
# profile instead of two implementations of the closure rules.
|
||||
Step 'Resolving plugin closure'
|
||||
$closure = (& $python.Source (Join-Path $RepoRoot 'scripts\resolve_plugin_closure.py') $Profile $RepoRoot)
|
||||
if ($LASTEXITCODE -ne 0 -or -not $closure) { Die 'could not resolve the plugin closure from the profile' }
|
||||
$closure = $closure.Trim()
|
||||
Say " $closure" 'White'
|
||||
|
||||
# --- 2. frontend ------------------------------------------------------------
|
||||
$frontend = Join-Path $RepoRoot 'frontend'
|
||||
$subStaged = Join-Path $BundleRoot 'spa-subpath'
|
||||
$rootStaged= Join-Path $BundleRoot 'spa-root'
|
||||
|
||||
if ($SkipFrontend) {
|
||||
if (-not (Test-Path $subStaged) -or -not (Test-Path $rootStaged)) {
|
||||
Die '-SkipFrontend was passed but no SPA build is staged' 'Run once without it.'
|
||||
}
|
||||
Say ''
|
||||
Say ' Reusing the staged SPA builds (-SkipFrontend). NOT shippable if the frontend changed.' 'Yellow'
|
||||
} else {
|
||||
Step "Building the SPA for /$SubpathAlias/"
|
||||
$env:SITE_PLUGINS = $closure
|
||||
$env:VITE_BASE_PATH = "/$SubpathAlias/"
|
||||
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'subpath frontend build' }
|
||||
finally { Remove-Item Env:\VITE_BASE_PATH -ErrorAction SilentlyContinue }
|
||||
Remove-Item $subStaged -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item (Join-Path $frontend 'dist') $subStaged -Recurse -Force
|
||||
# The alias is fixed at BUILD time; the installer reads this and refuses to
|
||||
# publish under a different one rather than serving a page that cannot load
|
||||
# its own assets.
|
||||
Set-Content -Path (Join-Path $subStaged '.alias') -Value $SubpathAlias -Encoding ASCII
|
||||
|
||||
# Root build LAST, so frontend\dist is left in the state a developer expects.
|
||||
Step 'Building the SPA for /'
|
||||
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'root frontend build' }
|
||||
finally { Remove-Item Env:\SITE_PLUGINS -ErrorAction SilentlyContinue }
|
||||
Remove-Item $rootStaged -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item (Join-Path $frontend 'dist') $rootStaged -Recurse -Force
|
||||
}
|
||||
|
||||
# --- 3. backend tree --------------------------------------------------------
|
||||
Step "Staging the application tree"
|
||||
Remove-Item $AppOut -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path (Join-Path $AppOut 'plugins') -Force | Out-Null
|
||||
|
||||
Copy-Item (Join-Path $RepoRoot 'shopdb') $AppOut -Recurse -Force
|
||||
foreach ($name in $closure.Split(',')) {
|
||||
$src = Join-Path $RepoRoot ('plugins\' + $name.Trim())
|
||||
if (-not (Test-Path $src)) { Die "plugin in the closure is not on disk: $name" }
|
||||
Copy-Item $src (Join-Path $AppOut 'plugins') -Recurse -Force
|
||||
}
|
||||
|
||||
# Runtime files a deployable tree needs beyond the Python packages. Without
|
||||
# these the tree imports but cannot be run or migrated.
|
||||
foreach ($f in @('wsgi.py', 'requirements.txt')) {
|
||||
Copy-Item (Join-Path $RepoRoot $f) $AppOut -Force
|
||||
}
|
||||
Copy-Item (Join-Path $RepoRoot 'migrations') $AppOut -Recurse -Force
|
||||
# ONLY web.config, not all of deploy\. The bundle is staged INSIDE deploy\, so
|
||||
# copying the whole tree would recurse into its own output, and the rest of
|
||||
# deploy\ is installer source that has no business on an application server.
|
||||
# shopdb-install.ps1 reads it from exactly this path.
|
||||
$cfgSrc = Join-Path $RepoRoot 'deploy\windows\web.config'
|
||||
if (Test-Path $cfgSrc) {
|
||||
New-Item -ItemType Directory -Path (Join-Path $AppOut 'deploy\windows') -Force | Out-Null
|
||||
Copy-Item $cfgSrc (Join-Path $AppOut 'deploy\windows') -Force
|
||||
}
|
||||
|
||||
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
|
||||
# Staged INTO the tree so it installs onto the server with the application: an
|
||||
# air-gapped site cannot be scanned remotely, so the only way to answer "are we
|
||||
# exposed to this CVE, and where" is for the answer to be sitting on the box.
|
||||
Step 'Generating SBOM'
|
||||
& $python.Source (Join-Path $RepoRoot 'scripts\generate_sbom.py') $RepoRoot `
|
||||
-o (Join-Path $AppOut 'sbom.cdx.json') | ForEach-Object { Say " $_" 'White' }
|
||||
if ($LASTEXITCODE -ne 0) { Die 'SBOM generation failed' }
|
||||
|
||||
# Docs the running site serves, plus the runbooks an air-gapped server has no
|
||||
# other way to reach. Without openapi.json and llms.txt the self-hosted /api/docs
|
||||
# page is broken on every installed server.
|
||||
Step 'Staging docs'
|
||||
$docsOut = Join-Path $AppOut 'docs'
|
||||
New-Item -ItemType Directory -Path $docsOut -Force | Out-Null
|
||||
foreach ($doc in @('openapi.json', 'llms.txt', 'api-inventory.json',
|
||||
'INSTALL-WINDOWS.md', 'OPERATE-WINDOWS.md',
|
||||
'BACKUP-RESTORE.md', 'UPGRADE.md')) {
|
||||
$src = Join-Path $RepoRoot ('docs\' + $doc)
|
||||
if (Test-Path $src) { Copy-Item $src $docsOut -Force; Say " $doc" }
|
||||
}
|
||||
|
||||
# Stage the profile INTO the tree: `flask plugin apply-profile` at provisioning
|
||||
# reads the same profile the tree was staged from, so the installed plugin set
|
||||
# and the shipped plugin code cannot drift.
|
||||
Copy-Item $Profile (Join-Path $AppOut 'site-profile.json') -Force
|
||||
|
||||
# The installer expects frontend\dist and frontend\dist-subpath.
|
||||
$feOut = Join-Path $AppOut 'frontend'
|
||||
New-Item -ItemType Directory -Path $feOut -Force | Out-Null
|
||||
Copy-Item $rootStaged (Join-Path $feOut 'dist') -Recurse -Force
|
||||
Copy-Item $subStaged (Join-Path $feOut 'dist-subpath') -Recurse -Force
|
||||
|
||||
Get-ChildItem $AppOut -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem $AppOut -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# --- 4. plugins.iss ---------------------------------------------------------
|
||||
Step 'Writing plugins.iss'
|
||||
$shipped = (Get-ChildItem (Join-Path $AppOut 'plugins') -Directory | Select-Object -ExpandProperty Name) -join ','
|
||||
$aliasBuilt = ''
|
||||
$aliasFile = Join-Path $feOut 'dist-subpath\.alias'
|
||||
if (Test-Path $aliasFile) { $aliasBuilt = (Get-Content $aliasFile -TotalCount 1).Trim() }
|
||||
@"
|
||||
; GENERATED by build-installer.ps1 - do not edit.
|
||||
; The plugins present in bundle\app\plugins at build time.
|
||||
#define AvailablePlugins "$shipped"
|
||||
; The alias the subpath SPA was built for, or empty if this bundle has no
|
||||
; subpath build - in which case the wizard must not offer that option.
|
||||
#define SubpathAlias "$aliasBuilt"
|
||||
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'plugins.iss') -Encoding ASCII
|
||||
Say " $shipped" 'White'
|
||||
|
||||
# The product version, read from the code rather than restated in the .iss.
|
||||
Step 'Writing version.iss'
|
||||
$initText = Get-Content (Join-Path $RepoRoot 'shopdb\__init__.py') -Raw
|
||||
if ($initText -notmatch "(?m)^__version__\s*=\s*'([^']+)'") { Die 'could not read __version__ from shopdb\__init__.py' }
|
||||
$appVersion = $Matches[1]
|
||||
@"
|
||||
; GENERATED by build-installer.ps1 from shopdb/__init__.py - do not edit.
|
||||
#define AppVersion "$appVersion"
|
||||
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'version.iss') -Encoding ASCII
|
||||
Say " $appVersion" 'White'
|
||||
|
||||
# --- 5. installer scripts ---------------------------------------------------
|
||||
# From THIS directory, which is the reviewed copy under version control. They
|
||||
# used to be copied from a downloads folder, so the logic that shipped was not
|
||||
# the logic that was committed and the build only worked on one machine.
|
||||
Step 'Copying installer scripts'
|
||||
foreach ($f in @('shopdb-install.ps1', 'shopdb-preflight.ps1', 'bundle-lock.ps1')) {
|
||||
$src = Join-Path $PSScriptRoot $f
|
||||
if (-not (Test-Path $src)) { Die "installer script missing from the repo: $f" }
|
||||
Copy-Item $src $BundleRoot -Force
|
||||
Say " $f"
|
||||
}
|
||||
|
||||
# --- 6. payload verification ------------------------------------------------
|
||||
Step 'Verifying the third-party payload against bundle-lock.json'
|
||||
$lockPath = Join-Path $PSScriptRoot 'bundle-lock.json'
|
||||
$lock = Read-BundleLock $lockPath
|
||||
if (-not $lock) {
|
||||
$msg = "no bundle-lock.json at $lockPath"
|
||||
if ($AllowUnlocked) { Say " $msg - continuing because -AllowUnlocked was passed" 'Yellow' }
|
||||
else {
|
||||
Die $msg @'
|
||||
Add the wheels and installers to the bundle, then:
|
||||
.\refresh-bundle-lock.ps1 (review the list)
|
||||
.\refresh-bundle-lock.ps1 -Yes (write it)
|
||||
and commit bundle-lock.json. Pass -AllowUnlocked to stage a bundle without one -
|
||||
it must not be shipped.
|
||||
'@
|
||||
}
|
||||
} else {
|
||||
$problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock
|
||||
if ($problems.Count -eq 0) {
|
||||
Say (" payload matches the lock ({0}, {1})" -f `
|
||||
(Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'Green'
|
||||
# Ships WITH the bundle: the installer re-checks the payload on the
|
||||
# target server before running any of it, so tampering between build and
|
||||
# install is caught too.
|
||||
Copy-Item $lockPath $BundleRoot -Force
|
||||
} else {
|
||||
Say ''
|
||||
foreach ($p in $problems) { Say " $p" 'Red' }
|
||||
Say ''
|
||||
if ($AllowUnlocked) {
|
||||
Say ' -AllowUnlocked: continuing anyway. DO NOT SHIP this bundle.' 'Yellow'
|
||||
} else {
|
||||
Die ("{0} payload problem(s) - the bundle is not what the lock describes" -f $problems.Count) @'
|
||||
Either the payload is wrong (fix the bundle) or it changed on purpose (run
|
||||
refresh-bundle-lock.ps1, read the diff, and commit the new lock).
|
||||
'@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- summary ----------------------------------------------------------------
|
||||
Write-Host ''
|
||||
Say " Bundle staged at: $BundleRoot" 'White'
|
||||
foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', 'mysql')) {
|
||||
$p = Join-Path $BundleRoot $d
|
||||
if (Test-Path $p) {
|
||||
$mb = ((Get-ChildItem $p -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
|
||||
Say (" {0,-22} {1,8:N1} MB" -f $d, $mb)
|
||||
} else {
|
||||
Say (" {0,-22} {1}" -f $d, 'absent') 'DarkGray'
|
||||
}
|
||||
}
|
||||
Write-Host ''
|
||||
Say ' Compile with: iscc ShopDBFlask.iss' 'White'
|
||||
Say ' (Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)' 'DarkGray'
|
||||
Write-Host ''
|
||||
140
deploy/windows/installer/build-installer.sh
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
# Stage a lean per-site bundle next to ShopDBFlask.iss, ready for Inno Setup.
|
||||
#
|
||||
# The bundle is built FOR ONE SITE from its plugin profile (ADR-013): plugins the
|
||||
# site did not choose are absent from the payload entirely. Build one installer
|
||||
# per site, not one universal installer.
|
||||
#
|
||||
# Usage: build-installer.sh <site-profile.json> [repo-path]
|
||||
#
|
||||
# The wheelhouse cannot be built here. Wheels are cp314 win_amd64 and must be
|
||||
# produced ON Windows with the matching Python:
|
||||
# pip download -r requirements.txt -d wheels --only-binary=:all:
|
||||
# Copy that wheels\ directory in before compiling.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROFILE="${1:?usage: build-installer.sh <site-profile.json> [repo-path]}"
|
||||
REPO="${2:-$HOME/projects/shopdb-flask}"
|
||||
BUNDLE="$HERE/bundle"
|
||||
|
||||
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
|
||||
[ -d "$REPO" ] || { echo "repo not found: $REPO"; exit 1; }
|
||||
|
||||
echo "==> Staging lean app tree from $PROFILE"
|
||||
rm -rf "$BUNDLE/app"
|
||||
bash "$REPO/scripts/build-site.sh" "$PROFILE" "$BUNDLE/app"
|
||||
|
||||
# build-site.sh emits the SPA as frontend-dist; the installer's web.config and
|
||||
# static route expect frontend\dist.
|
||||
if [ -d "$BUNDLE/app/frontend-dist" ]; then
|
||||
mkdir -p "$BUNDLE/app/frontend"
|
||||
rm -rf "$BUNDLE/app/frontend/dist"
|
||||
mv "$BUNDLE/app/frontend-dist" "$BUNDLE/app/frontend/dist"
|
||||
fi
|
||||
# The /shopdb-based build, used when the operator picks the subpath deployment.
|
||||
if [ -d "$BUNDLE/app/frontend-dist-subpath" ]; then
|
||||
rm -rf "$BUNDLE/app/frontend/dist-subpath"
|
||||
mv "$BUNDLE/app/frontend-dist-subpath" "$BUNDLE/app/frontend/dist-subpath"
|
||||
fi
|
||||
|
||||
# Tell the .iss which plugins this bundle actually carries, so the wizard's
|
||||
# plugin page always matches the payload instead of a hand-maintained list.
|
||||
echo "==> Writing plugins.iss"
|
||||
PLUGINS=$(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
|
||||
# The subpath SPA is built with its base path compiled in, so whether the wizard
|
||||
# can OFFER a subpath install is a property of the bundle, not a runtime choice.
|
||||
SUBPATH_ALIAS_BUILT=""
|
||||
if [ -f "$BUNDLE/app/frontend/dist-subpath/.alias" ]; then
|
||||
SUBPATH_ALIAS_BUILT="$(cat "$BUNDLE/app/frontend/dist-subpath/.alias")"
|
||||
fi
|
||||
|
||||
cat > "$HERE/plugins.iss" <<EOF
|
||||
; GENERATED by build-installer.sh - do not edit.
|
||||
; The plugins present in bundle\\app\\plugins at build time.
|
||||
#define AvailablePlugins "$PLUGINS"
|
||||
; The alias the subpath SPA was built for, or empty if this bundle has no
|
||||
; subpath build - in which case the wizard must not offer that option.
|
||||
#define SubpathAlias "$SUBPATH_ALIAS_BUILT"
|
||||
EOF
|
||||
echo " $PLUGINS"
|
||||
|
||||
# The product version, read from the code rather than restated here. A hardcoded
|
||||
# AppVersion in the .iss had drifted two minor versions from shopdb/__init__.py.
|
||||
echo "==> Writing version.iss"
|
||||
APPVERSION=$(sed -n "s/^__version__ = '\\(.*\\)'/\\1/p" "$REPO/shopdb/__init__.py" | head -1)
|
||||
[ -n "$APPVERSION" ] || { echo "could not read __version__ from shopdb/__init__.py"; exit 1; }
|
||||
cat > "$HERE/version.iss" <<EOF
|
||||
; GENERATED by build-installer.sh from shopdb/__init__.py - do not edit.
|
||||
#define AppVersion "$APPVERSION"
|
||||
EOF
|
||||
echo " $APPVERSION"
|
||||
|
||||
# From THIS directory, which is the reviewed copy under version control. These
|
||||
# used to be copied from $HOME/Downloads, so the installer logic that shipped was
|
||||
# not the logic that was committed, and the build only worked on one machine.
|
||||
echo "==> Copying installer scripts"
|
||||
mkdir -p "$BUNDLE"
|
||||
for f in shopdb-install.ps1 shopdb-preflight.ps1 bundle-lock.ps1; do
|
||||
[ -f "$HERE/$f" ] || { echo "installer script missing from the repo: $f"; exit 1; }
|
||||
cp "$HERE/$f" "$BUNDLE/"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Bundle staged at: $BUNDLE"
|
||||
for d in app wheels python httpplatformhandler urlrewrite mysql; do
|
||||
if [ -d "$BUNDLE/$d" ]; then
|
||||
printf ' %-20s %s\n' "$d" "$(du -sh "$BUNDLE/$d" | cut -f1)"
|
||||
else
|
||||
printf ' %-20s absent\n' "$d"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " plugins shipped: $(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
# --- payload verification ---------------------------------------------------
|
||||
# The third-party payload is the part git does not record: the wheels, the Python
|
||||
# installer and the MSIs that run as SYSTEM on the target server. It must be
|
||||
# EXACTLY what bundle-lock.json describes - no missing file, no stale extra wheel
|
||||
# left over from a previous build, no changed content - or this is not a bundle
|
||||
# anyone reviewed. Previously a missing wheelhouse printed MISSING and the script
|
||||
# still exited 0, so an empty bundle compiled into a shippable installer and the
|
||||
# failure surfaced on an air-gapped server with no way to fix it.
|
||||
#
|
||||
# ALLOW_UNLOCKED=1 downgrades this to a warning, for assembling a bundle before
|
||||
# its lock exists. A bundle built that way must not be shipped.
|
||||
echo ""
|
||||
echo "==> Verifying the third-party payload against bundle-lock.json"
|
||||
if python3 "$HERE/verify_bundle_lock.py" "$BUNDLE" "$HERE/bundle-lock.json"; then
|
||||
echo " payload matches the lock"
|
||||
# Ships WITH the bundle: the installer re-checks the payload on the target
|
||||
# server before running any of it, so tampering between build and install is
|
||||
# caught too.
|
||||
cp "$HERE/bundle-lock.json" "$BUNDLE/"
|
||||
elif [ "${ALLOW_UNLOCKED:-0}" = "1" ]; then
|
||||
echo ""
|
||||
echo " ALLOW_UNLOCKED=1: continuing anyway. DO NOT SHIP this bundle."
|
||||
else
|
||||
echo ""
|
||||
echo " The bundle is not what the lock describes."
|
||||
echo ""
|
||||
echo " Add the missing pieces by hand:"
|
||||
echo " wheels/ pip download -r requirements.txt --only-binary=:all: \\"
|
||||
echo " --platform win_amd64 --python-version 314 \\"
|
||||
echo " --implementation cp --abi cp314 -d wheels"
|
||||
echo " python/ python-3.14.x-amd64.exe"
|
||||
echo " httpplatformhandler/ httpPlatformHandler_amd64.msi"
|
||||
echo " urlrewrite/ rewrite_amd64.msi (client-IP rule; see README)"
|
||||
echo " mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)"
|
||||
echo ""
|
||||
echo " If the payload changed ON PURPOSE, regenerate and COMMIT the lock:"
|
||||
echo " pwsh ./refresh-bundle-lock.ps1 # review the diff"
|
||||
echo " pwsh ./refresh-bundle-lock.ps1 -Yes # write it"
|
||||
echo ""
|
||||
echo " To stage a bundle before its lock exists: ALLOW_UNLOCKED=1 $0 ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Then compile on Windows: iscc ShopDBFlask.iss"
|
||||
echo "(Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)"
|
||||
197
deploy/windows/installer/bundle-lock.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"generated": "2026-08-03T15:45:11Z",
|
||||
"pythontag": "cp314",
|
||||
"platform": "win_amd64",
|
||||
"note": "Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.",
|
||||
"payloads": {
|
||||
"wheels": {
|
||||
"required": true,
|
||||
"files": {
|
||||
"jinja2-3.1.6-py3-none-any.whl": {
|
||||
"size": 134899,
|
||||
"sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
|
||||
},
|
||||
"itsdangerous-2.2.0-py3-none-any.whl": {
|
||||
"size": 16234,
|
||||
"sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"
|
||||
},
|
||||
"cffi-2.1.0-cp314-cp314-win_amd64.whl": {
|
||||
"size": 187937,
|
||||
"sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"
|
||||
},
|
||||
"email_validator-2.3.0-py3-none-any.whl": {
|
||||
"size": 35604,
|
||||
"sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"
|
||||
},
|
||||
"urllib3-2.7.0-py3-none-any.whl": {
|
||||
"size": 131087,
|
||||
"sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"
|
||||
},
|
||||
"flask_jwt_extended-4.7.3-py2.py3-none-any.whl": {
|
||||
"size": 22698,
|
||||
"sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb"
|
||||
},
|
||||
"flask_sqlalchemy-3.1.1-py3-none-any.whl": {
|
||||
"size": 25125,
|
||||
"sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0"
|
||||
},
|
||||
"pycparser-3.0-py3-none-any.whl": {
|
||||
"size": 48172,
|
||||
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
|
||||
},
|
||||
"requests-2.33.1-py3-none-any.whl": {
|
||||
"size": 64947,
|
||||
"sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
|
||||
},
|
||||
"blinker-1.9.0-py3-none-any.whl": {
|
||||
"size": 8458,
|
||||
"sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"
|
||||
},
|
||||
"greenlet-3.5.0-cp314-cp314-win_amd64.whl": {
|
||||
"size": 239835,
|
||||
"sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033"
|
||||
},
|
||||
"marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": {
|
||||
"size": 16582,
|
||||
"sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258"
|
||||
},
|
||||
"alembic-1.18.4-py3-none-any.whl": {
|
||||
"size": 263893,
|
||||
"sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"
|
||||
},
|
||||
"pyjwt-2.12.1-py3-none-any.whl": {
|
||||
"size": 29726,
|
||||
"sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"
|
||||
},
|
||||
"mako-1.3.12-py3-none-any.whl": {
|
||||
"size": 78521,
|
||||
"sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"
|
||||
},
|
||||
"waitress-3.0.2-py3-none-any.whl": {
|
||||
"size": 56232,
|
||||
"sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e"
|
||||
},
|
||||
"colorama-0.4.6-py2.py3-none-any.whl": {
|
||||
"size": 25335,
|
||||
"sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
|
||||
},
|
||||
"certifi-2026.4.22-py3-none-any.whl": {
|
||||
"size": 135707,
|
||||
"sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"
|
||||
},
|
||||
"cachelib-0.13.0-py3-none-any.whl": {
|
||||
"size": 20914,
|
||||
"sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516"
|
||||
},
|
||||
"pymysql-1.1.3-py3-none-any.whl": {
|
||||
"size": 45356,
|
||||
"sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289"
|
||||
},
|
||||
"werkzeug-3.1.8-py3-none-any.whl": {
|
||||
"size": 226459,
|
||||
"sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50"
|
||||
},
|
||||
"idna-3.13-py3-none-any.whl": {
|
||||
"size": 68629,
|
||||
"sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
|
||||
},
|
||||
"python_dotenv-1.2.2-py3-none-any.whl": {
|
||||
"size": 22101,
|
||||
"sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"
|
||||
},
|
||||
"flask_marshmallow-1.5.0-py3-none-any.whl": {
|
||||
"size": 12161,
|
||||
"sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6"
|
||||
},
|
||||
"markupsafe-3.0.3-cp314-cp314-win_amd64.whl": {
|
||||
"size": 15341,
|
||||
"sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"
|
||||
},
|
||||
"flask_cors-6.0.2-py3-none-any.whl": {
|
||||
"size": 13257,
|
||||
"sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a"
|
||||
},
|
||||
"flask_caching-2.4.0-py3-none-any.whl": {
|
||||
"size": 28727,
|
||||
"sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486"
|
||||
},
|
||||
"tzdata-2026.3-py2.py3-none-any.whl": {
|
||||
"size": 348168,
|
||||
"sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"
|
||||
},
|
||||
"charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": {
|
||||
"size": 159634,
|
||||
"sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"
|
||||
},
|
||||
"sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": {
|
||||
"size": 2144204,
|
||||
"sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"
|
||||
},
|
||||
"marshmallow-4.3.0-py3-none-any.whl": {
|
||||
"size": 49148,
|
||||
"sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46"
|
||||
},
|
||||
"dnspython-2.8.0-py3-none-any.whl": {
|
||||
"size": 331094,
|
||||
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"
|
||||
},
|
||||
"typing_extensions-4.15.0-py3-none-any.whl": {
|
||||
"size": 44614,
|
||||
"sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||
},
|
||||
"flask-3.1.3-py3-none-any.whl": {
|
||||
"size": 103424,
|
||||
"sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"
|
||||
},
|
||||
"tabulate-0.10.0-py3-none-any.whl": {
|
||||
"size": 39814,
|
||||
"sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"
|
||||
},
|
||||
"mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": {
|
||||
"size": 18197576,
|
||||
"sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49"
|
||||
},
|
||||
"Flask_Migrate-4.1.0-py3-none-any.whl": {
|
||||
"size": 21237,
|
||||
"sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d"
|
||||
},
|
||||
"cryptography-50.0.0-cp311-abi3-win_amd64.whl": {
|
||||
"size": 3840395,
|
||||
"sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"
|
||||
},
|
||||
"click-8.3.3-py3-none-any.whl": {
|
||||
"size": 110502,
|
||||
"sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"
|
||||
}
|
||||
}
|
||||
},
|
||||
"python": {
|
||||
"required": true,
|
||||
"files": {
|
||||
"python-3.14.6-amd64.exe": {
|
||||
"size": 30774112,
|
||||
"sha256": "14b3e9a710a3fcf0bd9b55ab6b60412bd91227563f813fc49040cabc0209e0bd"
|
||||
}
|
||||
}
|
||||
},
|
||||
"urlrewrite": {
|
||||
"required": false,
|
||||
"files": {
|
||||
"rewrite_amd64_en-US.msi": {
|
||||
"size": 6078464,
|
||||
"sha256": "37342ff2f585f263f34f48e9de59eb1051d61015a8e967dbde4075716230a32a"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpplatformhandler": {
|
||||
"required": true,
|
||||
"files": {
|
||||
"httpPlatformHandler_amd64.msi": {
|
||||
"size": 557056,
|
||||
"sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
233
deploy/windows/installer/bundle-lock.ps1
Normal file
@@ -0,0 +1,233 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Hash manifest for the installer's third-party payload: create it, and check a
|
||||
bundle against it.
|
||||
|
||||
.DESCRIPTION
|
||||
Dot-source this. It defines two functions and runs nothing on its own:
|
||||
|
||||
New-BundleLock hash a staged bundle and return the lock object
|
||||
Test-BundleLock compare a staged bundle against a lock, return problems
|
||||
|
||||
WHAT IT COVERS, and why pip's own hash checking is not enough.
|
||||
|
||||
requirements.txt carries a sha256 for every wheel, so pip refuses an artifact
|
||||
upstream did not publish. Three gaps remain, and all three are what actually
|
||||
goes wrong with a hand-assembled offline bundle:
|
||||
|
||||
1. pip lists EVERY artifact of a pinned version - cffi 2.1.0 alone has 100
|
||||
hashes. It proves the wheel is genuine, not that it is the wheel this
|
||||
bundle was built and tested with.
|
||||
2. pip ignores extra files in the wheelhouse. A stale wheel left behind by
|
||||
a previous build sits there unnoticed until a resolve picks it up.
|
||||
3. pip says nothing about the rest of the payload - the Python installer,
|
||||
the HttpPlatformHandler MSI, URL Rewrite, MySQL. Those are executables
|
||||
that run as SYSTEM on the target server and were, until this file, the
|
||||
only unverified thing the installer would run.
|
||||
|
||||
So the lock records an exact file set with a sha256 and a byte size each, and
|
||||
verification is SET EQUALITY: a missing file, an unexpected extra file, or a
|
||||
changed file all fail. Nothing is skipped and nothing is "close enough".
|
||||
|
||||
The app tree is deliberately NOT covered. It is built from the repository on
|
||||
every run and changes with every commit; hashing it would make the lock churn
|
||||
constantly and train everyone to regenerate it without reading it. Git is the
|
||||
record for the app tree. This file is the record for everything that comes
|
||||
from outside the repository.
|
||||
|
||||
.NOTES
|
||||
Stock Windows PowerShell 5.1, and also runs under pwsh on Linux so the Bash
|
||||
builder can call the same checker.
|
||||
#>
|
||||
|
||||
# Payload directories under the bundle root. 'required' means the installer
|
||||
# cannot work without it; the optional ones are per-deployment choices, and an
|
||||
# absent optional directory is fine. A PRESENT directory is always checked in
|
||||
# full, optional or not.
|
||||
$script:BundlePayloads = @(
|
||||
@{ Name = 'wheels'; Required = $true; What = 'Python wheels for the offline install' }
|
||||
@{ Name = 'python'; Required = $true; What = 'the Python installer' }
|
||||
@{ Name = 'httpplatformhandler'; Required = $true; What = 'the IIS module that launches waitress' }
|
||||
@{ Name = 'urlrewrite'; Required = $false; What = 'IIS URL Rewrite, for the client-IP rule' }
|
||||
@{ Name = 'mysqlclient'; Required = $false; What = 'mysql/mysqldump, for backups against a remote database' }
|
||||
@{ Name = 'mysql'; Required = $false; What = 'MySQL, for the bundled-database option' }
|
||||
)
|
||||
|
||||
function Get-JsonProperty {
|
||||
# shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
|
||||
# that does not exist on a PSCustomObject THROWS instead of returning $null.
|
||||
# A truncated or hand-edited bundle-lock.json would therefore blow up with
|
||||
# "Property 'payloads' cannot be found" rather than saying what is wrong with
|
||||
# the lock. Every read of parsed JSON goes through here.
|
||||
param($Object, [string] $Name, $Default = $null)
|
||||
if ($null -eq $Object) { return $Default }
|
||||
$prop = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $prop) { return $Default }
|
||||
return $prop.Value
|
||||
}
|
||||
|
||||
function Get-FileDigest {
|
||||
param([string] $Path)
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try { return (-join ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') })) }
|
||||
finally { $stream.Dispose(); $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Get-PayloadFiles {
|
||||
# Every file in the directory, keyed by its path RELATIVE to that directory
|
||||
# with forward slashes, so a lock generated on Windows reads the same from
|
||||
# the Bash builder.
|
||||
param([string] $Dir)
|
||||
$out = @{}
|
||||
if (-not (Test-Path $Dir)) { return $out }
|
||||
$root = (Resolve-Path $Dir).Path.TrimEnd('\', '/')
|
||||
foreach ($f in (Get-ChildItem $Dir -Recurse -File)) {
|
||||
$rel = $f.FullName.Substring($root.Length).TrimStart('\', '/').Replace('\', '/')
|
||||
$out[$rel] = @{ sha256 = (Get-FileDigest $f.FullName); size = $f.Length }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function New-BundleLock {
|
||||
<#
|
||||
Hash a staged bundle. The caller writes the result to bundle-lock.json;
|
||||
this returns the object so a caller can diff it against the committed lock
|
||||
before overwriting anything.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $BundleRoot,
|
||||
[string] $PythonTag = 'cp314',
|
||||
[string] $Platform = 'win_amd64'
|
||||
)
|
||||
$payloads = @{}
|
||||
foreach ($p in $script:BundlePayloads) {
|
||||
$dir = Join-Path $BundleRoot $p.Name
|
||||
if (-not (Test-Path $dir)) {
|
||||
if ($p.Required) { throw ("payload directory is missing: {0} ({1})" -f $p.Name, $p.What) }
|
||||
continue
|
||||
}
|
||||
$files = Get-PayloadFiles $dir
|
||||
if ($files.Count -eq 0 -and $p.Required) {
|
||||
throw ("payload directory is empty: {0} ({1})" -f $p.Name, $p.What)
|
||||
}
|
||||
$payloads[$p.Name] = @{ required = $p.Required; files = $files }
|
||||
}
|
||||
return [ordered]@{
|
||||
schema = 1
|
||||
generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
pythontag = $PythonTag
|
||||
platform = $Platform
|
||||
note = 'Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.'
|
||||
payloads = $payloads
|
||||
}
|
||||
}
|
||||
|
||||
function Test-BundleLock {
|
||||
<#
|
||||
Compare a staged bundle against a lock. Returns an array of problem
|
||||
strings - EMPTY means the bundle is exactly what the lock describes.
|
||||
|
||||
Returning problems rather than throwing is deliberate: an operator fixing a
|
||||
wheelhouse wants the whole list at once, not one failure per rebuild.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $BundleRoot,
|
||||
[Parameter(Mandatory = $true)] $Lock
|
||||
)
|
||||
$problems = @()
|
||||
$payloads = Get-JsonProperty $Lock 'payloads'
|
||||
if ($null -eq $payloads) { return @('bundle-lock.json has no "payloads" section') }
|
||||
|
||||
foreach ($p in $script:BundlePayloads) {
|
||||
$name = $p.Name
|
||||
$dir = Join-Path $BundleRoot $name
|
||||
$present = Test-Path $dir
|
||||
$entry = Get-JsonProperty $payloads $name
|
||||
$locked = ($null -ne $entry)
|
||||
|
||||
if (-not $locked) {
|
||||
# Not in the lock at all. An unlocked directory that exists is a
|
||||
# payload nobody reviewed, which is exactly what this is here to stop.
|
||||
if ($present) { $problems += "$name/ is present but is not in bundle-lock.json - regenerate the lock" }
|
||||
elseif ($p.Required) { $problems += "$name/ is required but is in neither the bundle nor the lock" }
|
||||
continue
|
||||
}
|
||||
if (-not $present) {
|
||||
if ($p.Required -or (Get-JsonProperty $entry 'required' $false)) {
|
||||
$problems += "$name/ is in the lock but missing from the bundle ($($p.What))"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
$expected = @{}
|
||||
$lockedFiles = Get-JsonProperty $entry 'files'
|
||||
if ($null -ne $lockedFiles) {
|
||||
foreach ($prop in $lockedFiles.PSObject.Properties) { $expected[$prop.Name] = $prop.Value }
|
||||
}
|
||||
$actual = Get-PayloadFiles $dir
|
||||
|
||||
# Sorted so the report reads the same way twice, and so it matches the
|
||||
# order verify_bundle_lock.py produces.
|
||||
foreach ($rel in ($expected.Keys | Sort-Object)) {
|
||||
if (-not $actual.ContainsKey($rel)) { $problems += "$name/$rel is in the lock but missing from the bundle"; continue }
|
||||
if ($actual[$rel].sha256 -ne $expected[$rel].sha256) {
|
||||
$problems += "$name/$rel does NOT match the lock (expected sha256 $($expected[$rel].sha256.Substring(0,12))..., got $($actual[$rel].sha256.Substring(0,12))...)"
|
||||
} elseif ([int64] $actual[$rel].size -ne [int64] $expected[$rel].size) {
|
||||
# Cannot happen for a matching sha256, so it means the lock itself
|
||||
# was hand-edited. Say so rather than passing it.
|
||||
$problems += "$name/$rel size disagrees with the lock - the lock has been edited by hand"
|
||||
}
|
||||
}
|
||||
foreach ($rel in ($actual.Keys | Sort-Object)) {
|
||||
if (-not $expected.ContainsKey($rel)) { $problems += "$name/$rel is in the bundle but NOT in the lock (unexpected extra file)" }
|
||||
}
|
||||
}
|
||||
$problems += Test-WheelhouseCoversRequirements -BundleRoot $BundleRoot
|
||||
return $problems
|
||||
}
|
||||
|
||||
function Test-WheelhouseCoversRequirements {
|
||||
<#
|
||||
The lock records what IS in the wheelhouse, not what the application NEEDS.
|
||||
Without this an incomplete wheelhouse gets locked, blessed, and shipped,
|
||||
and the install fails on an air-gapped server.
|
||||
|
||||
Not hypothetical: assembling the wheelhouse anywhere other than Windows
|
||||
silently omits colorama, a win32-only dependency of click, because pip
|
||||
evaluates environment markers against the machine doing the downloading
|
||||
rather than the machine being targeted. Markers are therefore IGNORED here
|
||||
- a requirement guarded by sys_platform == 'win32' is precisely the one
|
||||
that has to be present.
|
||||
#>
|
||||
param([Parameter(Mandatory = $true)] [string] $BundleRoot)
|
||||
$wheels = Join-Path $BundleRoot 'wheels'
|
||||
$reqs = Join-Path $BundleRoot 'app\requirements.txt'
|
||||
if (-not (Test-Path $wheels) -or -not (Test-Path $reqs)) { return @() }
|
||||
|
||||
$have = @(Get-ChildItem $wheels -File -ErrorAction SilentlyContinue | ForEach-Object { $_.Name.ToLower() })
|
||||
$problems = @()
|
||||
$pins = @{}
|
||||
foreach ($line in (Get-Content $reqs)) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#')) { continue }
|
||||
if ($trimmed -match '^([A-Za-z0-9._-]+)==([^\s;\\]+)') {
|
||||
# PEP 427 wheel filename form: runs of non-alphanumerics become one _.
|
||||
$pins[([regex]::Replace($Matches[1], '[^A-Za-z0-9.]+', '_')).ToLower()] = $Matches[2]
|
||||
}
|
||||
}
|
||||
foreach ($name in ($pins.Keys | Sort-Object)) {
|
||||
$prefix = "$name-$($pins[$name])-"
|
||||
if (-not ($have | Where-Object { $_.StartsWith($prefix) })) {
|
||||
$problems += ("wheels/ has no wheel for {0}=={1}, which requirements.txt pins " +
|
||||
"(a marked-out dependency still installs on Windows)") -f $name, $pins[$name]
|
||||
}
|
||||
}
|
||||
return $problems
|
||||
}
|
||||
|
||||
function Read-BundleLock {
|
||||
param([Parameter(Mandatory = $true)] [string] $Path)
|
||||
if (-not (Test-Path $Path)) { return $null }
|
||||
return (Get-Content $Path -Raw | ConvertFrom-Json)
|
||||
}
|
||||
159
deploy/windows/installer/make-branding.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Inno Setup wizard artwork from the app's own brand assets.
|
||||
|
||||
Everything here is derived from frontend/public/*.svg so the installer and the
|
||||
running application are visibly the same product. Nothing is redrawn by hand.
|
||||
|
||||
Inno stretches artwork to fit and does not resample well, so render at the exact
|
||||
sizes it asks for and supply the 125%/250% variants for high-DPI displays.
|
||||
|
||||
WizardImageFile 164x314, 192x386, 384x772
|
||||
WizardSmallImageFile 55x55, 64x64, 138x138
|
||||
SetupIconFile .ico with 16/24/32/48/64/128/256
|
||||
|
||||
Usage: python3 make-branding.py [output-dir]
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cairosvg
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ASSETS = Path.home() / "projects/shopdb-flask/frontend/public"
|
||||
OUT = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
|
||||
|
||||
# Sampled from the application's own palette so the installer does not look like
|
||||
# a different product wearing the same badge.
|
||||
NAVY = (10, 34, 74) # deep base
|
||||
BLUE = (16, 74, 150) # GE blue
|
||||
CYAN = (0, 158, 224) # accent
|
||||
WHITE = (255, 255, 255)
|
||||
MUTED = (176, 197, 226)
|
||||
|
||||
|
||||
def render_svg(name, width=None, height=None):
|
||||
png = cairosvg.svg2png(url=str(ASSETS / name), output_width=width, output_height=height)
|
||||
return Image.open(io.BytesIO(png)).convert("RGBA")
|
||||
|
||||
|
||||
def recolour(img, colour):
|
||||
"""Replace RGB while keeping the alpha mask. Source marks are dark-on-light;
|
||||
on a dark panel they must be inverted or they disappear."""
|
||||
solid = Image.new("RGBA", img.size, colour + (255,))
|
||||
solid.putalpha(img.getchannel("A"))
|
||||
return solid
|
||||
|
||||
|
||||
def font(size, bold=False):
|
||||
for path in (
|
||||
f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf",
|
||||
f"/usr/share/fonts/truetype/liberation/LiberationSans{'-Bold' if bold else '-Regular'}.ttf",
|
||||
):
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def vertical_gradient(size, top, bottom):
|
||||
w, h = size
|
||||
img = Image.new("RGB", size)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for y in range(h):
|
||||
t = y / max(1, h - 1)
|
||||
# Ease the ramp so the middle does not look flat.
|
||||
t = t * t * (3 - 2 * t)
|
||||
draw.line(
|
||||
[(0, y), (w, y)],
|
||||
fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def banner(w, h):
|
||||
img = vertical_gradient((w, h), BLUE, NAVY)
|
||||
draw = ImageDraw.Draw(img)
|
||||
k = w / 164.0 # scale factor from the 100% design
|
||||
|
||||
# Faint diagonal wash: stops the flat area under the text reading as empty.
|
||||
glow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||
gd = ImageDraw.Draw(glow)
|
||||
gd.polygon([(0, int(h * 0.52)), (w, int(h * 0.30)), (w, h), (0, h)],
|
||||
fill=(255, 255, 255, 10))
|
||||
img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
margin = int(22 * k)
|
||||
|
||||
# GE Aerospace wordmark at the top, above the product name - the corporate
|
||||
# mark leads, the product sits under it. (Previously the bare monogram was
|
||||
# here and the wordmark was stranded at the bottom.)
|
||||
mark_w = w - (margin * 2)
|
||||
mark = render_svg("ge-aerospace-logo.svg", mark_w, int(mark_w * 32 / 138))
|
||||
mark = recolour(mark, WHITE)
|
||||
img.paste(mark, (margin, int(34 * k)), mark)
|
||||
|
||||
# Product name, directly beneath it.
|
||||
y = int(34 * k) + mark.height + int(30 * k)
|
||||
draw.text((margin, y), "ShopDB", font=font(int(26 * k), bold=True), fill=WHITE)
|
||||
y += int(31 * k)
|
||||
|
||||
# Hairline rule, then the descriptor. Cheap way to look considered.
|
||||
draw.rectangle([margin, y, margin + int(30 * k), y + max(1, int(2 * k))], fill=CYAN)
|
||||
y += int(14 * k)
|
||||
for line in ("Asset management", "for the shop floor"):
|
||||
draw.text((margin, y), line, font=font(int(10.5 * k)), fill=MUTED)
|
||||
y += int(15 * k)
|
||||
|
||||
# Accent bar flush to the bottom edge.
|
||||
bar = max(2, int(4 * k))
|
||||
draw.rectangle([0, h - bar, w, h], fill=CYAN)
|
||||
return img
|
||||
|
||||
|
||||
def small(size):
|
||||
"""Header mark on every page after the welcome page. White plate so it sits
|
||||
correctly on the wizard's own header, in light or dark mode."""
|
||||
img = Image.new("RGB", (size, size), WHITE)
|
||||
m = int(size * 0.80)
|
||||
mono = recolour(render_svg("ge-monogram.svg", m, m), BLUE)
|
||||
off = (size - m) // 2
|
||||
img.paste(mono, (off, off), mono)
|
||||
return img
|
||||
|
||||
|
||||
def icon(path):
|
||||
"""Installer icon. Rounded navy tile with the monogram, so it reads at 16px
|
||||
instead of turning into mush."""
|
||||
base = 256
|
||||
img = Image.new("RGBA", (base, base), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle([0, 0, base - 1, base - 1], radius=int(base * 0.22), fill=BLUE + (255,))
|
||||
d.rounded_rectangle([0, 0, base - 1, int(base * 0.5)], radius=int(base * 0.22),
|
||||
fill=(30, 96, 175, 255))
|
||||
d.rounded_rectangle([0, int(base * 0.3), base - 1, base - 1], radius=int(base * 0.22),
|
||||
fill=BLUE + (255,))
|
||||
m = int(base * 0.62)
|
||||
mono = recolour(render_svg("ge-monogram.svg", m, m), WHITE)
|
||||
img.paste(mono, ((base - m) // 2, (base - m) // 2), mono)
|
||||
img.save(path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64),
|
||||
(128, 128), (256, 256)])
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
made = []
|
||||
for w, h, name in [(164, 314, "wizard-image.bmp"),
|
||||
(192, 386, "wizard-image@125.bmp"),
|
||||
(384, 772, "wizard-image@250.bmp")]:
|
||||
banner(w, h).save(OUT / name, "BMP"); made.append((name, f"{w}x{h}"))
|
||||
for s, name in [(55, "wizard-small.bmp"), (64, "wizard-small@125.bmp"),
|
||||
(138, "wizard-small@250.bmp")]:
|
||||
small(s).save(OUT / name, "BMP"); made.append((name, f"{s}x{s}"))
|
||||
icon(OUT / "shopdb.ico"); made.append(("shopdb.ico", "multi-size"))
|
||||
for name, dims in made:
|
||||
print(f" {name:<26} {dims:<10} {(OUT / name).stat().st_size // 1024} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
110
deploy/windows/installer/refresh-bundle-lock.ps1
Normal file
@@ -0,0 +1,110 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Regenerate bundle-lock.json from the staged bundle, after showing what would
|
||||
change.
|
||||
|
||||
.DESCRIPTION
|
||||
Run this on the machine that assembled the wheelhouse, once the bundle holds
|
||||
the payload you intend to ship:
|
||||
|
||||
bundle\wheels\ the wheels, built with the matching Python
|
||||
bundle\python\ the Python installer
|
||||
bundle\httpplatformhandler\ the IIS module MSI
|
||||
bundle\urlrewrite\ URL Rewrite MSI (optional)
|
||||
bundle\mysql\ MySQL MSI (optional)
|
||||
|
||||
Then COMMIT the resulting bundle-lock.json. That commit is the review: it is
|
||||
the only place a change to what runs as SYSTEM on a customer's server becomes
|
||||
visible to a human. A lock regenerated and committed without reading the diff
|
||||
provides nothing, so this refuses to overwrite an existing lock until you
|
||||
have seen the change and passed -Yes.
|
||||
|
||||
.EXAMPLE
|
||||
.\refresh-bundle-lock.ps1 # show the diff, write nothing
|
||||
.\refresh-bundle-lock.ps1 -Yes # write it
|
||||
|
||||
.NOTES
|
||||
Building the wheelhouse itself no longer requires Windows. From any machine:
|
||||
pip download -r requirements.txt -d wheels --only-binary=:all: `
|
||||
--platform win_amd64 --python-version 314 --implementation cp --abi cp314
|
||||
Do it wherever you like; this script records what came out.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
|
||||
[string] $LockPath = (Join-Path $PSScriptRoot 'bundle-lock.json'),
|
||||
[string] $PythonTag = 'cp314',
|
||||
[string] $Platform = 'win_amd64',
|
||||
[switch] $Yes
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
|
||||
|
||||
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||
|
||||
if (-not (Test-Path $BundleRoot)) {
|
||||
Say "bundle not found: $BundleRoot" 'Red'
|
||||
Say 'Stage it first with build-installer.ps1 (or build-installer.sh), then add' 'Yellow'
|
||||
Say 'the wheels and installers by hand.' 'Yellow'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Say ''
|
||||
Say " Hashing $BundleRoot" 'Cyan'
|
||||
$new = New-BundleLock -BundleRoot $BundleRoot -PythonTag $PythonTag -Platform $Platform
|
||||
|
||||
foreach ($name in $new.payloads.Keys) {
|
||||
Say (" {0,-22} {1,4} files" -f $name, $new.payloads[$name].files.Count)
|
||||
}
|
||||
|
||||
$old = Read-BundleLock $LockPath
|
||||
if (-not $old) {
|
||||
Say ''
|
||||
Say ' No existing lock - this will be the first one.' 'Yellow'
|
||||
} else {
|
||||
# Diff by file, per payload, so the operator sees exactly which artifacts
|
||||
# changed rather than "the lock is different".
|
||||
Say ''
|
||||
Say ' Changes against the committed lock:' 'Cyan'
|
||||
$changes = 0
|
||||
foreach ($name in $new.payloads.Keys) {
|
||||
$oldFiles = @{}
|
||||
if ($old.payloads.PSObject.Properties.Name -contains $name) {
|
||||
foreach ($p in $old.payloads.$name.files.PSObject.Properties) { $oldFiles[$p.Name] = $p.Value.sha256 }
|
||||
}
|
||||
$newFiles = $new.payloads[$name].files
|
||||
foreach ($rel in ($newFiles.Keys | Sort-Object)) {
|
||||
if (-not $oldFiles.ContainsKey($rel)) { Say " + $name/$rel" 'Green'; $changes++ }
|
||||
elseif ($oldFiles[$rel] -ne $newFiles[$rel].sha256) { Say " ~ $name/$rel (content changed)" 'Yellow'; $changes++ }
|
||||
}
|
||||
foreach ($rel in ($oldFiles.Keys | Sort-Object)) {
|
||||
if (-not $newFiles.ContainsKey($rel)) { Say " - $name/$rel" 'Red'; $changes++ }
|
||||
}
|
||||
}
|
||||
foreach ($p in $old.payloads.PSObject.Properties.Name) {
|
||||
if (-not $new.payloads.Contains($p)) { Say " - $p/ (whole payload gone)" 'Red'; $changes++ }
|
||||
}
|
||||
if ($changes -eq 0) {
|
||||
Say ' none - the bundle already matches the lock' 'Green'
|
||||
exit 0
|
||||
}
|
||||
Say ''
|
||||
Say (" {0} change(s)." -f $changes) 'White'
|
||||
}
|
||||
|
||||
if (-not $Yes) {
|
||||
Say ''
|
||||
Say ' Nothing written. Read the list above, then re-run with -Yes.' 'Yellow'
|
||||
Say ' Commit the resulting bundle-lock.json - that commit IS the review.' 'Yellow'
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ConvertTo-Json defaults to a depth of 2, which silently flattens the per-file
|
||||
# entries into "System.Collections.Hashtable" strings and produces a lock that
|
||||
# verifies against nothing.
|
||||
$new | ConvertTo-Json -Depth 8 | Set-Content -Path $LockPath -Encoding UTF8
|
||||
Say ''
|
||||
Say " Written: $LockPath" 'Green'
|
||||
Say ' Commit it.' 'Green'
|
||||
816
deploy/windows/installer/shopdb-admin.ps1
Normal file
@@ -0,0 +1,816 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Day-to-day control of a ShopDB-Flask installation, on the server itself.
|
||||
|
||||
.DESCRIPTION
|
||||
Installed alongside the application so an operator never has to open IIS
|
||||
Manager, hunt for a log, or remember an appcmd incantation.
|
||||
|
||||
Run it with no arguments for a menu, or pass a command directly:
|
||||
|
||||
.\shopdb-admin.ps1 status
|
||||
.\shopdb-admin.ps1 restart
|
||||
.\shopdb-admin.ps1 backup D:\backups
|
||||
|
||||
Everything here is safe to run at any time EXCEPT backup/restore, which are
|
||||
called out explicitly.
|
||||
|
||||
.NOTES
|
||||
Written for stock Windows PowerShell 5.1. No modules to install.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
|
||||
'check','sessions','plugins','add-plugin','verify','uninstall')]
|
||||
[string] $Command = 'menu',
|
||||
[string] $Path = '',
|
||||
# Machine-readable output for 'check'. The people running this are expected to
|
||||
# ask an AI assistant for help, and pasting a screenshot of a console into a
|
||||
# chat window loses most of what matters. One structured blob they can paste
|
||||
# gives the assistant real state to reason about instead of guesses.
|
||||
[switch] $Json,
|
||||
[string] $AppRoot = 'C:\shopdb-flask',
|
||||
[string] $SiteName = 'shopdb-flask',
|
||||
[string] $AppPool = 'shopdbflask',
|
||||
[int] $SitePort = 8090
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe'
|
||||
|
||||
# --- bitness ---------------------------------------------------------------
|
||||
# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell,
|
||||
# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with
|
||||
# Retrieving the COM class factory ... REGDB_E_CLASSNOTREG
|
||||
# which this script caught and reported as "cannot read IIS" - indistinguishable
|
||||
# from IIS being absent.
|
||||
#
|
||||
# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so
|
||||
# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather
|
||||
# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is
|
||||
# the alias that lets a 32-bit process reach the real System32, and it exists
|
||||
# ONLY for 32-bit processes - hence the guard.
|
||||
|
||||
# Every relaunch below must carry the ORIGINAL arguments through. They used to be
|
||||
# dropped, so a console started from the Start Menu with -AppRoot D:\shopdb
|
||||
# relaunched itself with the C:\shopdb-flask default and reported a healthy
|
||||
# install as missing - and the operator had done nothing wrong.
|
||||
function Get-ForwardedArgs {
|
||||
$forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
|
||||
'-File', ('"' + $PSCommandPath + '"'), $Command)
|
||||
if ($Path) { $forward += @('-Path', ('"' + $Path + '"')) }
|
||||
if ($AppRoot) { $forward += @('-AppRoot', ('"' + $AppRoot + '"')) }
|
||||
if ($SiteName) { $forward += @('-SiteName', ('"' + $SiteName + '"')) }
|
||||
if ($AppPool) { $forward += @('-AppPool', ('"' + $AppPool + '"')) }
|
||||
if ($SitePort) { $forward += @('-SitePort', $SitePort) }
|
||||
if ($Json) { $forward += '-Json' }
|
||||
return $forward
|
||||
}
|
||||
|
||||
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
|
||||
$native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
|
||||
if (Test-Path $native) {
|
||||
Start-Process -FilePath $native -ArgumentList (Get-ForwardedArgs) -Wait -NoNewWindow
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# --- elevation -------------------------------------------------------------
|
||||
# Reading IIS state needs Administrator: the WebAdministration module and the
|
||||
# IIS: drive both fail without it. Unelevated, this tool used to report
|
||||
# "IIS not available / application pool: not installed", which reads as "your
|
||||
# install is broken" when it actually means "I cannot see it". Relaunch elevated
|
||||
# instead of reporting a false state.
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
# 'open' just launches a browser. Prompting for administrator to do that trains
|
||||
# people to click through UAC, and the Start Menu shortcut uses this command.
|
||||
if ($Command -ne 'open' -and
|
||||
-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host ''
|
||||
Write-Host ' Administrator rights are needed to read IIS state.' -ForegroundColor Yellow
|
||||
Write-Host ' Re-launching elevated - approve the prompt.' -ForegroundColor Yellow
|
||||
$argList = @('-NoExit') + (Get-ForwardedArgs)
|
||||
try {
|
||||
Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null
|
||||
} catch {
|
||||
Write-Host ''
|
||||
Write-Host ' Elevation was declined.' -ForegroundColor Red
|
||||
Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red
|
||||
Read-Host ' Press Enter to close' | Out-Null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
# --- brand header ----------------------------------------------------------
|
||||
# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive
|
||||
# linework; rendered as block characters at console resolution it reads as noise,
|
||||
# which looks worse than no mark at all. A clean rule and correct wordmark says
|
||||
# "considered"; mushy art says the opposite.
|
||||
#
|
||||
# Box-drawing characters used here are all in code page 437 (the Windows console
|
||||
# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them
|
||||
# correctly rather than assuming the ANSI code page.
|
||||
function Show-Banner {
|
||||
$rule = ([string][char]0x2500) * 58
|
||||
Write-Host ''
|
||||
Write-Host ' GE AEROSPACE' -ForegroundColor Blue
|
||||
Write-Host (' ' + $rule) -ForegroundColor DarkGray
|
||||
Write-Host ' ShopDB-Flask' -ForegroundColor White
|
||||
Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||
function Head {
|
||||
param($m)
|
||||
Write-Host ''
|
||||
Write-Host (' ' + $m) -ForegroundColor Cyan
|
||||
Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Get-EnvValue {
|
||||
param([string] $Key)
|
||||
$envFile = Join-Path $AppRoot '.env'
|
||||
if (-not (Test-Path $envFile)) { return '' }
|
||||
$line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1
|
||||
if ($line) { return $line.Substring($Key.Length + 1) }
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-Deployment {
|
||||
<#
|
||||
Which way was this installed?
|
||||
|
||||
Method A: its own IIS site on $SitePort.
|
||||
Method B: an IIS Application under an existing site, at /<alias>, reached
|
||||
on that site's port. MOUNT_PATH in .env is what distinguishes
|
||||
them - it is the same value wsgi.py uses to mount the app.
|
||||
|
||||
Without this the console looked for a SITE that method B never creates and
|
||||
reported "web site: not installed" on a perfectly healthy server, then
|
||||
probed the wrong port and said it was not responding.
|
||||
#>
|
||||
$mount = (Get-EnvValue 'MOUNT_PATH').Trim()
|
||||
if (-not $mount) {
|
||||
return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort }
|
||||
}
|
||||
$alias = $mount.Trim('/')
|
||||
$port = 80
|
||||
$parent = 'Default Web Site'
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction Stop
|
||||
foreach ($site in (Get-Website)) {
|
||||
$app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue
|
||||
if ($app) {
|
||||
$parent = $site.Name
|
||||
$b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
|
||||
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] }
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
$base = "http://localhost:$port/$alias"
|
||||
if ($port -eq 80) { $base = "http://localhost/$alias" }
|
||||
return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port }
|
||||
}
|
||||
|
||||
function Get-DbParts {
|
||||
# Pull host/port/name/user out of DATABASE_URL without printing the password.
|
||||
$url = Get-EnvValue 'DATABASE_URL'
|
||||
if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
|
||||
# The password is PERCENT-ENCODED in DATABASE_URL (the installer applies
|
||||
# [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without
|
||||
# this, every command here fails to authenticate whenever the password
|
||||
# contains a space, @, %, ! or /, and the console reports the database as
|
||||
# unreachable on a server where the application is running perfectly.
|
||||
return @{ User = [uri]::UnescapeDataString($Matches[1])
|
||||
Pass = [uri]::UnescapeDataString($Matches[2])
|
||||
Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Find-MysqlClient {
|
||||
$candidates = @(
|
||||
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
|
||||
'C:\mysql56\bin\mysql.exe'
|
||||
) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue |
|
||||
Select-Object -ExpandProperty FullName)
|
||||
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } }
|
||||
return ''
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
function Show-Status {
|
||||
Head 'Status'
|
||||
|
||||
$deploy = Get-Deployment
|
||||
$siteState = 'not installed'
|
||||
$poolState = 'not installed'
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction Stop
|
||||
if ($deploy.Subpath) {
|
||||
# Method B: there is no site of our own - we are an Application.
|
||||
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
|
||||
if ($app) {
|
||||
$parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue
|
||||
$siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent,
|
||||
$(if ($parentSite) { $parentSite.State } else { 'unknown' }))
|
||||
}
|
||||
} else {
|
||||
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||
if ($site) { $siteState = $site.State }
|
||||
}
|
||||
if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State }
|
||||
} catch {
|
||||
# Should be unreachable now that the script self-elevates, but if IIS is
|
||||
# genuinely absent say THAT, rather than implying ShopDB is missing.
|
||||
# Name the real cause instead of implying ShopDB is missing.
|
||||
if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) {
|
||||
$siteState = 'IIS is not installed on this server'
|
||||
} else {
|
||||
$siteState = 'could not read IIS - ' + $_.Exception.Message
|
||||
}
|
||||
$poolState = $siteState
|
||||
}
|
||||
|
||||
$colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' }
|
||||
Say (" published as : {0}" -f $siteState) $colour
|
||||
Say (" application pool: {0}" -f $poolState) $colour
|
||||
Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' }))
|
||||
|
||||
# Does it actually answer? A "Started" pool proves nothing. Under method B
|
||||
# this must include the mount path, or it tests a URL that never existed.
|
||||
$url = $deploy.BaseUrl + '/'
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20
|
||||
Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green'
|
||||
} catch {
|
||||
Say ' responding : NO' 'Red'
|
||||
Say (" {0}" -f $_.Exception.Message) 'DarkGray'
|
||||
}
|
||||
|
||||
$db = Get-DbParts
|
||||
if ($db) {
|
||||
Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User)
|
||||
$mysql = Find-MysqlClient
|
||||
if ($mysql) {
|
||||
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
|
||||
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' }
|
||||
else { Say ' tables : could not connect' 'Red' }
|
||||
}
|
||||
} else { Say ' database : no .env found' 'Yellow' }
|
||||
|
||||
# Is anyone set up yet?
|
||||
try {
|
||||
$na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15
|
||||
if ($na.Content -match '"needsadmin"\s*:\s*true') {
|
||||
Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow'
|
||||
} else { Say ' first run : complete' 'Green' }
|
||||
} catch { }
|
||||
|
||||
Say ''
|
||||
if ($deploy.Subpath) {
|
||||
$shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias
|
||||
if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
|
||||
} else {
|
||||
$shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
|
||||
}
|
||||
Say (" open with : {0}" -f $shown) 'White'
|
||||
}
|
||||
|
||||
function Start-App {
|
||||
Head 'Starting'
|
||||
$deploy = Get-Deployment
|
||||
& $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
||||
if (-not $deploy.Subpath) {
|
||||
& $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
||||
} else {
|
||||
# Method B shares the parent site - starting or stopping THAT would take
|
||||
# every other application on this server with it.
|
||||
Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray'
|
||||
}
|
||||
Say ' started' 'Green'
|
||||
}
|
||||
|
||||
function Stop-App {
|
||||
Head 'Stopping'
|
||||
$deploy = Get-Deployment
|
||||
Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow'
|
||||
if (-not $deploy.Subpath) {
|
||||
& $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
||||
} else {
|
||||
Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray'
|
||||
}
|
||||
& $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
||||
Say ' stopped' 'Yellow'
|
||||
}
|
||||
|
||||
function Restart-App {
|
||||
Head 'Restarting'
|
||||
# Recycle rather than stop/start: it drains existing requests instead of
|
||||
# cutting them off, and it is what a config change actually needs.
|
||||
& $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
||||
Start-Sleep -Seconds 2
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
|
||||
Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green'
|
||||
} catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' }
|
||||
}
|
||||
|
||||
function Show-Logs {
|
||||
Head 'Recent logs'
|
||||
$appLog = Join-Path $AppRoot 'logs'
|
||||
if (Test-Path $appLog) {
|
||||
$newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($newest) {
|
||||
Say (" application: {0}" -f $newest.FullName) 'White'
|
||||
Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" }
|
||||
} else { Say ' no application log yet' }
|
||||
}
|
||||
$inst = 'C:\ProgramData\ShopDB-Flask\logs'
|
||||
if (Test-Path $inst) {
|
||||
$newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($newest) {
|
||||
Say ''
|
||||
Say (" install: {0}" -f $newest.FullName) 'White'
|
||||
Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Open-Site {
|
||||
# The installer records the address it actually published at. Prefer it: this
|
||||
# command runs unelevated, and .env is ACL'd, so working the address out from
|
||||
# MOUNT_PATH and IIS may not be possible from here.
|
||||
$recorded = Join-Path $AppRoot '.installed-url'
|
||||
if (Test-Path $recorded) {
|
||||
$u = (Get-Content $recorded -TotalCount 1).Trim()
|
||||
if ($u) { Start-Process $u; Say " opened $u" 'Green'; return }
|
||||
}
|
||||
$deploy = Get-Deployment
|
||||
if ($deploy.Subpath) {
|
||||
if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias }
|
||||
else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
|
||||
} else {
|
||||
$u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
|
||||
}
|
||||
Start-Process $u
|
||||
Say " opened $u" 'Green'
|
||||
}
|
||||
|
||||
function Backup-Db {
|
||||
param([string] $Dest)
|
||||
Head 'Database backup'
|
||||
$db = Get-DbParts
|
||||
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
|
||||
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
|
||||
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
|
||||
|
||||
$mysql = Find-MysqlClient
|
||||
if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
|
||||
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
|
||||
if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return }
|
||||
|
||||
$file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
Say (" writing {0}" -f $file)
|
||||
# Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike.
|
||||
# Do NOT pipe mysqldump through the PowerShell pipeline into Out-File.
|
||||
# PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the
|
||||
# console code page, producing a dump MySQL refuses to load and mangling any
|
||||
# non-ASCII data - discovered only when the backup is finally needed.
|
||||
# Redirect the process's stdout straight to the file instead.
|
||||
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
|
||||
'--single-transaction', '--routines', '--triggers', $db.Name)
|
||||
$quoted = $args | ForEach-Object {
|
||||
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
|
||||
}
|
||||
$err = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
|
||||
-RedirectStandardOutput $file -RedirectStandardError $err
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red'
|
||||
Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' }
|
||||
return
|
||||
}
|
||||
} finally { Remove-Item $err -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
# Verify it is complete rather than merely present.
|
||||
$tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue)
|
||||
if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) {
|
||||
Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green'
|
||||
|
||||
# The dump is NOT everything. Uploaded branding, map blueprints and
|
||||
# generated files live in instance\ on disk, not in the database, so a
|
||||
# restore from the .sql alone comes back with no floor map. Saying "all of
|
||||
# your asset data" was true and misleading at the same time.
|
||||
$instance = Join-Path $AppRoot 'instance'
|
||||
if (Test-Path $instance) {
|
||||
$zip = [System.IO.Path]::ChangeExtension($file, $null) + 'instance.zip'
|
||||
try {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
[System.IO.Compression.ZipFile]::CreateFromDirectory($instance, $zip)
|
||||
Say (" uploaded files: {0:N1} MB -> {1}" -f ((Get-Item $zip).Length / 1MB), (Split-Path $zip -Leaf)) 'Green'
|
||||
} catch {
|
||||
Say (" could not archive instance\: {0}" -f $_.Exception.Message) 'Yellow'
|
||||
Say ' copy it by hand - it holds branding and floor-map images' 'Yellow'
|
||||
}
|
||||
}
|
||||
|
||||
Say ''
|
||||
Say ' Store BOTH files off this server. The .sql holds the records; the' 'Yellow'
|
||||
Say ' .zip holds uploaded branding and floor-map images, which the' 'Yellow'
|
||||
Say ' database does not. A restore needs both.' 'Yellow'
|
||||
Say ''
|
||||
Say ' Contains user password hashes - treat it as sensitive.' 'Yellow'
|
||||
} else {
|
||||
Say ' backup is empty or truncated - do NOT rely on it' 'Red'
|
||||
Remove-Item $file -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CheckState {
|
||||
<#
|
||||
Everything an outside reader needs to reason about this server, gathered
|
||||
without changing anything. Secrets are NEVER included: the database password
|
||||
lives in DATABASE_URL and this reports the host, port, name and user only.
|
||||
#>
|
||||
$deploy = Get-Deployment
|
||||
$db = Get-DbParts
|
||||
$state = [ordered]@{
|
||||
collected = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
computername = $env:COMPUTERNAME
|
||||
approot = $AppRoot
|
||||
installed = (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py'))
|
||||
version = ''
|
||||
publishedas = if ($deploy.Subpath) { "subpath /$($deploy.Alias) under '$($deploy.Parent)'" } else { "own site '$SiteName' on port $SitePort" }
|
||||
baseurl = $deploy.BaseUrl
|
||||
apppool = 'unknown'
|
||||
poolstate = 'unknown'
|
||||
sitestate = 'unknown'
|
||||
responding = $false
|
||||
httpstatus = 0
|
||||
database = $null
|
||||
pythonversion = ''
|
||||
plugins = @()
|
||||
sbomcomponents = 0
|
||||
errors = @()
|
||||
}
|
||||
foreach ($pair in @(@('version', '.installed-version'))) {
|
||||
$f = Join-Path $AppRoot $pair[1]
|
||||
if (Test-Path $f) { $state[$pair[0]] = (Get-Content $f -TotalCount 1).Trim() }
|
||||
}
|
||||
$state.apppool = $AppPool
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction Stop
|
||||
if (Test-Path "IIS:\AppPools\$AppPool") { $state.poolstate = (Get-Item "IIS:\AppPools\$AppPool").State.ToString() }
|
||||
if ($deploy.Subpath) {
|
||||
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
|
||||
$state.sitestate = if ($app) { 'application present' } else { 'application MISSING' }
|
||||
} else {
|
||||
$s = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||
$state.sitestate = if ($s) { $s.State.ToString() } else { 'site MISSING' }
|
||||
}
|
||||
} catch { $state.errors += "IIS: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/') -UseBasicParsing -TimeoutSec 20
|
||||
$state.responding = $true
|
||||
$state.httpstatus = [int] $r.StatusCode
|
||||
} catch { $state.errors += "HTTP: $($_.Exception.Message)" }
|
||||
|
||||
if ($db) {
|
||||
$state.database = [ordered]@{ host = $db.Host; port = $db.Port; name = $db.Name; user = $db.User; reachable = $false }
|
||||
$mysql = Find-MysqlClient
|
||||
if ($mysql) {
|
||||
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
|
||||
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { $state.database.reachable = $true; $state.database.tables = [int] $n }
|
||||
} else { $state.errors += 'no mysql client found, database not probed' }
|
||||
} else { $state.errors += 'no .env found' }
|
||||
|
||||
$py = Join-Path $AppRoot 'venv\Scripts\python.exe'
|
||||
if (Test-Path $py) { $state.pythonversion = (& $py -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null | Select-Object -First 1) }
|
||||
|
||||
$dir = Join-Path $AppRoot 'plugins'
|
||||
if (Test-Path $dir) {
|
||||
$state.plugins = @(Get-ChildItem $dir -Directory -EA SilentlyContinue |
|
||||
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
|
||||
Select-Object -ExpandProperty Name)
|
||||
}
|
||||
$sbom = Join-Path $AppRoot 'sbom.cdx.json'
|
||||
if (Test-Path $sbom) {
|
||||
try { $state.sbomcomponents = (Get-Content $sbom -Raw | ConvertFrom-Json).components.Count } catch { }
|
||||
}
|
||||
return $state
|
||||
}
|
||||
|
||||
function Invoke-Check {
|
||||
if ($Json) {
|
||||
# ONLY the JSON goes to stdout, so it can be redirected to a file or piped
|
||||
# without a banner in the middle of the document.
|
||||
Get-CheckState | ConvertTo-Json -Depth 6
|
||||
return
|
||||
}
|
||||
|
||||
Head 'Health check'
|
||||
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
|
||||
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return }
|
||||
Push-Location $AppRoot
|
||||
$env:FLASK_APP = 'shopdb'
|
||||
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
|
||||
finally { Pop-Location }
|
||||
Say ''
|
||||
Say ' For help from an AI assistant, paste the output of:' 'DarkGray'
|
||||
Say ' shopdb-admin.ps1 check -Json' 'White'
|
||||
}
|
||||
|
||||
function Show-Sessions {
|
||||
Head 'Worker processes'
|
||||
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
|
||||
if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' }
|
||||
else {
|
||||
foreach ($p in $w) {
|
||||
Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId,
|
||||
($p.WorkingSetSize/1MB), $p.CreationDate)
|
||||
}
|
||||
}
|
||||
$py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -like "*$AppRoot*" }
|
||||
if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } }
|
||||
}
|
||||
|
||||
|
||||
# Set by every Invoke-Flask call. Callers MUST test this rather than
|
||||
# $LASTEXITCODE: when flask.exe is missing no native command runs at all, so
|
||||
# $LASTEXITCODE keeps whatever value it had from something earlier - which reads
|
||||
# as success and made a command that did nothing report that it had worked.
|
||||
$script:LastFlaskExit = 0
|
||||
|
||||
function Invoke-Flask {
|
||||
param([string[]] $Arguments)
|
||||
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
|
||||
if (-not (Test-Path $flask)) {
|
||||
Say ' application not installed' 'Red'
|
||||
$script:LastFlaskExit = 127
|
||||
return $null
|
||||
}
|
||||
Push-Location $AppRoot
|
||||
$env:FLASK_APP = 'shopdb'
|
||||
# The app logs plugin startup to STDERR even on success; with EAP=Stop that
|
||||
# becomes a terminating error and a healthy command looks like a failure.
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
& $flask @Arguments 2>&1
|
||||
$script:LastFlaskExit = $LASTEXITCODE
|
||||
}
|
||||
finally { $ErrorActionPreference = $prev; Pop-Location }
|
||||
}
|
||||
|
||||
function Show-Plugins {
|
||||
Head 'Plugins'
|
||||
Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" }
|
||||
|
||||
# What is on disk but NOT installed can still be added here. What is absent
|
||||
# from disk cannot - see the note below.
|
||||
$dir = Join-Path $AppRoot 'plugins'
|
||||
if (Test-Path $dir) {
|
||||
$onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue |
|
||||
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
|
||||
Select-Object -ExpandProperty Name) -join ', '
|
||||
Say ''
|
||||
Say (" shipped in this build : {0}" -f $onDisk) 'White'
|
||||
}
|
||||
Say ''
|
||||
Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path <name>' 'White'
|
||||
Say ''
|
||||
Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray'
|
||||
Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray'
|
||||
Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray'
|
||||
}
|
||||
|
||||
function Add-Plugin {
|
||||
param([string] $Name)
|
||||
Head 'Add a plugin'
|
||||
if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path <plugin-name>' 'Yellow'; return }
|
||||
|
||||
$dir = Join-Path $AppRoot ('plugins\' + $Name)
|
||||
if (-not (Test-Path (Join-Path $dir 'manifest.json'))) {
|
||||
Say (" '{0}' is not present on this server." -f $Name) 'Red'
|
||||
Say ''
|
||||
Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow'
|
||||
Say ' requires a new installer built from an updated site profile - the' 'Yellow'
|
||||
Say ' code is not here to install.' 'Yellow'
|
||||
Say ''
|
||||
Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White'
|
||||
return
|
||||
}
|
||||
|
||||
# apply-profile, not plugin install. Five plugins ship default_enabled=false,
|
||||
# so `install` alone left them installed-but-disabled: the command printed a
|
||||
# green success line and the feature did not appear anywhere in the UI.
|
||||
# apply-profile installs AND enables, and pulls in the dependency closure.
|
||||
Say (" adding {0}..." -f $Name)
|
||||
$profilePath = Join-Path $AppRoot 'site-profile.json'
|
||||
$applied = $false
|
||||
if (Test-Path $profilePath) {
|
||||
try {
|
||||
$profile = Get-Content $profilePath -Raw | ConvertFrom-Json
|
||||
$wanted = @($profile.plugins)
|
||||
if ($wanted -notcontains $Name) { $wanted += $Name }
|
||||
$profile.plugins = $wanted
|
||||
$profile | ConvertTo-Json -Depth 6 | Set-Content -Path $profilePath -Encoding UTF8
|
||||
Invoke-Flask @('plugin','apply-profile',$profilePath) | ForEach-Object { Say " $_" }
|
||||
$applied = ($script:LastFlaskExit -eq 0)
|
||||
} catch { Say (" could not update site-profile.json: {0}" -f $_.Exception.Message) 'Yellow' }
|
||||
}
|
||||
if (-not $applied) {
|
||||
# No profile on disk, or apply-profile failed: fall back, but enable
|
||||
# explicitly so the outcome is the same either way.
|
||||
Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" }
|
||||
$installed = ($script:LastFlaskExit -eq 0)
|
||||
Invoke-Flask @('plugin','enable',$Name) | ForEach-Object { Say " $_" }
|
||||
$applied = $installed -and ($script:LastFlaskExit -eq 0)
|
||||
}
|
||||
if (-not $applied) {
|
||||
# Do NOT print the green line on a failure. It used to be unconditional,
|
||||
# so a failed add reported success and the operator went looking for a
|
||||
# feature that was never enabled.
|
||||
Say (" {0} was NOT added - see the output above" -f $Name) 'Red'
|
||||
return
|
||||
}
|
||||
|
||||
Say ' applying its database migrations...'
|
||||
Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" }
|
||||
if ($script:LastFlaskExit -ne 0) {
|
||||
Say ' migrations FAILED - the feature is installed but its tables are not' 'Red'
|
||||
Say ' do not use it until this is resolved; restore a backup if needed' 'Red'
|
||||
return
|
||||
}
|
||||
Say ' restarting so its routes register...'
|
||||
Restart-App
|
||||
Say (" {0} added" -f $Name) 'Green'
|
||||
}
|
||||
|
||||
function Invoke-Verify {
|
||||
<#
|
||||
Prove the installed dependencies are still the ones that shipped.
|
||||
|
||||
Two independent records, both written at install time:
|
||||
requirements.txt a sha256 for every wheel, checked by pip on install
|
||||
bundle-lock.json the exact third-party payload of the bundle
|
||||
|
||||
This re-checks what can still be checked on a live server. It reports; it
|
||||
changes nothing.
|
||||
#>
|
||||
Head 'Verify'
|
||||
|
||||
$lock = Join-Path $AppRoot 'bundle-lock.json'
|
||||
if (Test-Path $lock) {
|
||||
try {
|
||||
$l = Get-Content $lock -Raw | ConvertFrom-Json
|
||||
Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform)
|
||||
} catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' }
|
||||
} else {
|
||||
Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow'
|
||||
}
|
||||
|
||||
# The SBOM travels with the application, because a server on a vaulted
|
||||
# network cannot be scanned from anywhere else. When a CVE lands, this is
|
||||
# what answers "is that component here, and at what version" without needing
|
||||
# the build box, the internet, or anyone's memory.
|
||||
$sbom = Join-Path $AppRoot 'sbom.cdx.json'
|
||||
if (Test-Path $sbom) {
|
||||
try {
|
||||
$b = Get-Content $sbom -Raw | ConvertFrom-Json
|
||||
$shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count
|
||||
Say (" components : {0} ({1} shipped), CycloneDX {2}" -f `
|
||||
$b.components.Count, $shipped, $b.specVersion)
|
||||
Say (" bill of materials: {0}" -f $sbom) 'DarkGray'
|
||||
Say ' search it with : shopdb-admin.ps1 verify -Path <name>' 'DarkGray'
|
||||
} catch { Say ' sbom.cdx.json is present but unreadable' 'Yellow' }
|
||||
|
||||
# A named component turns this into the actual question being asked.
|
||||
# Guarded on $b: an unreadable SBOM leaves it unset, and querying it then
|
||||
# would report 'none', which reads as "you are not affected".
|
||||
if ($Path -and $b) {
|
||||
Say ''
|
||||
Say (" matches for '{0}':" -f $Path) 'Cyan'
|
||||
$hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') })
|
||||
if (-not $hits) { Say ' none - this server does not carry it' 'Green' }
|
||||
foreach ($h in $hits) {
|
||||
$tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' }
|
||||
Say (" {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) `
|
||||
$(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Say ' no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow'
|
||||
}
|
||||
|
||||
# pip's own audit. It re-reads the metadata of what is actually installed and
|
||||
# reports anything missing or version-inconsistent, which is the part that
|
||||
# can still drift after install - a hand-run `pip install` on the server.
|
||||
$py = Join-Path $AppRoot 'venv\Scripts\python.exe'
|
||||
if (-not (Test-Path $py)) { Say ' no venv - application not installed' 'Red'; return }
|
||||
|
||||
Say ''
|
||||
Say ' checking installed packages against requirements.txt...'
|
||||
$req = Join-Path $AppRoot 'requirements.txt'
|
||||
if (-not (Test-Path $req)) { Say ' requirements.txt is missing from the install' 'Red'; return }
|
||||
|
||||
$wanted = @{}
|
||||
foreach ($line in (Get-Content $req)) {
|
||||
if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
|
||||
}
|
||||
$frozen = @{}
|
||||
foreach ($line in (& $py -m pip freeze 2>$null)) {
|
||||
if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
|
||||
}
|
||||
|
||||
$bad = 0
|
||||
foreach ($name in ($wanted.Keys | Sort-Object)) {
|
||||
if (-not $frozen.ContainsKey($name)) {
|
||||
Say (" MISSING {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++
|
||||
} elseif ($frozen[$name] -ne $wanted[$name]) {
|
||||
Say (" DIFFERS {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++
|
||||
}
|
||||
}
|
||||
if ($bad -eq 0) {
|
||||
Say (" all {0} packages match" -f $wanted.Count) 'Green'
|
||||
} else {
|
||||
Say ''
|
||||
Say (" {0} package(s) do not match what shipped." -f $bad) 'Red'
|
||||
Say ' Something was installed or upgraded on this server by hand. Re-run the' 'Yellow'
|
||||
Say ' installer to put the shipped set back.' 'Yellow'
|
||||
}
|
||||
|
||||
& $py -m pip check 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
|
||||
}
|
||||
|
||||
function Show-Uninstall {
|
||||
Head 'Uninstall'
|
||||
Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
|
||||
Say ''
|
||||
Say ' That removes the web site, application pool, firewall rule and files.'
|
||||
Say ' It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow'
|
||||
Say ' Take a backup first: shopdb-admin.ps1 backup' 'Yellow'
|
||||
}
|
||||
|
||||
function Show-Menu {
|
||||
$first = $true
|
||||
while ($true) {
|
||||
if ($first) { Show-Banner; $first = $false }
|
||||
Show-Status
|
||||
Write-Host ''
|
||||
Write-Host ' 1 Restart the application 6 Back up the database' -ForegroundColor White
|
||||
Write-Host ' 2 Stop the application 7 Worker processes' -ForegroundColor White
|
||||
Write-Host ' 3 Start the application 8 Open in browser' -ForegroundColor White
|
||||
Write-Host ' 4 View recent logs 9 Plugins' -ForegroundColor White
|
||||
Write-Host ' 5 Health check V Verify this install' -ForegroundColor White
|
||||
Write-Host ' 0 Exit' -ForegroundColor White
|
||||
Write-Host ''
|
||||
$c = Read-Host ' Choose'
|
||||
switch ($c) {
|
||||
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
|
||||
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
|
||||
'7' { Show-Sessions } '8' { Open-Site }
|
||||
'v' { Invoke-Verify } 'V' { Invoke-Verify }
|
||||
'9' { Show-Plugins
|
||||
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
|
||||
if ($add) { Add-Plugin $add } }
|
||||
'0' { return }
|
||||
default { Say ' not a choice' 'Yellow' }
|
||||
}
|
||||
Write-Host ''
|
||||
Read-Host ' Press Enter to continue' | Out-Null
|
||||
Clear-Host
|
||||
Show-Banner
|
||||
}
|
||||
}
|
||||
|
||||
switch ($Command) {
|
||||
'status' { Show-Banner; Show-Status }
|
||||
'start' { Start-App }
|
||||
'stop' { Stop-App }
|
||||
'restart' { Restart-App }
|
||||
'logs' { Show-Logs }
|
||||
'open' { Open-Site }
|
||||
'backup' { Backup-Db $Path }
|
||||
'check' { Invoke-Check }
|
||||
'sessions' { Show-Sessions }
|
||||
'plugins' { Show-Plugins }
|
||||
'add-plugin'{ Add-Plugin $Path }
|
||||
'verify' { Invoke-Verify }
|
||||
'uninstall' { Show-Uninstall }
|
||||
default { Show-Menu }
|
||||
}
|
||||
2104
deploy/windows/installer/shopdb-install.ps1
Normal file
547
deploy/windows/installer/shopdb-preflight.ps1
Normal file
@@ -0,0 +1,547 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ShopDB-Flask installer - Stage 1: read-only preflight.
|
||||
|
||||
.DESCRIPTION
|
||||
Discovers everything the installer needs to know about this box and reports
|
||||
it. Makes NO changes: no installs, no config edits, no service restarts.
|
||||
Safe to run on a production server.
|
||||
|
||||
Written for stock Windows PowerShell 5.1 (Windows Server ships it). No
|
||||
pwsh-only syntax, no external modules, no network access.
|
||||
|
||||
.PARAMETER SitePort
|
||||
The port the ShopDB site will listen on. Default 8090 (the runbook's example;
|
||||
the classic ASP site keeps 8080).
|
||||
|
||||
.PARAMETER AppRoot
|
||||
Intended install directory. Default C:\shopdb-flask.
|
||||
|
||||
.PARAMETER Json
|
||||
Emit machine-readable JSON instead of the human report. Later installer
|
||||
stages consume this.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1
|
||||
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 -Json > preflight.json
|
||||
|
||||
.NOTES
|
||||
Exit 0 = no blocking problems. Exit 1 = at least one FAIL.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int] $SitePort = 8090,
|
||||
[string] $AppRoot = 'C:\shopdb-flask',
|
||||
# Needed so the port check can tell OUR site apart from a stranger's.
|
||||
[string] $SiteName = 'shopdb-flask',
|
||||
[switch] $Json,
|
||||
# Machine-readable output for a GUI caller: one record per line,
|
||||
# STATUS|AREA|CHECK|DETAIL|FIX
|
||||
# The console rendering below aligns columns with padding spaces, which only
|
||||
# works in a fixed-width font at console width. A GUI must do its own layout,
|
||||
# so give it DATA and let it decide - do not make it parse a formatted table.
|
||||
# (-Json exists too, but Inno's Pascal Script has no JSON parser.)
|
||||
[switch] $Delimited
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- result collection -------------------------------------------------------
|
||||
# Every check appends one record. Status is PASS / WARN / FAIL / INFO.
|
||||
$script:Results = New-Object System.Collections.ArrayList
|
||||
$script:IisPresent = $false
|
||||
|
||||
function Add-Result {
|
||||
param(
|
||||
[string] $Area,
|
||||
[string] $Check,
|
||||
[ValidateSet('PASS','WARN','FAIL','INFO','SKIP')] [string] $Status,
|
||||
[string] $Detail,
|
||||
[string] $Fix = ''
|
||||
)
|
||||
$null = $script:Results.Add([PSCustomObject]@{
|
||||
Area = $Area
|
||||
Check = $Check
|
||||
Status = $Status
|
||||
Detail = $Detail
|
||||
Fix = $Fix
|
||||
})
|
||||
}
|
||||
|
||||
# Wrap a check so one failure cannot abort the whole run. On an unfamiliar box
|
||||
# an unexpected exception is itself a finding, not a crash.
|
||||
function Invoke-Check {
|
||||
param([string] $Area, [string] $Check, [scriptblock] $Body)
|
||||
try { & $Body }
|
||||
catch {
|
||||
Add-Result $Area $Check 'WARN' "check could not run: $($_.Exception.Message)" `
|
||||
'Report this output; the installer needs to handle this box shape.'
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 1. Operator context
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'System' 'Elevation' {
|
||||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$adm = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if ($adm) { Add-Result 'System' 'Elevation' 'PASS' 'running as Administrator' }
|
||||
else {
|
||||
Add-Result 'System' 'Elevation' 'FAIL' 'not elevated' `
|
||||
'Re-run PowerShell as Administrator. IIS and service changes require it.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'System' 'Windows version' {
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$name = $os.Caption
|
||||
$ver = $os.Version
|
||||
# ProductType: 1 = workstation, 2 = domain controller, 3 = server
|
||||
$isServer = ($os.ProductType -ne 1)
|
||||
$detail = "$name (build $ver), $(if ($isServer) {'Server'} else {'Client'})"
|
||||
|
||||
$supported = $false
|
||||
if ($isServer -and [version]$ver -ge [version]'10.0.17763') { $supported = $true } # 2019+
|
||||
if (-not $isServer -and [version]$ver -ge [version]'10.0.19045') { $supported = $true } # Win10 22H2+
|
||||
|
||||
if ($supported) { Add-Result 'System' 'Windows version' 'PASS' $detail }
|
||||
else {
|
||||
Add-Result 'System' 'Windows version' 'FAIL' $detail `
|
||||
'Supported: Windows Server 2019/2022+, or Windows 10 22H2 / 11 Pro+.'
|
||||
}
|
||||
|
||||
# Client SKUs must be Pro/Enterprise/Education for IIS.
|
||||
if (-not $isServer -and $name -match 'Home') {
|
||||
Add-Result 'System' 'Windows edition' 'FAIL' 'Windows Home edition' `
|
||||
'IIS is not available on Home editions. Pro or higher is required.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'System' 'Architecture' {
|
||||
if ([Environment]::Is64BitOperatingSystem) {
|
||||
Add-Result 'System' 'Architecture' 'PASS' '64-bit'
|
||||
} else {
|
||||
Add-Result 'System' 'Architecture' 'FAIL' '32-bit' `
|
||||
'The bundled Python and wheels are 64-bit (win_amd64) only.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'System' 'PowerShell version' {
|
||||
$v = $PSVersionTable.PSVersion
|
||||
Add-Result 'System' 'PowerShell version' 'INFO' "$v"
|
||||
if ($v.Major -lt 5) {
|
||||
Add-Result 'System' 'PowerShell version' 'FAIL' "$v" `
|
||||
'PowerShell 5.1 or later is required.'
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 2. Disk and ports
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'Disk' 'Free space' {
|
||||
$drive = (Split-Path -Qualifier $AppRoot)
|
||||
$d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'"
|
||||
if ($null -eq $d) {
|
||||
Add-Result 'Disk' 'Free space' 'FAIL' "drive $drive not found" `
|
||||
"Choose an -AppRoot on an existing volume."
|
||||
return
|
||||
}
|
||||
$freeGB = [math]::Round($d.FreeSpace / 1GB, 1)
|
||||
$detail = "$freeGB GB free on $drive"
|
||||
if ($freeGB -ge 5) { Add-Result 'Disk' 'Free space' 'PASS' $detail }
|
||||
else { Add-Result 'Disk' 'Free space' 'FAIL' $detail 'At least 5 GB is required.' }
|
||||
}
|
||||
|
||||
Invoke-Check 'Disk' 'AppRoot' {
|
||||
if (Test-Path $AppRoot) {
|
||||
$existing = @(Get-ChildItem $AppRoot -Force -ErrorAction SilentlyContinue)
|
||||
if ($existing.Count -gt 0) {
|
||||
$hasEnv = Test-Path (Join-Path $AppRoot '.env')
|
||||
if ($hasEnv) {
|
||||
# An existing install is the NORMAL state for an upgrade. Reporting
|
||||
# it as a warning makes a routine update look like a problem.
|
||||
$ver = ''
|
||||
$vf = Join-Path $AppRoot '.installed-version'
|
||||
if (Test-Path $vf) { $ver = ' version ' + (Get-Content $vf -TotalCount 1).Trim() }
|
||||
Add-Result 'Disk' 'AppRoot' 'INFO' `
|
||||
"existing ShopDB-Flask install found$ver - it will be upgraded in place, and your settings and database are kept"
|
||||
} else {
|
||||
Add-Result 'Disk' 'AppRoot' 'WARN' "$AppRoot exists and is not empty" `
|
||||
'Confirm this directory is safe to install into.'
|
||||
}
|
||||
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot exists and is empty" }
|
||||
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot does not exist yet" }
|
||||
}
|
||||
|
||||
function Test-PortFree {
|
||||
param([int] $Port)
|
||||
# Get-NetTCPConnection is the reliable listener check on Server 2012R2+.
|
||||
try {
|
||||
$listening = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
|
||||
return ($listening.Count -eq 0)
|
||||
} catch {
|
||||
# Fall back to a bind attempt if the cmdlet is unavailable.
|
||||
try {
|
||||
$l = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $Port)
|
||||
$l.Start(); $l.Stop(); return $true
|
||||
} catch { return $false }
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'Network' 'Site port' {
|
||||
if (Test-PortFree $SitePort) {
|
||||
Add-Result 'Network' 'Site port' 'PASS' "TCP $SitePort is free"
|
||||
} else {
|
||||
$owner = ''
|
||||
try {
|
||||
$c = Get-NetTCPConnection -State Listen -LocalPort $SitePort -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { $owner = " (pid $($c.OwningProcess): $((Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName))" }
|
||||
} catch { }
|
||||
# Is the listener OUR OWN site? On a reinstall or upgrade the port is held
|
||||
# by the very application being upgraded, and blocking on that makes the
|
||||
# installer refuse to update anything it previously installed.
|
||||
$ours = $false
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||
if ($site) {
|
||||
foreach ($b in $site.bindings.Collection) {
|
||||
# ${} is required: "$SitePort:" parses as a DRIVE-qualified variable.
|
||||
if ($b.bindingInformation -match ":${SitePort}:") { $ours = $true }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if ($ours) {
|
||||
Add-Result 'Network' 'Site port' 'INFO' `
|
||||
"TCP $SitePort is used by the existing $SiteName site - this will be upgraded in place"
|
||||
} else {
|
||||
Add-Result 'Network' 'Site port' 'FAIL' "TCP $SitePort is in use$owner" `
|
||||
"Choose a different port with -SitePort, or stop the listener."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 3. IIS
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'IIS' 'Installed' {
|
||||
$svc = Get-Service -Name W3SVC -ErrorAction SilentlyContinue
|
||||
$script:IisPresent = ($null -ne $svc)
|
||||
if ($null -eq $svc) {
|
||||
Add-Result 'IIS' 'Installed' 'FAIL' 'W3SVC service not found' `
|
||||
'Install IIS. Server: Install-WindowsFeature Web-Server -IncludeManagementTools. Client: enable Internet Information Services in Windows Features.'
|
||||
return
|
||||
}
|
||||
Add-Result 'IIS' 'Installed' 'PASS' "W3SVC present, status $($svc.Status)"
|
||||
if ($svc.Status -ne 'Running') {
|
||||
Add-Result 'IIS' 'Running' 'WARN' "W3SVC is $($svc.Status)" 'Start-Service W3SVC'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'WebAdministration module' {
|
||||
$m = Get-Module -ListAvailable -Name WebAdministration
|
||||
if ($m) { Add-Result 'IIS' 'WebAdministration module' 'PASS' 'available' }
|
||||
else {
|
||||
Add-Result 'IIS' 'WebAdministration module' 'FAIL' 'not available' `
|
||||
'Install the IIS management tools (Web-Mgmt-Console / IIS Management Scripts and Tools).'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'HttpPlatformHandler' {
|
||||
# The handler registers itself as a global module. Check the module list.
|
||||
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
||||
if (-not (Test-Path $appcmd)) {
|
||||
Add-Result 'IIS' 'HttpPlatformHandler' 'SKIP' 'appcmd.exe not present (IIS not installed)' `
|
||||
'Re-run this preflight after installing IIS.'
|
||||
return
|
||||
}
|
||||
$modules = & $appcmd list module 2>$null
|
||||
if ($modules -match 'httpPlatformHandler') {
|
||||
Add-Result 'IIS' 'HttpPlatformHandler' 'PASS' 'installed'
|
||||
} else {
|
||||
Add-Result 'IIS' 'HttpPlatformHandler' 'FAIL' 'not installed' `
|
||||
'Install httpPlatformHandler_amd64.msi from the bundle. IIS cannot launch waitress without it.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'Locked config sections' {
|
||||
# The authoritative source is applicationHost.config. `appcmd list config
|
||||
# /section:X` prints the section CONTENTS, not its lock state, so grepping
|
||||
# that output silently reports every section as unlocked.
|
||||
$cfg = Join-Path $env:windir 'system32\inetsrv\config\applicationHost.config'
|
||||
if (-not (Test-Path $cfg)) {
|
||||
Add-Result 'IIS' 'Locked config sections' 'SKIP' 'applicationHost.config not found'
|
||||
return
|
||||
}
|
||||
foreach ($name in @('handlers','httpPlatform')) {
|
||||
$line = Select-String -Path $cfg -Pattern ('<section name="' + $name + '"') |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $line) {
|
||||
# httpPlatform is registered by the HttpPlatformHandler MSI. Before
|
||||
# that, `appcmd unlock config /section:system.webServer/httpPlatform`
|
||||
# fails with "Unknown config section".
|
||||
Add-Result 'IIS' "Section $name" 'SKIP' 'section not registered yet' `
|
||||
'Install HttpPlatformHandler FIRST; only then can this section be unlocked.'
|
||||
} elseif ($line.Line -match 'overrideModeDefault="Deny"') {
|
||||
Add-Result 'IIS' "Section $name" 'WARN' 'locked (overrideModeDefault="Deny")' `
|
||||
"Installer must run: appcmd unlock config /section:system.webServer/$name (else IIS 500.19)"
|
||||
} else {
|
||||
Add-Result 'IIS' "Section $name" 'PASS' 'not locked'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'URL Rewrite module' {
|
||||
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
||||
if (-not (Test-Path $appcmd)) {
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'SKIP' 'IIS not installed; cannot check'
|
||||
return
|
||||
}
|
||||
$modules = & $appcmd list module 2>$null
|
||||
if ($modules -match 'RewriteModule') {
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)'
|
||||
} else {
|
||||
# Not a FAIL: it is only needed for -ClientIpSource direct, and the
|
||||
# installer carries the MSI and installs it itself. Worth reporting
|
||||
# because without the rule IIS sends no X-Forwarded-For at all, so every
|
||||
# client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the
|
||||
# visitor-location lookup and per-host login rate limiting go quiet.
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
|
||||
'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'Existing sites' {
|
||||
if (-not $script:IisPresent) {
|
||||
Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed'
|
||||
return
|
||||
}
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction Stop
|
||||
$sites = @(Get-Website)
|
||||
if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return }
|
||||
$desc = ($sites | ForEach-Object {
|
||||
$b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ','
|
||||
"$($_.Name) [$($_.State)] $b"
|
||||
}) -join '; '
|
||||
Add-Result 'IIS' 'Existing sites' 'INFO' $desc
|
||||
# Adoption sites typically run the classic ASP shopdb here already.
|
||||
if ($desc -match '8080') {
|
||||
Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' `
|
||||
'Install ShopDB as a separate site on its own port; do not disturb this one.'
|
||||
}
|
||||
} catch {
|
||||
Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" ''
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 4. MySQL (detect BEFORE offering bundled vs existing)
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'MySQL' 'Service' {
|
||||
$svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' })
|
||||
if ($svcs.Count -eq 0) {
|
||||
Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' `
|
||||
'Bundled MySQL 8.0 is the appropriate choice on this box.'
|
||||
return
|
||||
}
|
||||
foreach ($s in $svcs) {
|
||||
Add-Result 'MySQL' 'Service' 'WARN' "$($s.Name) ($($s.DisplayName)) is $($s.Status)" `
|
||||
'MySQL already present. Default to the EXISTING-server option; installing bundled MySQL will collide on port 3306.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'MySQL' 'Port 3306' {
|
||||
if (Test-PortFree 3306) {
|
||||
Add-Result 'MySQL' 'Port 3306' 'INFO' 'nothing listening on 3306'
|
||||
} else {
|
||||
Add-Result 'MySQL' 'Port 3306' 'WARN' 'something is listening on 3306' `
|
||||
'Bundled MySQL cannot use the default port. Use the existing server, or pick another port.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'MySQL' 'Backup client' {
|
||||
# mysqldump is what takes the mandatory pre-upgrade backup. Without it every
|
||||
# upgrade skips the backup - and skips it AFTER the application pool has been
|
||||
# stopped and the tree replaced, so the site is down and there is nothing to
|
||||
# restore from. A site whose database is on another server typically has no
|
||||
# MySQL client installed here at all, which is exactly the case that needs it.
|
||||
$names = @('mysqldump.exe')
|
||||
$found = ''
|
||||
foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'),
|
||||
(Join-Path $AppRoot 'mysqlclient'),
|
||||
'C:\Program Files\MySQL', 'C:\mysql56\bin',
|
||||
'C:\Program Files (x86)\MySQL')) {
|
||||
if (-not (Test-Path $root)) { continue }
|
||||
$hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($hit) { $found = $hit.FullName; break }
|
||||
}
|
||||
if ($found) {
|
||||
Add-Result 'MySQL' 'Backup client' 'PASS' "mysqldump found ($found)"
|
||||
} else {
|
||||
Add-Result 'MySQL' 'Backup client' 'WARN' 'mysqldump not found on this server' `
|
||||
'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. Add mysqlclient\ to the installer bundle, or install the MySQL client on this server.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'MySQL' 'Version and config' {
|
||||
# Find mysqld.exe via the service binary path; read the version and locate my.ini.
|
||||
$svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.PathName -match 'mysqld' } | Select-Object -First 1
|
||||
if ($null -eq $svc) { return }
|
||||
|
||||
$path = $svc.PathName
|
||||
$exe = ''
|
||||
if ($path -match '"([^"]+mysqld[^"]*)"') { $exe = $matches[1] }
|
||||
elseif ($path -match '(\S+mysqld\S*)') { $exe = $matches[1] }
|
||||
|
||||
$ver = ''
|
||||
if ($exe -and (Test-Path $exe)) {
|
||||
try { $ver = (& $exe --version 2>$null | Out-String).Trim() } catch { }
|
||||
}
|
||||
if ($ver) { Add-Result 'MySQL' 'Version' 'INFO' $ver }
|
||||
|
||||
# my.ini path is passed as --defaults-file in the service command line.
|
||||
$ini = ''
|
||||
if ($path -match '--defaults-file="?([^"]+\.ini)"?') { $ini = $matches[1] }
|
||||
if ($ini -and (Test-Path $ini)) {
|
||||
Add-Result 'MySQL' 'Config file' 'INFO' $ini
|
||||
|
||||
# MySQL 5.6 needs three flags or `flask db upgrade` dies with error 1071.
|
||||
$is56 = ($ver -match '\b5\.6\.')
|
||||
if ($is56) {
|
||||
$content = Get-Content $ini -Raw
|
||||
$need = @('innodb_file_per_table','innodb_file_format','innodb_large_prefix')
|
||||
$missing = @()
|
||||
foreach ($k in $need) { if ($content -notmatch $k) { $missing += $k } }
|
||||
if ($missing.Count -eq 0) {
|
||||
# Present in the FILE is not the same as ACTIVE. MySQL must be
|
||||
# restarted for them to take effect, and the app's own
|
||||
# `flask db-utils preflight` queries the live server - trust that.
|
||||
Add-Result 'MySQL' '5.6 index flags' 'WARN' 'all three present in my.ini' `
|
||||
'Present in the file only. They take effect after a MySQL RESTART, which interrupts the classic ASP app. Confirm with SHOW VARIABLES or flask db-utils preflight.'
|
||||
} else {
|
||||
Add-Result 'MySQL' '5.6 index flags' 'FAIL' ("missing: " + ($missing -join ', ')) `
|
||||
"Add to [mysqld] in $ini and restart MySQL, or 'flask db upgrade' fails with error 1071. NOTE: restarting interrupts the classic ASP app."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 5. Python (detect, but the installer uses its OWN bundled interpreter)
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'Python' 'On PATH' {
|
||||
$cmd = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($null -eq $cmd) {
|
||||
Add-Result 'Python' 'On PATH' 'INFO' 'no python on PATH' `
|
||||
'Expected. The installer supplies its own interpreter.'
|
||||
return
|
||||
}
|
||||
$v = ''
|
||||
try { $v = (& $cmd.Source --version 2>&1 | Out-String).Trim() } catch { }
|
||||
$detail = "$v at $($cmd.Source)"
|
||||
|
||||
# A per-user install under %LOCALAPPDATA% is unreadable by the IIS app-pool
|
||||
# identity. That produces a 500 with an empty HttpPlatform log.
|
||||
if ($cmd.Source -like "$env:LOCALAPPDATA*") {
|
||||
Add-Result 'Python' 'On PATH' 'WARN' "$detail (PER-USER install)" `
|
||||
'The IIS app-pool identity cannot read %LOCALAPPDATA%. The installer must install Python for ALL USERS and use absolute paths.'
|
||||
} elseif ($cmd.Source -like '*WindowsApps*') {
|
||||
Add-Result 'Python' 'On PATH' 'WARN' "$detail (Microsoft Store)" `
|
||||
'Store Python misbehaves under service identities. The installer will not use it.'
|
||||
} else {
|
||||
Add-Result 'Python' 'On PATH' 'INFO' $detail `
|
||||
'Not used by the installer, but a manual `flask` command later would resolve to this interpreter.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'Python' 'Registered installs' {
|
||||
$found = @()
|
||||
foreach ($hive in @('HKLM:\SOFTWARE\Python\PythonCore','HKCU:\SOFTWARE\Python\PythonCore')) {
|
||||
if (Test-Path $hive) {
|
||||
foreach ($k in Get-ChildItem $hive -ErrorAction SilentlyContinue) {
|
||||
$ip = Join-Path $k.PSPath 'InstallPath'
|
||||
if (Test-Path $ip) {
|
||||
$loc = (Get-ItemProperty $ip -ErrorAction SilentlyContinue).'(default)'
|
||||
$scope = if ($hive -like 'HKLM*') { 'all-users' } else { 'per-user' }
|
||||
$found += "$($k.PSChildName) ($scope) $loc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($found.Count -eq 0) { Add-Result 'Python' 'Registered installs' 'INFO' 'none' }
|
||||
else { Add-Result 'Python' 'Registered installs' 'INFO' ($found -join '; ') }
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Report
|
||||
# =============================================================================
|
||||
|
||||
$fails = @($script:Results | Where-Object { $_.Status -eq 'FAIL' })
|
||||
$warns = @($script:Results | Where-Object { $_.Status -eq 'WARN' })
|
||||
$skips = @($script:Results | Where-Object { $_.Status -eq 'SKIP' })
|
||||
|
||||
if ($Delimited) {
|
||||
# Data only. No padding, no colour, no alignment - the caller lays it out.
|
||||
# Pipes are stripped from field values so the record can be split naively.
|
||||
foreach ($r in $script:Results) {
|
||||
$fix = ''
|
||||
if ($r.Fix) { $fix = $r.Fix }
|
||||
$fields = @($r.Status, $r.Area, $r.Check, $r.Detail, $fix) | ForEach-Object {
|
||||
([string]$_) -replace '\|', '/' -replace '\s*\r?\n\s*', ' '
|
||||
}
|
||||
Write-Output ($fields -join '|')
|
||||
}
|
||||
} elseif ($Json) {
|
||||
[PSCustomObject]@{
|
||||
Timestamp = (Get-Date).ToString('s')
|
||||
Computer = $env:COMPUTERNAME
|
||||
SitePort = $SitePort
|
||||
AppRoot = $AppRoot
|
||||
Failures = $fails.Count
|
||||
Warnings = $warns.Count
|
||||
Skipped = $skips.Count
|
||||
Results = $script:Results
|
||||
} | ConvertTo-Json -Depth 5
|
||||
} else {
|
||||
Write-Host ''
|
||||
Write-Host 'ShopDB-Flask preflight' -ForegroundColor Cyan
|
||||
Write-Host (" host {0} site port {1} approot {2}" -f $env:COMPUTERNAME, $SitePort, $AppRoot)
|
||||
Write-Host ''
|
||||
$area = ''
|
||||
foreach ($r in $script:Results) {
|
||||
if ($r.Area -ne $area) { $area = $r.Area; Write-Host "[$area]" -ForegroundColor White }
|
||||
$colour = 'Gray'
|
||||
if ($r.Status -eq 'PASS') { $colour = 'Green' }
|
||||
if ($r.Status -eq 'WARN') { $colour = 'Yellow' }
|
||||
if ($r.Status -eq 'FAIL') { $colour = 'Red' }
|
||||
if ($r.Status -eq 'SKIP') { $colour = 'DarkGray' }
|
||||
Write-Host (" {0,-5} {1,-28} {2}" -f $r.Status, $r.Check, $r.Detail) -ForegroundColor $colour
|
||||
if ($r.Fix -and $r.Status -ne 'PASS' -and $r.Status -ne 'INFO' -and $r.Status -ne 'SKIP') {
|
||||
Write-Host (" -> {0}" -f $r.Fix) -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
Write-Host ''
|
||||
if ($fails.Count -eq 0) {
|
||||
Write-Host "No blocking problems. $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "$($fails.Count) blocking problem(s), $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Red
|
||||
}
|
||||
if ($skips.Count -gt 0) {
|
||||
Write-Host " Skipped checks were NOT verified. Re-run once their prerequisite is installed." -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
if ($fails.Count -gt 0) { exit 1 } else { exit 0 }
|
||||
BIN
deploy/windows/installer/shopdb.ico
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
deploy/windows/installer/swatch-bad.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-ok.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-warn.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
170
deploy/windows/installer/verify_bundle_lock.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check a staged installer bundle against bundle-lock.json.
|
||||
|
||||
Prints one line per problem and exits non-zero if there are any. Exits 0 only
|
||||
when the bundle's third-party payload is EXACTLY what the lock describes: no
|
||||
missing file, no unexpected extra file, no changed content.
|
||||
|
||||
Why this exists alongside bundle-lock.ps1, which does the same job:
|
||||
|
||||
- bundle-lock.ps1 is canonical. It runs at INSTALL time on the target server,
|
||||
where PowerShell is the only thing guaranteed to be present - Python is not
|
||||
installed until stage 2, and verifying the payload after running part of it
|
||||
would defeat the purpose.
|
||||
- This file lets the Linux builder (build-installer.sh) do the same check
|
||||
without adding pwsh as a build dependency.
|
||||
|
||||
The two are kept honest by tests/test_bundle_lock.py, which runs BOTH against
|
||||
the same fixtures and fails if they disagree.
|
||||
|
||||
Usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Must match $script:BundlePayloads in bundle-lock.ps1.
|
||||
PAYLOADS = [
|
||||
('wheels', True, 'Python wheels for the offline install'),
|
||||
('python', True, 'the Python installer'),
|
||||
('httpplatformhandler', True, 'the IIS module that launches waitress'),
|
||||
('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'),
|
||||
('mysqlclient', False, 'mysql/mysqldump, for backups against a remote database'),
|
||||
('mysql', False, 'MySQL, for the bundled-database option'),
|
||||
]
|
||||
|
||||
|
||||
def digest(path):
|
||||
sha = hashlib.sha256()
|
||||
with open(path, 'rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b''):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def payload_files(directory):
|
||||
"""Every file under the directory, keyed by forward-slashed relative path."""
|
||||
found = {}
|
||||
if not os.path.isdir(directory):
|
||||
return found
|
||||
for root, _dirs, files in os.walk(directory):
|
||||
for name in files:
|
||||
full = os.path.join(root, name)
|
||||
rel = os.path.relpath(full, directory).replace(os.sep, '/')
|
||||
found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)}
|
||||
return found
|
||||
|
||||
|
||||
def normalize(name):
|
||||
"""PEP 427 wheel filename form: runs of non-alphanumerics become one _."""
|
||||
return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower()
|
||||
|
||||
|
||||
def requirement_pins(requirements_path):
|
||||
"""Every 'name==version' pinned in a lockfile, including marked-out ones.
|
||||
|
||||
Markers are deliberately IGNORED. A requirement guarded by
|
||||
sys_platform == 'win32' is exactly the case that must be present, because the
|
||||
target is Windows and the wheelhouse is usually assembled somewhere else.
|
||||
"""
|
||||
pins = {}
|
||||
with open(requirements_path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line)
|
||||
if match:
|
||||
pins[normalize(match.group(1))] = match.group(2)
|
||||
return pins
|
||||
|
||||
|
||||
def check_wheelhouse_covers_requirements(bundle_root):
|
||||
"""The lock records what IS in the wheelhouse, not what the app NEEDS.
|
||||
|
||||
Without this, an incomplete wheelhouse gets locked and blessed, and the
|
||||
install fails on an air-gapped server. That is not hypothetical: assembling
|
||||
the wheelhouse on Linux silently omits colorama, a win32-only dependency of
|
||||
click, because pip evaluates environment markers against the machine doing
|
||||
the downloading rather than the machine being targeted.
|
||||
"""
|
||||
wheels = os.path.join(bundle_root, 'wheels')
|
||||
requirements = os.path.join(bundle_root, 'app', 'requirements.txt')
|
||||
if not os.path.isdir(wheels) or not os.path.exists(requirements):
|
||||
return []
|
||||
have = os.listdir(wheels)
|
||||
problems = []
|
||||
for name, version in sorted(requirement_pins(requirements).items()):
|
||||
prefix = '%s-%s-' % (name, version)
|
||||
if not any(f.lower().startswith(prefix) for f in have):
|
||||
problems.append(
|
||||
'wheels/ has no wheel for %s==%s, which requirements.txt pins '
|
||||
'(a marked-out dependency still installs on Windows)' % (name, version))
|
||||
return problems
|
||||
|
||||
|
||||
def verify(bundle_root, lock):
|
||||
problems = []
|
||||
locked = lock.get('payloads')
|
||||
if not locked:
|
||||
return ['bundle-lock.json has no "payloads" section']
|
||||
|
||||
for name, required, what in PAYLOADS:
|
||||
directory = os.path.join(bundle_root, name)
|
||||
present = os.path.isdir(directory)
|
||||
if name not in locked:
|
||||
if present:
|
||||
problems.append(
|
||||
'%s/ is present but is not in bundle-lock.json - regenerate the lock' % name)
|
||||
elif required:
|
||||
problems.append(
|
||||
'%s/ is required but is in neither the bundle nor the lock' % name)
|
||||
continue
|
||||
if not present:
|
||||
if required or locked[name].get('required'):
|
||||
problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what))
|
||||
continue
|
||||
|
||||
expected = locked[name].get('files', {})
|
||||
actual = payload_files(directory)
|
||||
for rel, want in sorted(expected.items()):
|
||||
got = actual.get(rel)
|
||||
if got is None:
|
||||
problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel))
|
||||
elif got['sha256'] != want['sha256']:
|
||||
problems.append(
|
||||
'%s/%s does NOT match the lock (expected sha256 %s..., got %s...)'
|
||||
% (name, rel, want['sha256'][:12], got['sha256'][:12]))
|
||||
elif int(got['size']) != int(want['size']):
|
||||
# Impossible for a matching sha256, so the lock was hand-edited.
|
||||
problems.append(
|
||||
'%s/%s size disagrees with the lock - the lock has been edited by hand'
|
||||
% (name, rel))
|
||||
for rel in sorted(actual):
|
||||
if rel not in expected:
|
||||
problems.append(
|
||||
'%s/%s is in the bundle but NOT in the lock (unexpected extra file)'
|
||||
% (name, rel))
|
||||
problems.extend(check_wheelhouse_covers_requirements(bundle_root))
|
||||
return problems
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit('usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>')
|
||||
bundle_root, lock_path = sys.argv[1], sys.argv[2]
|
||||
if not os.path.exists(lock_path):
|
||||
print('no bundle-lock.json at %s' % lock_path)
|
||||
return 1
|
||||
with open(lock_path) as fh:
|
||||
lock = json.load(fh)
|
||||
problems = verify(bundle_root, lock)
|
||||
for problem in problems:
|
||||
print(problem)
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
BIN
deploy/windows/installer/wizard-image.bmp
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
deploy/windows/installer/wizard-image@125.bmp
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
deploy/windows/installer/wizard-image@250.bmp
Normal file
|
After Width: | Height: | Size: 869 KiB |
BIN
deploy/windows/installer/wizard-small.bmp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
deploy/windows/installer/wizard-small@125.bmp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
deploy/windows/installer/wizard-small@250.bmp
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -11,7 +11,7 @@
|
||||
- HttpPlatformHandler IIS module installed
|
||||
(https://www.iis.net/downloads/microsoft/httpplatformhandler)
|
||||
- URL Rewrite module installed (only for the optional X-Forwarded-For rule)
|
||||
- Python 3.12 + a venv at APP_ROOT\venv with requirements.txt + waitress
|
||||
- Python 3.14 + a venv at APP_ROOT\venv with requirements.txt + waitress
|
||||
- Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them
|
||||
OUT of this file. Lock .env ACLs to the app pool identity + admins.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<httpPlatform
|
||||
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
|
||||
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for wsgi:app"
|
||||
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for --trusted-proxy-count=1 wsgi:app"
|
||||
stdoutLogEnabled="true"
|
||||
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
|
||||
startupTimeLimit="120"
|
||||
@@ -49,16 +49,29 @@
|
||||
</httpPlatform>
|
||||
|
||||
<!--
|
||||
OPTIONAL: forward the real client IP so audit logs and the kiosk
|
||||
visitor-location feature (IP -> business unit) see the caller, not the
|
||||
loopback that HttpPlatformHandler connects from.
|
||||
Forward the real client IP, so the audit log, the kiosk visitor-location
|
||||
feature (IP -> business unit), the GE-Enforce IP allowlist and per-host
|
||||
login rate limiting all see the caller rather than the loopback address
|
||||
HttpPlatformHandler connects from.
|
||||
|
||||
This block is COMMENTED OUT by default because it needs the URL Rewrite
|
||||
module; with it uncommented but URL Rewrite not installed, IIS returns
|
||||
HTTP 500.19 ("configuration section not well-formed / cannot be read").
|
||||
Install URL Rewrite (https://www.iis.net/downloads/microsoft/url-rewrite)
|
||||
and then uncomment the <rewrite> block below to enable it.
|
||||
IIS does not set X-Forwarded-For on its own. Without the rule below there
|
||||
is no such header at all, and every client looks like 127.0.0.1 - so the
|
||||
allowlist and the visitor-location lookup silently stop working.
|
||||
|
||||
ONLY CORRECT WHEN IIS IS DIRECTLY EXPOSED. It overwrites the header with
|
||||
REMOTE_ADDR, which is what stops a client spoofing its own X-Forwarded-For.
|
||||
Behind a reverse proxy (ARR, a load balancer) REMOTE_ADDR is the PROXY, so
|
||||
this rule would destroy the real client IP - there, leave it disabled and
|
||||
let the proxy set the header.
|
||||
|
||||
It ships DISABLED because it needs the URL Rewrite module; enabled without
|
||||
it, IIS returns HTTP 500.19 ("configuration section not well-formed").
|
||||
|
||||
The installer handles both: -ClientIpSource direct installs URL Rewrite
|
||||
from the bundle and enables this; -ClientIpSource proxy leaves it alone.
|
||||
By hand: install URL Rewrite, then delete the two marker lines below.
|
||||
-->
|
||||
<!-- SHOPDB-CLIENTIP-BEGIN
|
||||
<rewrite>
|
||||
<allowedServerVariables>
|
||||
<add name="HTTP_X_FORWARDED_FOR" />
|
||||
@@ -73,7 +86,7 @@
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
-->
|
||||
SHOPDB-CLIENTIP-END -->
|
||||
|
||||
</system.webServer>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// admin into the /setup wizard. Defaults to "complete" so the wizard never
|
||||
// flashes before the real value loads.
|
||||
import { ref } from 'vue'
|
||||
import { settingsApi } from '../api'
|
||||
import { settingsApi, setupApi } from '../api'
|
||||
|
||||
export const setupComplete = ref(true)
|
||||
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
|
||||
@@ -26,3 +26,31 @@ export async function refreshSetupState() {
|
||||
loaded.value = true
|
||||
return setupComplete.value
|
||||
}
|
||||
|
||||
// First run, before any user exists. Defaults to false so the app never flashes
|
||||
// the create-admin screen at an established site while this loads.
|
||||
export const needsAdmin = ref(false)
|
||||
const adminLoaded = ref(false)
|
||||
|
||||
export function isNeedsAdminLoaded() {
|
||||
return adminLoaded.value
|
||||
}
|
||||
|
||||
export async function refreshNeedsAdmin() {
|
||||
try {
|
||||
const response = await setupApi.needsAdmin()
|
||||
needsAdmin.value = response.data?.data?.needsadmin === true
|
||||
} catch (err) {
|
||||
// Fail open: if we cannot tell, assume an admin exists rather than trap
|
||||
// everyone behind a create-admin screen they cannot complete.
|
||||
needsAdmin.value = false
|
||||
}
|
||||
adminLoaded.value = true
|
||||
return needsAdmin.value
|
||||
}
|
||||
|
||||
// Called after the first admin is created so the gate stops firing.
|
||||
export function clearNeedsAdmin() {
|
||||
needsAdmin.value = false
|
||||
adminLoaded.value = true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import AppLayout from '../views/AppLayout.vue'
|
||||
import SettingsLayout from '../views/settings/SettingsLayout.vue'
|
||||
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
|
||||
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
|
||||
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
|
||||
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { getFacilityName } from '../utils/siteSettings'
|
||||
@@ -196,6 +197,24 @@ router.afterEach(() => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// FIRST RUN: until an admin exists, nothing else in the app is useful. Without
|
||||
// this, a fresh instance serves an anonymous dashboard and the operator has no
|
||||
// indication that setup is unfinished (they must guess their way to /login).
|
||||
// Unauthenticated only, so it costs an established site nothing.
|
||||
if (!authStore.isAuthenticated && to.path !== '/login') {
|
||||
if (!isNeedsAdminLoaded()) {
|
||||
// Bounded like the plugin gate: a hung request must not hold navigation
|
||||
// open. refreshNeedsAdmin fails open on error.
|
||||
await Promise.race([
|
||||
refreshNeedsAdmin(),
|
||||
new Promise(resolve => setTimeout(resolve, 4000)),
|
||||
])
|
||||
}
|
||||
if (needsAdmin.value) {
|
||||
return next({ path: '/login', query: { firstrun: '1' } })
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
// Remember where they were headed so login can send them back there.
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
|
||||
@@ -51,6 +51,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { setupApi } from '../api'
|
||||
import { clearNeedsAdmin } from '../composables/setupState'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
import { withBase, stripBase } from '../utils/basePath'
|
||||
|
||||
@@ -102,6 +103,7 @@ async function handleCreateAdmin() {
|
||||
loading.value = true
|
||||
try {
|
||||
await setupApi.createAdmin({ username: username.value, email: email.value, password: password.value })
|
||||
clearNeedsAdmin() // an admin now exists; stop the first-run gate firing
|
||||
// Log straight in with the new credentials, then on to the setup wizard.
|
||||
const result = await authStore.login(username.value, password.value)
|
||||
if (result.success) router.push('/setup')
|
||||
|
||||
@@ -117,8 +117,9 @@
|
||||
|
||||
<!-- Starter data -->
|
||||
<div v-else-if="current.key === 'data'">
|
||||
<h2>Starter data</h2>
|
||||
<p class="hint">Seed the data a new site needs. Both are idempotent - safe to run more than once.</p>
|
||||
<h2>Reference data</h2>
|
||||
<p class="hint">Seed the lookup data a new site needs: statuses, types, permissions, and a short list of common vendors. Both are idempotent - safe to run more than once.</p>
|
||||
<p class="hint">This does not create any assets. The site starts empty and assets are loaded afterwards via the import API, so an empty dashboard here is expected.</p>
|
||||
<div class="starter-actions">
|
||||
<div>
|
||||
<button class="btn btn-secondary" :disabled="seeding" @click="seedReference">
|
||||
@@ -181,7 +182,7 @@ const steps = [
|
||||
{ key: 'site', label: 'Site' },
|
||||
{ key: 'plugins', label: 'Features' },
|
||||
{ key: 'map', label: 'Floor Map' },
|
||||
{ key: 'data', label: 'Starter Data' },
|
||||
{ key: 'data', label: 'Reference Data' },
|
||||
{ key: 'finish', label: 'Finish' },
|
||||
]
|
||||
const step = ref(0)
|
||||
|
||||
@@ -13,10 +13,10 @@ This guide documents the process for migrating data from the legacy VBScript Sho
|
||||
### New System (Flask)
|
||||
- Core `assets` table (unified asset registry)
|
||||
- Plugin-specific extension tables:
|
||||
- `equipment` (equipmenttypeid → equipmenttypes)
|
||||
- `computers` (computertypeid → computertypes)
|
||||
- `printers` (printertypeid → printertypes)
|
||||
- `network_device` (networkdevicetypeid → networkdevicetypes)
|
||||
- `equipment` (equipmenttypeid -> equipmenttypes)
|
||||
- `computers` (computertypeid -> computertypes)
|
||||
- `printers` (printertypeid -> printertypes)
|
||||
- `network_device` (networkdevicetypeid -> networkdevicetypes)
|
||||
|
||||
## Key Mappings
|
||||
|
||||
|
||||
@@ -4,27 +4,12 @@ from logging.config import fileConfig
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
|
||||
# Force every table the migrations create on MySQL to utf8mb4 + DYNAMIC row
|
||||
# format. Without this a fresh `flask db upgrade` inherits the server default
|
||||
# charset, so a box whose default is latin1 (common on older MySQL) silently
|
||||
# builds a latin1 schema that drifts from the utf8mb4 production target. The
|
||||
# DYNAMIC row format also keeps utf8mb4 indexes under the 767-byte prefix limit
|
||||
# on pre-5.7 InnoDB. Scoped to the mysql dialect so the SQLite test DB is
|
||||
# untouched.
|
||||
@compiles(CreateTable, "mysql")
|
||||
def _mysql_create_table_utf8mb4(element, compiler, **kw):
|
||||
sql = compiler.visit_create_table(element, **kw)
|
||||
if "CHARSET" not in sql.upper():
|
||||
sql = sql.rstrip().rstrip(";")
|
||||
sql += (
|
||||
" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
" COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC"
|
||||
)
|
||||
return sql
|
||||
# Registers the compiler hook that forces utf8mb4 + DYNAMIC on MySQL. Imported
|
||||
# for the side effect, and shared with the PLUGIN chains via
|
||||
# shopdb/plugins/alembic_template.py - it used to live here, so it covered core
|
||||
# only and plugin baselines inherited the server default charset.
|
||||
import shopdb.utils.mysql_charset # noqa: F401
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
||||
@@ -153,7 +153,7 @@ def resolve_employee_display_name(sso):
|
||||
# location not carried in the directory). Their name lives on the User
|
||||
# record; GE usernames are the SSO. Photo still falls back to the GE mark.
|
||||
try:
|
||||
from shopdb.core.models import User
|
||||
from shopdb.api import User
|
||||
user = User.query.filter_by(username=str(sso)).first()
|
||||
if user:
|
||||
name = f'{(user.firstname or "").strip()} {(user.lastname or "").strip()}'.strip()
|
||||
|
||||
@@ -52,12 +52,23 @@ def _trusted_client_ip():
|
||||
Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND
|
||||
to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as
|
||||
_client_ip does for rate-limiting) would let any caller send
|
||||
'X-Forwarded-For: <allowlisted-ip>' and bypass the token. remote_addr cannot
|
||||
be forged here - behind IIS the URL-Rewrite rule overwrites X-Forwarded-For
|
||||
with the real TCP peer and waitress (--trusted-proxy=127.0.0.1
|
||||
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it; a
|
||||
client hitting waitress directly is not a trusted proxy, so its remote_addr
|
||||
is its own real peer address. Either way remote_addr is the true client.
|
||||
'X-Forwarded-For: <allowlisted-ip>' and bypass the token.
|
||||
|
||||
DEPLOYMENT DEPENDENCY, not a property of this function. remote_addr is only
|
||||
trustworthy because of what sits in front:
|
||||
|
||||
- Behind IIS, the URL-Rewrite rule OVERWRITES X-Forwarded-For with the real
|
||||
TCP peer, and waitress (--trusted-proxy=127.0.0.1
|
||||
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it.
|
||||
- Remove that rule and IIS still forwards whatever X-Forwarded-For the
|
||||
CALLER sent. waitress trusts it because it comes from 127.0.0.1, so
|
||||
remote_addr becomes attacker-controlled and this allowlist is bypassable
|
||||
from anywhere on the network. It does NOT fail closed.
|
||||
- A client reaching waitress directly is not a trusted proxy, so there
|
||||
remote_addr is its own peer address.
|
||||
|
||||
The rule is the control. See the geenforce-api-cutover wiki page; the Windows
|
||||
installer enables it and verifies it is live at the end of an install.
|
||||
"""
|
||||
return request.remote_addr or ''
|
||||
|
||||
|
||||
12
requirements-dev.in
Normal file
@@ -0,0 +1,12 @@
|
||||
# Development and CI dependencies.
|
||||
# Production installs use requirements.txt only; these never ship to a site.
|
||||
#
|
||||
# Compile with:
|
||||
# uv pip compile requirements-dev.in -o requirements-dev.txt
|
||||
|
||||
-r requirements.in
|
||||
|
||||
# Testing
|
||||
pytest>=7.0
|
||||
pytest-flask>=1.2
|
||||
pytest-cov>=4.0
|
||||
886
requirements-dev.txt
Normal file
@@ -0,0 +1,886 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile requirements-dev.in -c requirements.txt --universal --generate-hashes -o requirements-dev.txt
|
||||
#
|
||||
# The -c constraint pins every shared dependency to the SAME version production
|
||||
# runs. Without it the two files were compiled at different times and drifted:
|
||||
# CI tested against alembic 1.18.5 while sites installed 1.18.4.
|
||||
alembic==1.18.4 \
|
||||
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
|
||||
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask-migrate
|
||||
blinker==1.9.0 \
|
||||
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask
|
||||
cachelib==0.13.0 \
|
||||
--hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \
|
||||
--hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask-caching
|
||||
certifi==2026.4.22 \
|
||||
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
|
||||
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# requests
|
||||
cffi==2.1.0 ; platform_python_implementation != 'PyPy' \
|
||||
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
|
||||
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
|
||||
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
|
||||
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
|
||||
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
|
||||
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
|
||||
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
|
||||
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
|
||||
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
|
||||
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
|
||||
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
|
||||
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
|
||||
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
|
||||
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
|
||||
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
|
||||
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
|
||||
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
|
||||
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
|
||||
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
|
||||
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
|
||||
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
|
||||
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
|
||||
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
|
||||
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
|
||||
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
|
||||
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
|
||||
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
|
||||
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
|
||||
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
|
||||
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
|
||||
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
|
||||
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
|
||||
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
|
||||
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
|
||||
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
|
||||
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
|
||||
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
|
||||
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
|
||||
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
|
||||
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
|
||||
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
|
||||
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
|
||||
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
|
||||
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
|
||||
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
|
||||
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
|
||||
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
|
||||
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
|
||||
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
|
||||
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
|
||||
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
|
||||
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
|
||||
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
|
||||
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
|
||||
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
|
||||
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
|
||||
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
|
||||
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
|
||||
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
|
||||
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
|
||||
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
|
||||
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
|
||||
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
|
||||
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
|
||||
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
|
||||
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
|
||||
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
|
||||
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
|
||||
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
|
||||
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
|
||||
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
|
||||
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
|
||||
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
|
||||
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
|
||||
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
|
||||
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
|
||||
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
|
||||
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
|
||||
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
|
||||
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
|
||||
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
|
||||
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
|
||||
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
|
||||
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
|
||||
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
|
||||
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
|
||||
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
|
||||
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
|
||||
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
|
||||
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
|
||||
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
|
||||
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
|
||||
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
|
||||
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
|
||||
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
|
||||
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
|
||||
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
|
||||
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
|
||||
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
|
||||
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# cryptography
|
||||
charset-normalizer==3.4.7 \
|
||||
--hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
|
||||
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
|
||||
--hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
|
||||
--hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
|
||||
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
|
||||
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
|
||||
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
|
||||
--hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
|
||||
--hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
|
||||
--hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
|
||||
--hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
|
||||
--hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
|
||||
--hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
|
||||
--hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
|
||||
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
|
||||
--hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
|
||||
--hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
|
||||
--hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
|
||||
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
|
||||
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
|
||||
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
|
||||
--hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
|
||||
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
|
||||
--hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
|
||||
--hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
|
||||
--hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
|
||||
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
|
||||
--hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
|
||||
--hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
|
||||
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
|
||||
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
|
||||
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
|
||||
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
|
||||
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
|
||||
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
|
||||
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
|
||||
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
|
||||
--hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
|
||||
--hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
|
||||
--hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
|
||||
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
|
||||
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
|
||||
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
|
||||
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
|
||||
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
|
||||
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
|
||||
--hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
|
||||
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
|
||||
--hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
|
||||
--hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
|
||||
--hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
|
||||
--hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
|
||||
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
|
||||
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
|
||||
--hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
|
||||
--hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
|
||||
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
|
||||
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
|
||||
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
|
||||
--hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
|
||||
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
|
||||
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
|
||||
--hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
|
||||
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
|
||||
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
|
||||
--hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
|
||||
--hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
|
||||
--hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
|
||||
--hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
|
||||
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
|
||||
--hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
|
||||
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
|
||||
--hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
|
||||
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
|
||||
--hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
|
||||
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
|
||||
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
|
||||
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
|
||||
--hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
|
||||
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
|
||||
--hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
|
||||
--hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
|
||||
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
|
||||
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
|
||||
--hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
|
||||
--hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
|
||||
--hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
|
||||
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
|
||||
--hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
|
||||
--hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
|
||||
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
|
||||
--hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
|
||||
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
|
||||
--hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
|
||||
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
|
||||
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
|
||||
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
|
||||
--hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
|
||||
--hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
|
||||
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
|
||||
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
|
||||
--hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
|
||||
--hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
|
||||
--hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
|
||||
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
|
||||
--hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
|
||||
--hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
|
||||
--hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
|
||||
--hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
|
||||
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
|
||||
--hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
|
||||
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
|
||||
--hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
|
||||
--hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
|
||||
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
|
||||
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
|
||||
--hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
|
||||
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
|
||||
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
|
||||
--hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
|
||||
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
|
||||
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
|
||||
--hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
|
||||
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
|
||||
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
|
||||
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
|
||||
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
|
||||
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
|
||||
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# requests
|
||||
click==8.3.3 \
|
||||
--hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \
|
||||
--hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
# flask
|
||||
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# click
|
||||
# pytest
|
||||
coverage==7.15.3 \
|
||||
--hash=sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93 \
|
||||
--hash=sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77 \
|
||||
--hash=sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767 \
|
||||
--hash=sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de \
|
||||
--hash=sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2 \
|
||||
--hash=sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0 \
|
||||
--hash=sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d \
|
||||
--hash=sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4 \
|
||||
--hash=sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d \
|
||||
--hash=sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3 \
|
||||
--hash=sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049 \
|
||||
--hash=sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0 \
|
||||
--hash=sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b \
|
||||
--hash=sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d \
|
||||
--hash=sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2 \
|
||||
--hash=sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446 \
|
||||
--hash=sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9 \
|
||||
--hash=sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a \
|
||||
--hash=sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74 \
|
||||
--hash=sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949 \
|
||||
--hash=sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0 \
|
||||
--hash=sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa \
|
||||
--hash=sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8 \
|
||||
--hash=sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764 \
|
||||
--hash=sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743 \
|
||||
--hash=sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646 \
|
||||
--hash=sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91 \
|
||||
--hash=sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea \
|
||||
--hash=sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f \
|
||||
--hash=sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef \
|
||||
--hash=sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e \
|
||||
--hash=sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c \
|
||||
--hash=sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3 \
|
||||
--hash=sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb \
|
||||
--hash=sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9 \
|
||||
--hash=sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd \
|
||||
--hash=sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e \
|
||||
--hash=sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088 \
|
||||
--hash=sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42 \
|
||||
--hash=sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2 \
|
||||
--hash=sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95 \
|
||||
--hash=sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715 \
|
||||
--hash=sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156 \
|
||||
--hash=sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926 \
|
||||
--hash=sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c \
|
||||
--hash=sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11 \
|
||||
--hash=sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8 \
|
||||
--hash=sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f \
|
||||
--hash=sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60 \
|
||||
--hash=sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15 \
|
||||
--hash=sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f \
|
||||
--hash=sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779 \
|
||||
--hash=sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30 \
|
||||
--hash=sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040 \
|
||||
--hash=sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3 \
|
||||
--hash=sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57 \
|
||||
--hash=sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7 \
|
||||
--hash=sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10 \
|
||||
--hash=sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c \
|
||||
--hash=sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866 \
|
||||
--hash=sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef \
|
||||
--hash=sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd \
|
||||
--hash=sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13 \
|
||||
--hash=sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851 \
|
||||
--hash=sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d \
|
||||
--hash=sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f \
|
||||
--hash=sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21 \
|
||||
--hash=sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871 \
|
||||
--hash=sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c \
|
||||
--hash=sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965 \
|
||||
--hash=sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c \
|
||||
--hash=sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a \
|
||||
--hash=sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f \
|
||||
--hash=sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c \
|
||||
--hash=sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43 \
|
||||
--hash=sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058 \
|
||||
--hash=sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e \
|
||||
--hash=sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e \
|
||||
--hash=sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e \
|
||||
--hash=sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e \
|
||||
--hash=sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005 \
|
||||
--hash=sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2 \
|
||||
--hash=sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3 \
|
||||
--hash=sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6 \
|
||||
--hash=sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c \
|
||||
--hash=sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731 \
|
||||
--hash=sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b \
|
||||
--hash=sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f \
|
||||
--hash=sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a \
|
||||
--hash=sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235 \
|
||||
--hash=sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429
|
||||
# via pytest-cov
|
||||
cryptography==50.0.0 \
|
||||
--hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \
|
||||
--hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \
|
||||
--hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \
|
||||
--hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \
|
||||
--hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \
|
||||
--hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \
|
||||
--hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \
|
||||
--hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \
|
||||
--hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \
|
||||
--hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \
|
||||
--hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \
|
||||
--hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \
|
||||
--hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \
|
||||
--hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \
|
||||
--hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \
|
||||
--hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \
|
||||
--hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \
|
||||
--hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \
|
||||
--hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \
|
||||
--hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \
|
||||
--hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \
|
||||
--hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \
|
||||
--hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \
|
||||
--hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \
|
||||
--hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \
|
||||
--hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \
|
||||
--hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \
|
||||
--hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \
|
||||
--hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \
|
||||
--hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \
|
||||
--hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \
|
||||
--hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \
|
||||
--hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \
|
||||
--hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \
|
||||
--hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \
|
||||
--hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \
|
||||
--hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \
|
||||
--hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \
|
||||
--hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \
|
||||
--hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \
|
||||
--hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \
|
||||
--hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \
|
||||
--hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \
|
||||
--hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \
|
||||
--hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \
|
||||
--hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
dnspython==2.8.0 \
|
||||
--hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
|
||||
--hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# email-validator
|
||||
email-validator==2.3.0 \
|
||||
--hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \
|
||||
--hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
flask==3.1.3 \
|
||||
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
|
||||
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
# flask-caching
|
||||
# flask-cors
|
||||
# flask-jwt-extended
|
||||
# flask-marshmallow
|
||||
# flask-migrate
|
||||
# flask-sqlalchemy
|
||||
# pytest-flask
|
||||
flask-caching==2.4.0 \
|
||||
--hash=sha256:a7f14e43617cd0612a57bec2f80516d6d2ec888bfc50a807b1e4e41ca345a3b5 \
|
||||
--hash=sha256:d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
flask-cors==6.0.2 \
|
||||
--hash=sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423 \
|
||||
--hash=sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
flask-jwt-extended==4.7.3 \
|
||||
--hash=sha256:905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb \
|
||||
--hash=sha256:a8318a3d983d1f360724b901889f1947ffee418e2a3950b48e99c70923b6615e
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
flask-marshmallow==1.5.0 \
|
||||
--hash=sha256:7c06b56e41647eccdb3cd57c25b109f19191b4c62509362bd64920cdf601a066 \
|
||||
--hash=sha256:99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
flask-migrate==4.1.0 \
|
||||
--hash=sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d \
|
||||
--hash=sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
flask-sqlalchemy==3.1.1 \
|
||||
--hash=sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0 \
|
||||
--hash=sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
# flask-migrate
|
||||
greenlet==3.5.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' \
|
||||
--hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \
|
||||
--hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \
|
||||
--hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \
|
||||
--hash=sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce \
|
||||
--hash=sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2 \
|
||||
--hash=sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588 \
|
||||
--hash=sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13 \
|
||||
--hash=sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e \
|
||||
--hash=sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a \
|
||||
--hash=sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3 \
|
||||
--hash=sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b \
|
||||
--hash=sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 \
|
||||
--hash=sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628 \
|
||||
--hash=sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136 \
|
||||
--hash=sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b \
|
||||
--hash=sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d \
|
||||
--hash=sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2 \
|
||||
--hash=sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb \
|
||||
--hash=sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd \
|
||||
--hash=sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b \
|
||||
--hash=sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1 \
|
||||
--hash=sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16 \
|
||||
--hash=sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d \
|
||||
--hash=sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106 \
|
||||
--hash=sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba \
|
||||
--hash=sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c \
|
||||
--hash=sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc \
|
||||
--hash=sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7 \
|
||||
--hash=sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339 \
|
||||
--hash=sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b \
|
||||
--hash=sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae \
|
||||
--hash=sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8 \
|
||||
--hash=sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2 \
|
||||
--hash=sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5 \
|
||||
--hash=sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf \
|
||||
--hash=sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f \
|
||||
--hash=sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f \
|
||||
--hash=sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2 \
|
||||
--hash=sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb \
|
||||
--hash=sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082 \
|
||||
--hash=sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 \
|
||||
--hash=sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0 \
|
||||
--hash=sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c \
|
||||
--hash=sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853 \
|
||||
--hash=sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988 \
|
||||
--hash=sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3 \
|
||||
--hash=sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858 \
|
||||
--hash=sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37 \
|
||||
--hash=sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977 \
|
||||
--hash=sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4 \
|
||||
--hash=sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 \
|
||||
--hash=sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86 \
|
||||
--hash=sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f \
|
||||
--hash=sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112 \
|
||||
--hash=sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e \
|
||||
--hash=sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2 \
|
||||
--hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \
|
||||
--hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \
|
||||
--hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# sqlalchemy
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# email-validator
|
||||
# requests
|
||||
iniconfig==2.3.0 \
|
||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
# via pytest
|
||||
itsdangerous==2.2.0 \
|
||||
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
|
||||
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask
|
||||
jinja2==3.1.6 \
|
||||
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
|
||||
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask
|
||||
mako==1.3.12 \
|
||||
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
|
||||
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# alembic
|
||||
markupsafe==3.0.3 \
|
||||
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
|
||||
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
|
||||
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
|
||||
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
|
||||
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
|
||||
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
|
||||
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
|
||||
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
|
||||
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
|
||||
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
|
||||
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
|
||||
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
|
||||
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
|
||||
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
|
||||
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
|
||||
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
|
||||
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
|
||||
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
|
||||
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
|
||||
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
|
||||
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
|
||||
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
|
||||
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
|
||||
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
|
||||
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
|
||||
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
|
||||
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
|
||||
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
|
||||
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
|
||||
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
|
||||
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
|
||||
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
|
||||
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
|
||||
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
|
||||
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
|
||||
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
|
||||
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
|
||||
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
|
||||
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
|
||||
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
|
||||
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
|
||||
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
|
||||
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
|
||||
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
|
||||
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
|
||||
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
|
||||
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
|
||||
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
|
||||
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
|
||||
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
|
||||
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
|
||||
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
|
||||
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
|
||||
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
|
||||
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
|
||||
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
|
||||
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
|
||||
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
|
||||
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
|
||||
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
|
||||
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
|
||||
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
|
||||
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
|
||||
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
|
||||
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
|
||||
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
|
||||
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
|
||||
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
|
||||
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
|
||||
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
|
||||
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
|
||||
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
|
||||
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
|
||||
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
|
||||
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
|
||||
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
|
||||
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
|
||||
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
|
||||
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
|
||||
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
|
||||
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
|
||||
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
|
||||
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
|
||||
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
|
||||
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
|
||||
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
|
||||
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
|
||||
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
|
||||
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask
|
||||
# jinja2
|
||||
# mako
|
||||
# werkzeug
|
||||
marshmallow==4.3.0 \
|
||||
--hash=sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46 \
|
||||
--hash=sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask-marshmallow
|
||||
# marshmallow-sqlalchemy
|
||||
marshmallow-sqlalchemy==1.5.0 \
|
||||
--hash=sha256:3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258 \
|
||||
--hash=sha256:e51192c204770645a2fab0d72f44f8789272eef75951f84b1608d6b4b0bfe0e6
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
mysql-connector-python==9.7.0 \
|
||||
--hash=sha256:079ce68d617250ef5cefffd5243056dc9f87c6034c804235fd6f45a0daf5a6ae \
|
||||
--hash=sha256:15106ed73d74487f86de6b1859ff7f362efca7c7f9c494497ccb7439d3139fe6 \
|
||||
--hash=sha256:229ffce2333b88b59f8f034881e12dc85b309615338be9f843ed63923db99d52 \
|
||||
--hash=sha256:236a4b8abfac8217517ce9e9edc735decf55282ada3890b3cd33770009f33d68 \
|
||||
--hash=sha256:272c4e8263f6a514e4ae65e9553ed214fc9d4cf1f0f6bedca30def20c2ae1d52 \
|
||||
--hash=sha256:2eae45230b5cbd783d68bdfe8b05ad9b4ebd06799f8d302a6169d7f025572baf \
|
||||
--hash=sha256:39bf31cdbe920eae08801eb829584dc27f70dadbdb3a5c3b78402f54cea87ab0 \
|
||||
--hash=sha256:45491d4ce56722cb335e6d0bde2d4f4a98b7073421bd02a04ad5e6220d69e499 \
|
||||
--hash=sha256:5492d57a6a0e5127a928290737fbb91b66b46d31dac8de3e7604e550bf3b3a6e \
|
||||
--hash=sha256:5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49 \
|
||||
--hash=sha256:6546e0b60c275409a5add9e3308c3897fcf478d1338cd845b1664c1a8946f72f \
|
||||
--hash=sha256:933887e71c871b6e9d8908459fe8303ebcf8feb5cc1e1c49caa6490e525cf78e \
|
||||
--hash=sha256:9549a2974353407427b574f005c92f03972e303182b4c3b4a272bf4ca10855ed \
|
||||
--hash=sha256:9bdfc2d4c4444cd1cc79cc6487c047b28fe2b26d0327b27eb9f5737bb553cb5c \
|
||||
--hash=sha256:9da73e212bb08e4df286506ffbda943063bbce448375a7db29748bb367f4e0af \
|
||||
--hash=sha256:9e17ceb79c1c4c137a5994f8ca8c6a1ce8ebf83eda3ccc21dd67f2c1398680f0 \
|
||||
--hash=sha256:a2f371ab69d65c61136c51ad7026017400166cef3c959cab7a9fb668c7acbfba \
|
||||
--hash=sha256:af80b1e7179d5c2d983cf62470ad9b134a7e9ef05cf31108ae587f15873530cc \
|
||||
--hash=sha256:b5cb8a3ba42b539f79cd13e4c8376d28506f3180f7079c9b04ea7bfd0424fb03 \
|
||||
--hash=sha256:b8d3d6f32b95ae1d0a2f29d1a2b4e628849bcf7e84a70dc56c85b4929361d86b \
|
||||
--hash=sha256:c51be697bfdfdf63bb71c5ecc51f7c6faf4aaa3d14a0136fa16e97cc37df1185 \
|
||||
--hash=sha256:d5924a76b530159c02f2fe8da4d3c6377ce1f5e195827e8ecbd36124673651d3 \
|
||||
--hash=sha256:daf70f7da64a2e7c17e6dfd1fc98d2deb653fd955d0bd0b1fef246356e682b0f \
|
||||
--hash=sha256:e3842ecd62391a28c1dda5eb817f40418715e68482a8c146b3c478ac8bb7a23f \
|
||||
--hash=sha256:ea4e05ab864ab8b0b5066d885d89ca51e5ba1320dba520c20a6f9388a8eca022 \
|
||||
--hash=sha256:ee90c5f44f706f012be17f03f6ad158ff96e7f2dcc077896fe4537d3d28b3cf4
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via pytest
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via
|
||||
# pytest
|
||||
# pytest-cov
|
||||
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# cffi
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via pytest
|
||||
pyjwt==2.12.1 \
|
||||
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
|
||||
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# flask-jwt-extended
|
||||
pymysql==1.1.3 \
|
||||
--hash=sha256:8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289 \
|
||||
--hash=sha256:e70ebf2047a4edf6138cf79c68ad418ef620af65900aa585c5e8bfc95044d43a
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
pytest==9.1.1 \
|
||||
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
|
||||
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
# via
|
||||
# -r requirements-dev.in
|
||||
# pytest-cov
|
||||
# pytest-flask
|
||||
pytest-cov==7.1.0 \
|
||||
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
|
||||
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
|
||||
# via -r requirements-dev.in
|
||||
pytest-flask==1.3.0 \
|
||||
--hash=sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e \
|
||||
--hash=sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253
|
||||
# via -r requirements-dev.in
|
||||
python-dotenv==1.2.2 \
|
||||
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
|
||||
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
requests==2.33.1 \
|
||||
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
|
||||
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
sqlalchemy==2.0.49 \
|
||||
--hash=sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72 \
|
||||
--hash=sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe \
|
||||
--hash=sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75 \
|
||||
--hash=sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5 \
|
||||
--hash=sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148 \
|
||||
--hash=sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7 \
|
||||
--hash=sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e \
|
||||
--hash=sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518 \
|
||||
--hash=sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7 \
|
||||
--hash=sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700 \
|
||||
--hash=sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717 \
|
||||
--hash=sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 \
|
||||
--hash=sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88 \
|
||||
--hash=sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f \
|
||||
--hash=sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f \
|
||||
--hash=sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08 \
|
||||
--hash=sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a \
|
||||
--hash=sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3 \
|
||||
--hash=sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b \
|
||||
--hash=sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536 \
|
||||
--hash=sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0 \
|
||||
--hash=sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b \
|
||||
--hash=sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a \
|
||||
--hash=sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3 \
|
||||
--hash=sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 \
|
||||
--hash=sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339 \
|
||||
--hash=sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158 \
|
||||
--hash=sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 \
|
||||
--hash=sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662 \
|
||||
--hash=sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1 \
|
||||
--hash=sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3 \
|
||||
--hash=sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 \
|
||||
--hash=sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01 \
|
||||
--hash=sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613 \
|
||||
--hash=sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a \
|
||||
--hash=sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0 \
|
||||
--hash=sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f \
|
||||
--hash=sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a \
|
||||
--hash=sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e \
|
||||
--hash=sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2 \
|
||||
--hash=sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af \
|
||||
--hash=sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014 \
|
||||
--hash=sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33 \
|
||||
--hash=sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61 \
|
||||
--hash=sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d \
|
||||
--hash=sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187 \
|
||||
--hash=sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401 \
|
||||
--hash=sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b \
|
||||
--hash=sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d \
|
||||
--hash=sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f \
|
||||
--hash=sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba \
|
||||
--hash=sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977 \
|
||||
--hash=sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a \
|
||||
--hash=sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe \
|
||||
--hash=sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b \
|
||||
--hash=sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f \
|
||||
--hash=sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1 \
|
||||
--hash=sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4 \
|
||||
--hash=sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d \
|
||||
--hash=sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120 \
|
||||
--hash=sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750 \
|
||||
--hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \
|
||||
--hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# alembic
|
||||
# flask-sqlalchemy
|
||||
# marshmallow-sqlalchemy
|
||||
tabulate==0.10.0 \
|
||||
--hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \
|
||||
--hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
typing-extensions==4.15.0 \
|
||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# alembic
|
||||
# sqlalchemy
|
||||
tzdata==2026.3 \
|
||||
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
|
||||
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
urllib3==2.7.0 \
|
||||
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
|
||||
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# requests
|
||||
waitress==3.0.2 \
|
||||
--hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \
|
||||
--hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
werkzeug==3.1.8 \
|
||||
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
|
||||
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# -r requirements.in
|
||||
# flask
|
||||
# flask-cors
|
||||
# flask-jwt-extended
|
||||
# pytest-flask
|
||||
@@ -24,10 +24,14 @@ requests>=2.31
|
||||
# Security
|
||||
werkzeug>=3.0
|
||||
|
||||
# Windows/IIS runtime
|
||||
# waitress is the WSGI server IIS launches via HttpPlatformHandler.
|
||||
# tzdata ships the IANA timezone database, which Windows does not provide;
|
||||
# without it the notifications plugin fails on zoneinfo lookups.
|
||||
# Both were previously hand-added to requirements.txt only, so a recompile
|
||||
# would have silently dropped them from every Windows install.
|
||||
waitress>=3.0
|
||||
tzdata
|
||||
|
||||
# Validation
|
||||
email-validator>=2.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.0
|
||||
pytest-flask>=1.2
|
||||
pytest-cov>=4.0
|
||||
|
||||
762
requirements.txt
@@ -1,26 +1,352 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile requirements.in -o requirements.txt
|
||||
alembic==1.18.4
|
||||
# via flask-migrate
|
||||
blinker==1.9.0
|
||||
# via flask
|
||||
cachelib==0.13.0
|
||||
# via flask-caching
|
||||
certifi==2026.4.22
|
||||
# via requests
|
||||
charset-normalizer==3.4.7
|
||||
# via requests
|
||||
click==8.3.3
|
||||
# uv pip compile requirements.in --universal --generate-hashes -o requirements.txt
|
||||
#
|
||||
# UNIVERSAL + HASHED on purpose:
|
||||
# --universal resolves for every platform, so ONE lockfile serves Linux (dev,
|
||||
# Docker, CI) and the Windows offline wheelhouse. A Linux-only
|
||||
# resolve silently omitted colorama, a win32-only dependency of
|
||||
# click, which then had no entry to match in hash-checking mode.
|
||||
# --generate-hashes puts pip into hash-checking mode automatically. A wheel
|
||||
# whose sha256 is not listed here is REFUSED at install time.
|
||||
# This pins the VERSION and proves the artifact is the one
|
||||
# upstream published; it does NOT pin which artifact of that
|
||||
# version is used, and it says nothing about extra wheels loose
|
||||
# in the wheelhouse - bundle-lock.json covers both of those.
|
||||
#
|
||||
# Regenerate with the command above. Do NOT hand-edit: waitress and tzdata were
|
||||
# hand-added here once and vanished on the next compile.
|
||||
alembic==1.18.4 \
|
||||
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
|
||||
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask-migrate
|
||||
blinker==1.9.0 \
|
||||
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
coverage==7.13.5
|
||||
# via pytest-cov
|
||||
dnspython==2.8.0
|
||||
# via email-validator
|
||||
email-validator==2.3.0
|
||||
cachelib==0.13.0 \
|
||||
--hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \
|
||||
--hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask-caching
|
||||
certifi==2026.4.22 \
|
||||
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
|
||||
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
|
||||
# via
|
||||
# -r requirements.in
|
||||
# requests
|
||||
cffi==2.1.0 \
|
||||
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
|
||||
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
|
||||
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
|
||||
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
|
||||
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
|
||||
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
|
||||
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
|
||||
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
|
||||
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
|
||||
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
|
||||
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
|
||||
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
|
||||
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
|
||||
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
|
||||
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
|
||||
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
|
||||
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
|
||||
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
|
||||
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
|
||||
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
|
||||
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
|
||||
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
|
||||
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
|
||||
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
|
||||
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
|
||||
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
|
||||
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
|
||||
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
|
||||
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
|
||||
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
|
||||
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
|
||||
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
|
||||
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
|
||||
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
|
||||
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
|
||||
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
|
||||
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
|
||||
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
|
||||
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
|
||||
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
|
||||
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
|
||||
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
|
||||
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
|
||||
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
|
||||
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
|
||||
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
|
||||
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
|
||||
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
|
||||
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
|
||||
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
|
||||
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
|
||||
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
|
||||
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
|
||||
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
|
||||
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
|
||||
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
|
||||
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
|
||||
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
|
||||
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
|
||||
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
|
||||
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
|
||||
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
|
||||
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
|
||||
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
|
||||
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
|
||||
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
|
||||
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
|
||||
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
|
||||
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
|
||||
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
|
||||
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
|
||||
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
|
||||
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
|
||||
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
|
||||
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
|
||||
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
|
||||
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
|
||||
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
|
||||
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
|
||||
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
|
||||
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
|
||||
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
|
||||
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
|
||||
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
|
||||
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
|
||||
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
|
||||
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
|
||||
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
|
||||
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
|
||||
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
|
||||
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
|
||||
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
|
||||
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
|
||||
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
|
||||
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
|
||||
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
|
||||
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
|
||||
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
|
||||
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
|
||||
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cryptography
|
||||
charset-normalizer==3.4.7 \
|
||||
--hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
|
||||
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
|
||||
--hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
|
||||
--hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
|
||||
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
|
||||
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
|
||||
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
|
||||
--hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
|
||||
--hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
|
||||
--hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
|
||||
--hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
|
||||
--hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
|
||||
--hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
|
||||
--hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
|
||||
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
|
||||
--hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
|
||||
--hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
|
||||
--hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
|
||||
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
|
||||
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
|
||||
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
|
||||
--hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
|
||||
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
|
||||
--hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
|
||||
--hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
|
||||
--hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
|
||||
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
|
||||
--hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
|
||||
--hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
|
||||
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
|
||||
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
|
||||
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
|
||||
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
|
||||
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
|
||||
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
|
||||
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
|
||||
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
|
||||
--hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
|
||||
--hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
|
||||
--hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
|
||||
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
|
||||
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
|
||||
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
|
||||
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
|
||||
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
|
||||
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
|
||||
--hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
|
||||
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
|
||||
--hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
|
||||
--hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
|
||||
--hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
|
||||
--hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
|
||||
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
|
||||
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
|
||||
--hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
|
||||
--hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
|
||||
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
|
||||
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
|
||||
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
|
||||
--hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
|
||||
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
|
||||
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
|
||||
--hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
|
||||
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
|
||||
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
|
||||
--hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
|
||||
--hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
|
||||
--hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
|
||||
--hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
|
||||
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
|
||||
--hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
|
||||
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
|
||||
--hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
|
||||
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
|
||||
--hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
|
||||
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
|
||||
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
|
||||
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
|
||||
--hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
|
||||
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
|
||||
--hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
|
||||
--hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
|
||||
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
|
||||
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
|
||||
--hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
|
||||
--hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
|
||||
--hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
|
||||
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
|
||||
--hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
|
||||
--hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
|
||||
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
|
||||
--hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
|
||||
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
|
||||
--hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
|
||||
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
|
||||
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
|
||||
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
|
||||
--hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
|
||||
--hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
|
||||
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
|
||||
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
|
||||
--hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
|
||||
--hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
|
||||
--hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
|
||||
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
|
||||
--hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
|
||||
--hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
|
||||
--hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
|
||||
--hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
|
||||
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
|
||||
--hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
|
||||
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
|
||||
--hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
|
||||
--hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
|
||||
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
|
||||
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
|
||||
--hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
|
||||
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
|
||||
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
|
||||
--hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
|
||||
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
|
||||
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
|
||||
--hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
|
||||
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
|
||||
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
|
||||
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
|
||||
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
|
||||
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
|
||||
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
|
||||
# via
|
||||
# -r requirements.in
|
||||
# requests
|
||||
click==8.3.3 \
|
||||
--hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \
|
||||
--hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via click
|
||||
cryptography==50.0.0 \
|
||||
--hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \
|
||||
--hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \
|
||||
--hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \
|
||||
--hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \
|
||||
--hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \
|
||||
--hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \
|
||||
--hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \
|
||||
--hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \
|
||||
--hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \
|
||||
--hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \
|
||||
--hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \
|
||||
--hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \
|
||||
--hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \
|
||||
--hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \
|
||||
--hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \
|
||||
--hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \
|
||||
--hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \
|
||||
--hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \
|
||||
--hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \
|
||||
--hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \
|
||||
--hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \
|
||||
--hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \
|
||||
--hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \
|
||||
--hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \
|
||||
--hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \
|
||||
--hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \
|
||||
--hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \
|
||||
--hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \
|
||||
--hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \
|
||||
--hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \
|
||||
--hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \
|
||||
--hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \
|
||||
--hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \
|
||||
--hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \
|
||||
--hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \
|
||||
--hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \
|
||||
--hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \
|
||||
--hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \
|
||||
--hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \
|
||||
--hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \
|
||||
--hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \
|
||||
--hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \
|
||||
--hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \
|
||||
--hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \
|
||||
--hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \
|
||||
--hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645
|
||||
# via -r requirements.in
|
||||
flask==3.1.3
|
||||
dnspython==2.8.0 \
|
||||
--hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
|
||||
--hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
|
||||
# via
|
||||
# -r requirements.in
|
||||
# email-validator
|
||||
email-validator==2.3.0 \
|
||||
--hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \
|
||||
--hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426
|
||||
# via -r requirements.in
|
||||
flask==3.1.3 \
|
||||
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
|
||||
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask-caching
|
||||
@@ -29,94 +355,378 @@ flask==3.1.3
|
||||
# flask-marshmallow
|
||||
# flask-migrate
|
||||
# flask-sqlalchemy
|
||||
# pytest-flask
|
||||
flask-caching==2.4.0
|
||||
flask-caching==2.4.0 \
|
||||
--hash=sha256:a7f14e43617cd0612a57bec2f80516d6d2ec888bfc50a807b1e4e41ca345a3b5 \
|
||||
--hash=sha256:d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486
|
||||
# via -r requirements.in
|
||||
flask-cors==6.0.2
|
||||
flask-cors==6.0.2 \
|
||||
--hash=sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423 \
|
||||
--hash=sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a
|
||||
# via -r requirements.in
|
||||
flask-jwt-extended==4.7.3
|
||||
flask-jwt-extended==4.7.3 \
|
||||
--hash=sha256:905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb \
|
||||
--hash=sha256:a8318a3d983d1f360724b901889f1947ffee418e2a3950b48e99c70923b6615e
|
||||
# via -r requirements.in
|
||||
flask-marshmallow==1.5.0
|
||||
flask-marshmallow==1.5.0 \
|
||||
--hash=sha256:7c06b56e41647eccdb3cd57c25b109f19191b4c62509362bd64920cdf601a066 \
|
||||
--hash=sha256:99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6
|
||||
# via -r requirements.in
|
||||
flask-migrate==4.1.0
|
||||
flask-migrate==4.1.0 \
|
||||
--hash=sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d \
|
||||
--hash=sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d
|
||||
# via -r requirements.in
|
||||
flask-sqlalchemy==3.1.1
|
||||
flask-sqlalchemy==3.1.1 \
|
||||
--hash=sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0 \
|
||||
--hash=sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask-migrate
|
||||
greenlet==3.5.0
|
||||
# via sqlalchemy
|
||||
idna==3.13
|
||||
greenlet==3.5.0 \
|
||||
--hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \
|
||||
--hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \
|
||||
--hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \
|
||||
--hash=sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce \
|
||||
--hash=sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2 \
|
||||
--hash=sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588 \
|
||||
--hash=sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13 \
|
||||
--hash=sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e \
|
||||
--hash=sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a \
|
||||
--hash=sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3 \
|
||||
--hash=sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b \
|
||||
--hash=sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 \
|
||||
--hash=sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628 \
|
||||
--hash=sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136 \
|
||||
--hash=sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b \
|
||||
--hash=sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d \
|
||||
--hash=sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2 \
|
||||
--hash=sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb \
|
||||
--hash=sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd \
|
||||
--hash=sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b \
|
||||
--hash=sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1 \
|
||||
--hash=sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16 \
|
||||
--hash=sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d \
|
||||
--hash=sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106 \
|
||||
--hash=sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba \
|
||||
--hash=sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c \
|
||||
--hash=sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc \
|
||||
--hash=sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7 \
|
||||
--hash=sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339 \
|
||||
--hash=sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b \
|
||||
--hash=sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae \
|
||||
--hash=sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8 \
|
||||
--hash=sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2 \
|
||||
--hash=sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5 \
|
||||
--hash=sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf \
|
||||
--hash=sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f \
|
||||
--hash=sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f \
|
||||
--hash=sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2 \
|
||||
--hash=sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb \
|
||||
--hash=sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082 \
|
||||
--hash=sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 \
|
||||
--hash=sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0 \
|
||||
--hash=sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c \
|
||||
--hash=sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853 \
|
||||
--hash=sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988 \
|
||||
--hash=sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3 \
|
||||
--hash=sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858 \
|
||||
--hash=sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37 \
|
||||
--hash=sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977 \
|
||||
--hash=sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4 \
|
||||
--hash=sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 \
|
||||
--hash=sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86 \
|
||||
--hash=sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f \
|
||||
--hash=sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112 \
|
||||
--hash=sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e \
|
||||
--hash=sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2 \
|
||||
--hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \
|
||||
--hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \
|
||||
--hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564
|
||||
# via
|
||||
# -r requirements.in
|
||||
# sqlalchemy
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
# via
|
||||
# -r requirements.in
|
||||
# email-validator
|
||||
# requests
|
||||
iniconfig==2.3.0
|
||||
# via pytest
|
||||
itsdangerous==2.2.0
|
||||
# via flask
|
||||
jinja2==3.1.6
|
||||
# via flask
|
||||
mako==1.3.12
|
||||
# via alembic
|
||||
markupsafe==3.0.3
|
||||
itsdangerous==2.2.0 \
|
||||
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
|
||||
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
jinja2==3.1.6 \
|
||||
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
|
||||
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
mako==1.3.12 \
|
||||
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
|
||||
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
|
||||
# via
|
||||
# -r requirements.in
|
||||
# alembic
|
||||
markupsafe==3.0.3 \
|
||||
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
|
||||
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
|
||||
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
|
||||
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
|
||||
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
|
||||
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
|
||||
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
|
||||
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
|
||||
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
|
||||
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
|
||||
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
|
||||
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
|
||||
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
|
||||
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
|
||||
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
|
||||
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
|
||||
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
|
||||
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
|
||||
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
|
||||
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
|
||||
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
|
||||
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
|
||||
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
|
||||
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
|
||||
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
|
||||
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
|
||||
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
|
||||
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
|
||||
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
|
||||
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
|
||||
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
|
||||
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
|
||||
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
|
||||
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
|
||||
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
|
||||
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
|
||||
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
|
||||
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
|
||||
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
|
||||
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
|
||||
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
|
||||
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
|
||||
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
|
||||
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
|
||||
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
|
||||
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
|
||||
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
|
||||
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
|
||||
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
|
||||
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
|
||||
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
|
||||
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
|
||||
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
|
||||
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
|
||||
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
|
||||
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
|
||||
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
|
||||
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
|
||||
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
|
||||
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
|
||||
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
|
||||
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
|
||||
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
|
||||
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
|
||||
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
|
||||
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
|
||||
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
|
||||
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
|
||||
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
|
||||
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
|
||||
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
|
||||
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
|
||||
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
|
||||
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
|
||||
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
|
||||
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
|
||||
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
|
||||
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
|
||||
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
|
||||
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
|
||||
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
|
||||
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
|
||||
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
|
||||
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
|
||||
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
|
||||
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
|
||||
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
|
||||
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
|
||||
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
# jinja2
|
||||
# mako
|
||||
# werkzeug
|
||||
marshmallow==4.3.0
|
||||
# via
|
||||
# flask-marshmallow
|
||||
# marshmallow-sqlalchemy
|
||||
marshmallow-sqlalchemy==1.5.0
|
||||
# via -r requirements.in
|
||||
mysql-connector-python==9.7.0
|
||||
# via -r requirements.in
|
||||
packaging==26.2
|
||||
# via pytest
|
||||
pluggy==1.6.0
|
||||
# via
|
||||
# pytest
|
||||
# pytest-cov
|
||||
pygments==2.20.0
|
||||
# via pytest
|
||||
pyjwt==2.12.1
|
||||
# via flask-jwt-extended
|
||||
pymysql==1.1.3
|
||||
cryptography>=42.0
|
||||
# via -r requirements.in
|
||||
pytest==9.0.3
|
||||
marshmallow==4.3.0 \
|
||||
--hash=sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46 \
|
||||
--hash=sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880
|
||||
# via
|
||||
# -r requirements.in
|
||||
# pytest-cov
|
||||
# pytest-flask
|
||||
pytest-cov==7.1.0
|
||||
# flask-marshmallow
|
||||
# marshmallow-sqlalchemy
|
||||
marshmallow-sqlalchemy==1.5.0 \
|
||||
--hash=sha256:3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258 \
|
||||
--hash=sha256:e51192c204770645a2fab0d72f44f8789272eef75951f84b1608d6b4b0bfe0e6
|
||||
# via -r requirements.in
|
||||
pytest-flask==1.3.0
|
||||
mysql-connector-python==9.7.0 \
|
||||
--hash=sha256:079ce68d617250ef5cefffd5243056dc9f87c6034c804235fd6f45a0daf5a6ae \
|
||||
--hash=sha256:15106ed73d74487f86de6b1859ff7f362efca7c7f9c494497ccb7439d3139fe6 \
|
||||
--hash=sha256:229ffce2333b88b59f8f034881e12dc85b309615338be9f843ed63923db99d52 \
|
||||
--hash=sha256:236a4b8abfac8217517ce9e9edc735decf55282ada3890b3cd33770009f33d68 \
|
||||
--hash=sha256:272c4e8263f6a514e4ae65e9553ed214fc9d4cf1f0f6bedca30def20c2ae1d52 \
|
||||
--hash=sha256:2eae45230b5cbd783d68bdfe8b05ad9b4ebd06799f8d302a6169d7f025572baf \
|
||||
--hash=sha256:39bf31cdbe920eae08801eb829584dc27f70dadbdb3a5c3b78402f54cea87ab0 \
|
||||
--hash=sha256:45491d4ce56722cb335e6d0bde2d4f4a98b7073421bd02a04ad5e6220d69e499 \
|
||||
--hash=sha256:5492d57a6a0e5127a928290737fbb91b66b46d31dac8de3e7604e550bf3b3a6e \
|
||||
--hash=sha256:5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49 \
|
||||
--hash=sha256:6546e0b60c275409a5add9e3308c3897fcf478d1338cd845b1664c1a8946f72f \
|
||||
--hash=sha256:933887e71c871b6e9d8908459fe8303ebcf8feb5cc1e1c49caa6490e525cf78e \
|
||||
--hash=sha256:9549a2974353407427b574f005c92f03972e303182b4c3b4a272bf4ca10855ed \
|
||||
--hash=sha256:9bdfc2d4c4444cd1cc79cc6487c047b28fe2b26d0327b27eb9f5737bb553cb5c \
|
||||
--hash=sha256:9da73e212bb08e4df286506ffbda943063bbce448375a7db29748bb367f4e0af \
|
||||
--hash=sha256:9e17ceb79c1c4c137a5994f8ca8c6a1ce8ebf83eda3ccc21dd67f2c1398680f0 \
|
||||
--hash=sha256:a2f371ab69d65c61136c51ad7026017400166cef3c959cab7a9fb668c7acbfba \
|
||||
--hash=sha256:af80b1e7179d5c2d983cf62470ad9b134a7e9ef05cf31108ae587f15873530cc \
|
||||
--hash=sha256:b5cb8a3ba42b539f79cd13e4c8376d28506f3180f7079c9b04ea7bfd0424fb03 \
|
||||
--hash=sha256:b8d3d6f32b95ae1d0a2f29d1a2b4e628849bcf7e84a70dc56c85b4929361d86b \
|
||||
--hash=sha256:c51be697bfdfdf63bb71c5ecc51f7c6faf4aaa3d14a0136fa16e97cc37df1185 \
|
||||
--hash=sha256:d5924a76b530159c02f2fe8da4d3c6377ce1f5e195827e8ecbd36124673651d3 \
|
||||
--hash=sha256:daf70f7da64a2e7c17e6dfd1fc98d2deb653fd955d0bd0b1fef246356e682b0f \
|
||||
--hash=sha256:e3842ecd62391a28c1dda5eb817f40418715e68482a8c146b3c478ac8bb7a23f \
|
||||
--hash=sha256:ea4e05ab864ab8b0b5066d885d89ca51e5ba1320dba520c20a6f9388a8eca022 \
|
||||
--hash=sha256:ee90c5f44f706f012be17f03f6ad158ff96e7f2dcc077896fe4537d3d28b3cf4
|
||||
# via -r requirements.in
|
||||
python-dotenv==1.2.2
|
||||
# via -r requirements.in
|
||||
requests==2.33.1
|
||||
# via -r requirements.in
|
||||
sqlalchemy==2.0.49
|
||||
pycparser==3.0 \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cffi
|
||||
pyjwt==2.12.1 \
|
||||
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
|
||||
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask-jwt-extended
|
||||
pymysql==1.1.3 \
|
||||
--hash=sha256:8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289 \
|
||||
--hash=sha256:e70ebf2047a4edf6138cf79c68ad418ef620af65900aa585c5e8bfc95044d43a
|
||||
# via -r requirements.in
|
||||
python-dotenv==1.2.2 \
|
||||
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
|
||||
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
|
||||
# via -r requirements.in
|
||||
requests==2.33.1 \
|
||||
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
|
||||
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
|
||||
# via -r requirements.in
|
||||
sqlalchemy==2.0.49 \
|
||||
--hash=sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72 \
|
||||
--hash=sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe \
|
||||
--hash=sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75 \
|
||||
--hash=sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5 \
|
||||
--hash=sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148 \
|
||||
--hash=sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7 \
|
||||
--hash=sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e \
|
||||
--hash=sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518 \
|
||||
--hash=sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7 \
|
||||
--hash=sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700 \
|
||||
--hash=sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717 \
|
||||
--hash=sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 \
|
||||
--hash=sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88 \
|
||||
--hash=sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f \
|
||||
--hash=sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f \
|
||||
--hash=sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08 \
|
||||
--hash=sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a \
|
||||
--hash=sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3 \
|
||||
--hash=sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b \
|
||||
--hash=sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536 \
|
||||
--hash=sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0 \
|
||||
--hash=sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b \
|
||||
--hash=sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a \
|
||||
--hash=sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3 \
|
||||
--hash=sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 \
|
||||
--hash=sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339 \
|
||||
--hash=sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158 \
|
||||
--hash=sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 \
|
||||
--hash=sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662 \
|
||||
--hash=sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1 \
|
||||
--hash=sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3 \
|
||||
--hash=sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 \
|
||||
--hash=sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01 \
|
||||
--hash=sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613 \
|
||||
--hash=sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a \
|
||||
--hash=sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0 \
|
||||
--hash=sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f \
|
||||
--hash=sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a \
|
||||
--hash=sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e \
|
||||
--hash=sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2 \
|
||||
--hash=sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af \
|
||||
--hash=sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014 \
|
||||
--hash=sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33 \
|
||||
--hash=sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61 \
|
||||
--hash=sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d \
|
||||
--hash=sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187 \
|
||||
--hash=sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401 \
|
||||
--hash=sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b \
|
||||
--hash=sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d \
|
||||
--hash=sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f \
|
||||
--hash=sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba \
|
||||
--hash=sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977 \
|
||||
--hash=sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a \
|
||||
--hash=sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe \
|
||||
--hash=sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b \
|
||||
--hash=sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f \
|
||||
--hash=sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1 \
|
||||
--hash=sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4 \
|
||||
--hash=sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d \
|
||||
--hash=sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120 \
|
||||
--hash=sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750 \
|
||||
--hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \
|
||||
--hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982
|
||||
# via
|
||||
# -r requirements.in
|
||||
# alembic
|
||||
# flask-sqlalchemy
|
||||
# marshmallow-sqlalchemy
|
||||
tabulate==0.10.0
|
||||
tabulate==0.10.0 \
|
||||
--hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \
|
||||
--hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3
|
||||
# via -r requirements.in
|
||||
typing-extensions==4.15.0
|
||||
typing-extensions==4.15.0 \
|
||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||
# via
|
||||
# -r requirements.in
|
||||
# alembic
|
||||
# sqlalchemy
|
||||
urllib3==2.7.0
|
||||
# via requests
|
||||
werkzeug==3.1.8
|
||||
tzdata==2026.3 \
|
||||
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
|
||||
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
|
||||
# via -r requirements.in
|
||||
urllib3==2.7.0 \
|
||||
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
|
||||
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
# via
|
||||
# -r requirements.in
|
||||
# requests
|
||||
waitress==3.0.2 \
|
||||
--hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \
|
||||
--hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e
|
||||
# via -r requirements.in
|
||||
werkzeug==3.1.8 \
|
||||
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
|
||||
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
# flask-cors
|
||||
# flask-jwt-extended
|
||||
# pytest-flask
|
||||
waitress>=3.0
|
||||
tzdata
|
||||
|
||||
@@ -17,40 +17,31 @@ OUT="${2:-$REPO/build/site}"
|
||||
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
|
||||
|
||||
# Resolve the chosen plugins + their hard-dependency closure from the manifests.
|
||||
CLOSURE=$(python3 - "$PROFILE" "$REPO" <<'PY'
|
||||
import json, sys, os
|
||||
profile_path, repo = sys.argv[1], sys.argv[2]
|
||||
chosen = json.load(open(profile_path)).get('plugins', [])
|
||||
plugins_dir = os.path.join(repo, 'plugins')
|
||||
|
||||
def deps(name):
|
||||
mpath = os.path.join(plugins_dir, name, 'manifest.json')
|
||||
if not os.path.exists(mpath):
|
||||
sys.exit(f'profile plugin not found on disk: {name}')
|
||||
out = []
|
||||
for dep in json.load(open(mpath)).get('dependencies', []):
|
||||
# name-only (strip any PEP440 range)
|
||||
for sep in '><=!~ ':
|
||||
dep = dep.split(sep)[0]
|
||||
out.append(dep.strip())
|
||||
return out
|
||||
|
||||
closure, seen = [], set()
|
||||
def add(name):
|
||||
if name in seen: return
|
||||
seen.add(name)
|
||||
for d in deps(name): add(d)
|
||||
closure.append(name)
|
||||
for p in chosen: add(p)
|
||||
print(','.join(closure))
|
||||
PY
|
||||
)
|
||||
# Shared with the Windows builder (deploy/windows/installer/build-installer.ps1)
|
||||
# so both stage the same set from the same profile.
|
||||
CLOSURE=$(python3 "$REPO/scripts/resolve_plugin_closure.py" "$PROFILE" "$REPO")
|
||||
|
||||
echo "Site profile: $PROFILE"
|
||||
echo "Plugin closure: $CLOSURE"
|
||||
|
||||
# Frontend: build carrying only the closure's plugins.
|
||||
echo "==> Building frontend (SITE_PLUGINS=$CLOSURE) ..."
|
||||
# Frontend: TWO builds, because Vite compiles the mount path into the bundle and
|
||||
# it therefore cannot be chosen at install time from a single build - the page
|
||||
# would load and then request its assets from the wrong path.
|
||||
# dist -> own IIS site (method A, the default)
|
||||
# dist-subpath -> IIS Application under an existing site (method B)
|
||||
# SUBPATH_ALIAS is fixed per bundle so the three places that must agree - the IIS
|
||||
# application alias, MOUNT_PATH and this base - cannot drift apart.
|
||||
SUBPATH_ALIAS="${SUBPATH_ALIAS:-shopdb}"
|
||||
SUBPATH_TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$SUBPATH_TMP"' EXIT
|
||||
|
||||
echo "==> Building frontend for /$SUBPATH_ALIAS/ (SITE_PLUGINS=$CLOSURE) ..."
|
||||
( cd "$REPO/frontend" && SITE_PLUGINS="$CLOSURE" VITE_BASE_PATH="/$SUBPATH_ALIAS/" npm run build --silent )
|
||||
cp -r "$REPO/frontend/dist" "$SUBPATH_TMP/dist-subpath"
|
||||
echo "$SUBPATH_ALIAS" > "$SUBPATH_TMP/dist-subpath/.alias"
|
||||
|
||||
# Root build LAST, so frontend/dist is left in the state a developer expects.
|
||||
echo "==> Building frontend for / (SITE_PLUGINS=$CLOSURE) ..."
|
||||
( cd "$REPO/frontend" && SITE_PLUGINS="$CLOSURE" npm run build --silent )
|
||||
|
||||
# Backend: stage core + only the chosen plugin dirs.
|
||||
@@ -64,6 +55,60 @@ for name in ${CLOSURE//,/ }; do
|
||||
cp -a "$REPO/plugins/$name" "$OUT/plugins/"
|
||||
done
|
||||
cp -r "$REPO/frontend/dist" "$OUT/frontend-dist"
|
||||
# Staged after the rm -rf above, or it would be deleted with everything else.
|
||||
cp -r "$SUBPATH_TMP/dist-subpath" "$OUT/frontend-dist-subpath"
|
||||
|
||||
# Runtime files a deployable tree needs beyond the Python packages. Without these
|
||||
# the staged tree can be imported but not actually run or migrated, so the
|
||||
# Windows installer (which consumes this output as its app\ payload) had to
|
||||
# assemble them separately - and could assemble a tree whose plugin set did not
|
||||
# match the profile it was built from.
|
||||
cp "$REPO/wsgi.py" "$REPO/requirements.txt" "$OUT/"
|
||||
cp -a "$REPO/migrations" "$OUT/"
|
||||
# ONLY web.config, not all of deploy/. Two reasons, and the first is fatal:
|
||||
# the Windows builder stages its output at deploy/windows/installer/bundle, so
|
||||
# copying deploy/ wholesale recursed into the destination and cp aborted with
|
||||
# "cannot copy a directory into itself" - the documented build could not finish.
|
||||
# Second, the rest of deploy/ is installer SOURCE (scripts, artwork, MSIs); none
|
||||
# of it belongs in an application tree that gets copied onto a server.
|
||||
# shopdb-install.ps1 reads it from exactly this path.
|
||||
if [ -f "$REPO/deploy/windows/web.config" ]; then
|
||||
mkdir -p "$OUT/deploy/windows"
|
||||
cp -a "$REPO/deploy/windows/web.config" "$OUT/deploy/windows/"
|
||||
fi
|
||||
|
||||
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
|
||||
# Staged INTO the tree so it installs onto the server with the application: an
|
||||
# air-gapped site cannot be scanned remotely, so the only way to answer "are we
|
||||
# exposed to this CVE, and where" is for the answer to be sitting on the box.
|
||||
# Generated from requirements.txt and package-lock.json, both already pinned and
|
||||
# committed, so it is a translation rather than a scan - and deterministic.
|
||||
echo "==> Generating SBOM ..."
|
||||
python3 "$REPO/scripts/generate_sbom.py" "$REPO" -o "$OUT/sbom.cdx.json"
|
||||
|
||||
# Docs the RUNNING SITE serves or the operator needs on the box. Without
|
||||
# openapi.json and llms.txt the self-hosted /api/docs page is broken on every
|
||||
# installed server - which matters most for the sites least able to debug it, and
|
||||
# for the operators who will point an assistant at their own instance. The
|
||||
# Windows runbooks ship because an air-gapped server has no other way to reach
|
||||
# them; docs/ is not otherwise staged, so shipping the whole tree would be noise.
|
||||
echo "==> Staging docs ..."
|
||||
mkdir -p "$OUT/docs"
|
||||
for doc in openapi.json llms.txt api-inventory.json \
|
||||
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md; do
|
||||
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
|
||||
done
|
||||
# Never let a missing optional doc fail the build - the loop's last test governs
|
||||
# the exit status under `set -e`.
|
||||
true
|
||||
|
||||
# Stage the profile INTO the tree. This is what makes the set self-describing:
|
||||
# `flask plugin apply-profile` at provisioning time reads the same profile the
|
||||
# tree was staged from, so the installed plugin set and the shipped plugin code
|
||||
# cannot drift. It is also what `flask plugin prune-schema` effectively keys off
|
||||
# (via what ends up installed), so a mismatch here would drop the wrong tables.
|
||||
cp "$PROFILE" "$OUT/site-profile.json"
|
||||
|
||||
find "$OUT" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||||
find "$OUT" -type f -name '*.pyc' -delete
|
||||
|
||||
@@ -71,3 +116,8 @@ echo ""
|
||||
echo "Lean site staged at: $OUT"
|
||||
echo " backend plugins: $(ls "$OUT/plugins" | tr '\n' ' ')"
|
||||
echo " (a plugin not listed is absent from both the backend tree and the bundle)"
|
||||
echo " profile staged as: $OUT/site-profile.json"
|
||||
echo ""
|
||||
echo "At provisioning, after 'flask db upgrade' and 'flask plugin upgrade-all':"
|
||||
echo " flask plugin apply-profile site-profile.json"
|
||||
echo " flask plugin prune-schema --yes --force # ADR-014; BOTH flags required"
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
# 4. No snake_case API params in frontend that should match DB column names
|
||||
#
|
||||
# Exits non-zero if any violation found.
|
||||
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/
|
||||
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/,
|
||||
# deploy/windows/installer/bundle/ (installer build output)
|
||||
|
||||
set -e
|
||||
|
||||
@@ -23,6 +24,12 @@ EXCLUDES=(
|
||||
--exclude-dir=node_modules
|
||||
--exclude-dir=__pycache__
|
||||
--exclude-dir=dist
|
||||
# The installer's build output: a staged copy of the whole application plus
|
||||
# a second SPA build under dist-subpath, which --exclude-dir=dist does not
|
||||
# match. Linting it means linting vendored minified JS and failing on
|
||||
# characters nobody in this repository wrote.
|
||||
--exclude-dir=bundle
|
||||
--exclude-dir=dist-subpath
|
||||
--exclude-dir=.git
|
||||
--exclude-dir=versions
|
||||
--exclude-dir=staticdocs
|
||||
@@ -46,8 +53,12 @@ INCLUDES_ALL=(
|
||||
--include='*.yml'
|
||||
)
|
||||
|
||||
# INCLUDES_ALL, not INCLUDES_CODE: the check only ever covered .py/.vue/.js/.ts,
|
||||
# so documentation was free to accumulate em-dashes, arrows and smart quotes -
|
||||
# and did, including in files this repo's own convention forbids them in.
|
||||
# Markdown, JSON and YAML are now covered too.
|
||||
echo "==> Checking for non-ASCII characters..."
|
||||
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_CODE[@]}" . 2>/dev/null || true)
|
||||
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null || true)
|
||||
if [ -n "$NON_ASCII" ]; then
|
||||
echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):"
|
||||
echo "$NON_ASCII"
|
||||
|
||||
348
scripts/generate_sbom.py
Normal file
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit a CycloneDX 1.6 SBOM for this application: Python and npm together.
|
||||
|
||||
Why this is hand-rolled rather than cyclonedx-py plus cyclonedx-npm:
|
||||
|
||||
- It has to run on the machine that builds the installer, which may be a work
|
||||
PC with nothing but Python and Node. Two more toolchains to install, keep
|
||||
current and match versions across build boxes is a real cost for output this
|
||||
small.
|
||||
- Both inputs are already fully pinned and committed - requirements.txt with a
|
||||
sha256 per package, package-lock.json with an integrity per package - so
|
||||
there is nothing to resolve. This is a format translation, not a scan.
|
||||
- It is deterministic by construction, which the packaged tools are not
|
||||
without extra flags. Same inputs, byte-identical output.
|
||||
|
||||
WHAT IT COVERS
|
||||
|
||||
Python every pin in requirements.txt, with the sha256 the installer enforces.
|
||||
Environment markers are ignored on purpose: a requirement guarded by
|
||||
sys_platform == 'win32' still installs on the target, which is Windows.
|
||||
npm every package in frontend/package-lock.json. Development-only packages
|
||||
are included but marked scope 'excluded', because they do not ship
|
||||
inside the compiled SPA - a reader answering "are we exposed" needs to
|
||||
see the distinction, not a list with them silently missing.
|
||||
|
||||
Dependency edges are real, not flat: uv writes '# via <parent>' comments into
|
||||
requirements.txt, and package-lock records each package's dependencies.
|
||||
|
||||
NOT COVERED: the application's own source, which git records, and the operating
|
||||
system. See deploy/windows/installer/bundle-lock.json for the third-party
|
||||
binaries (Python installer, IIS MSIs) that ship alongside this.
|
||||
|
||||
Usage:
|
||||
generate_sbom.py <repo-root> [-o out.json] [--timestamp ISO8601] [--appversion X]
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
SPEC_VERSION = '1.6'
|
||||
GENERATOR = 'scripts/generate_sbom.py'
|
||||
# Fixed namespace so the same component set always yields the same serial number.
|
||||
# A random UUID per run would make every SBOM differ from the last for no reason.
|
||||
SERIAL_NAMESPACE = uuid.UUID('6f9d1a1e-2b3c-4d5e-8f90-a1b2c3d4e5f6')
|
||||
|
||||
|
||||
def normalize(name):
|
||||
return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower()
|
||||
|
||||
|
||||
def read_app_version(repo):
|
||||
init = os.path.join(repo, 'shopdb', '__init__.py')
|
||||
try:
|
||||
with open(init) as fh:
|
||||
match = re.search(r"^__version__\s*=\s*'([^']+)'", fh.read(), re.M)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
return '0.0.0'
|
||||
|
||||
|
||||
def parse_requirements(path):
|
||||
"""Pins, their sha256 list, and which package pulled each one in.
|
||||
|
||||
uv emits, per entry:
|
||||
flask==3.1.3 \\
|
||||
--hash=sha256:... \\
|
||||
--hash=sha256:...
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask-migrate
|
||||
|
||||
'-r <file>' means the site asked for it directly; anything else is a parent.
|
||||
"""
|
||||
components, order = {}, []
|
||||
current = None
|
||||
in_via = False
|
||||
with open(path) as fh:
|
||||
for raw in fh:
|
||||
line = raw.rstrip('\n')
|
||||
stripped = line.strip()
|
||||
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', stripped)
|
||||
if match and not stripped.startswith('#'):
|
||||
current = normalize(match.group(1))
|
||||
in_via = False
|
||||
if current not in components:
|
||||
order.append(current)
|
||||
components[current] = {
|
||||
'name': match.group(1), 'version': match.group(2),
|
||||
'hashes': [], 'parents': [], 'direct': False,
|
||||
}
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
hash_match = re.search(r'--hash=sha256:([a-f0-9]{64})', stripped)
|
||||
if hash_match:
|
||||
components[current]['hashes'].append(hash_match.group(1))
|
||||
continue
|
||||
if stripped.startswith('#'):
|
||||
body = stripped.lstrip('#').strip()
|
||||
if body == 'via':
|
||||
in_via = True
|
||||
continue
|
||||
if body.startswith('via '):
|
||||
in_via = True
|
||||
body = body[4:].strip()
|
||||
if in_via and body:
|
||||
if body.startswith('-r ') or body.startswith('-c '):
|
||||
components[current]['direct'] = True
|
||||
else:
|
||||
components[current]['parents'].append(normalize(body))
|
||||
continue
|
||||
in_via = False
|
||||
return [components[key] for key in order]
|
||||
|
||||
|
||||
def integrity_to_hash(integrity):
|
||||
"""npm 'sha512-<base64>' becomes a CycloneDX hash entry."""
|
||||
if not integrity or '-' not in integrity:
|
||||
return None
|
||||
algorithm, _, encoded = integrity.partition('-')
|
||||
algorithms = {'sha512': 'SHA-512', 'sha256': 'SHA-256', 'sha1': 'SHA-1'}
|
||||
if algorithm not in algorithms:
|
||||
return None
|
||||
try:
|
||||
digest = base64.b64decode(encoded)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
return {'alg': algorithms[algorithm], 'content': digest.hex()}
|
||||
|
||||
|
||||
def parse_package_lock(path):
|
||||
"""Every locked package, plus the names the project depends on DIRECTLY.
|
||||
|
||||
The direct set comes from the lockfile's root entry. Without it the SBOM
|
||||
claimed the application depended directly on all ~110 shipped packages,
|
||||
which flattens the graph and makes 'what did we choose' unanswerable.
|
||||
"""
|
||||
with open(path) as fh:
|
||||
data = json.load(fh)
|
||||
packages = data.get('packages', {})
|
||||
root = packages.get('', {})
|
||||
direct = sorted((root.get('dependencies') or {}).keys())
|
||||
# Keyed by (name, version), because npm installs the same package at several
|
||||
# depths: node_modules/vite AND node_modules/vitest/node_modules/vite are two
|
||||
# entries for one component. Emitting both produced duplicate bom-refs, which
|
||||
# CycloneDX forbids and scanners reject.
|
||||
merged = {}
|
||||
for key, meta in packages.items():
|
||||
if not key.startswith('node_modules/'):
|
||||
continue
|
||||
# Nested paths (a/node_modules/b) name the package after the LAST segment.
|
||||
name = key.split('node_modules/')[-1]
|
||||
version = meta.get('version')
|
||||
if not version:
|
||||
continue
|
||||
entry = merged.get((name, version))
|
||||
if entry is None:
|
||||
merged[(name, version)] = {
|
||||
'name': name,
|
||||
'version': version,
|
||||
'dev': bool(meta.get('dev') or meta.get('devOptional')),
|
||||
'license': meta.get('license'),
|
||||
'hash': integrity_to_hash(meta.get('integrity')),
|
||||
'deps': set((meta.get('dependencies') or {}).keys()),
|
||||
}
|
||||
continue
|
||||
# One copy reachable outside the dev tree means the component ships, so
|
||||
# 'dev' only survives while EVERY instance is dev. Getting this backwards
|
||||
# would mark a shipped package build-only and hide it from a CVE search.
|
||||
entry['dev'] = entry['dev'] and bool(meta.get('dev') or meta.get('devOptional'))
|
||||
entry['license'] = entry['license'] or meta.get('license')
|
||||
entry['hash'] = entry['hash'] or integrity_to_hash(meta.get('integrity'))
|
||||
entry['deps'] |= set((meta.get('dependencies') or {}).keys())
|
||||
|
||||
out = []
|
||||
for entry in merged.values():
|
||||
entry['deps'] = sorted(entry['deps'])
|
||||
out.append(entry)
|
||||
out.sort(key=lambda component: (component['name'], component['version']))
|
||||
return out, direct
|
||||
|
||||
|
||||
def purl(ecosystem, name, version):
|
||||
from urllib.parse import quote
|
||||
return 'pkg:%s/%s@%s' % (ecosystem, quote(name, safe='@/'), quote(version, safe=''))
|
||||
|
||||
|
||||
def build(repo, timestamp, app_version):
|
||||
requirements = os.path.join(repo, 'requirements.txt')
|
||||
lockfile = os.path.join(repo, 'frontend', 'package-lock.json')
|
||||
|
||||
components, dependencies = [], []
|
||||
root_ref = 'shopdb-flask@%s' % app_version
|
||||
root_deps = []
|
||||
|
||||
pips = parse_requirements(requirements) if os.path.exists(requirements) else []
|
||||
by_normalized = {}
|
||||
for pin in pips:
|
||||
ref = purl('pypi', pin['name'], pin['version'])
|
||||
by_normalized[normalize(pin['name'])] = ref
|
||||
entry = {
|
||||
'type': 'library',
|
||||
'bom-ref': ref,
|
||||
'name': pin['name'],
|
||||
'version': pin['version'],
|
||||
'purl': ref,
|
||||
'scope': 'required',
|
||||
'properties': [{'name': 'shopdb:ecosystem', 'value': 'python'}],
|
||||
}
|
||||
if pin['hashes']:
|
||||
entry['hashes'] = [{'alg': 'SHA-256', 'content': value}
|
||||
for value in sorted(pin['hashes'])]
|
||||
components.append(entry)
|
||||
|
||||
for pin in pips:
|
||||
ref = by_normalized[normalize(pin['name'])]
|
||||
if pin['direct']:
|
||||
root_deps.append(ref)
|
||||
for parent in pin['parents']:
|
||||
parent_ref = by_normalized.get(parent)
|
||||
if parent_ref:
|
||||
dependencies.append((parent_ref, ref))
|
||||
|
||||
npms, npm_direct = parse_package_lock(lockfile) if os.path.exists(lockfile) else ([], [])
|
||||
npm_refs = {}
|
||||
for package in npms:
|
||||
ref = purl('npm', package['name'], package['version'])
|
||||
npm_refs.setdefault(package['name'], ref)
|
||||
entry = {
|
||||
'type': 'library',
|
||||
'bom-ref': ref,
|
||||
'name': package['name'],
|
||||
'version': package['version'],
|
||||
'purl': ref,
|
||||
# Development packages do not ship inside the compiled SPA. Recorded
|
||||
# rather than dropped so the distinction is visible.
|
||||
'scope': 'excluded' if package['dev'] else 'required',
|
||||
'properties': [{'name': 'shopdb:ecosystem', 'value': 'npm'}],
|
||||
}
|
||||
if package['hash']:
|
||||
entry['hashes'] = [package['hash']]
|
||||
if isinstance(package['license'], str):
|
||||
entry['licenses'] = [{'license': {'id': package['license']}}]
|
||||
components.append(entry)
|
||||
|
||||
for name in npm_direct:
|
||||
ref = npm_refs.get(name)
|
||||
if ref:
|
||||
root_deps.append(ref)
|
||||
|
||||
for package in npms:
|
||||
ref = npm_refs.get(package['name'])
|
||||
for name in package['deps']:
|
||||
child = npm_refs.get(name)
|
||||
if child and ref:
|
||||
dependencies.append((ref, child))
|
||||
|
||||
graph = {}
|
||||
for parent, child in dependencies:
|
||||
graph.setdefault(parent, set()).add(child)
|
||||
depends_on = [{'ref': root_ref, 'dependsOn': sorted(set(root_deps))}]
|
||||
for ref in sorted(graph):
|
||||
depends_on.append({'ref': ref, 'dependsOn': sorted(graph[ref])})
|
||||
|
||||
components.sort(key=lambda component: component['bom-ref'])
|
||||
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps([component['bom-ref'] for component in components],
|
||||
sort_keys=True).encode()).hexdigest()
|
||||
serial = uuid.uuid5(SERIAL_NAMESPACE, fingerprint)
|
||||
|
||||
return {
|
||||
'bomFormat': 'CycloneDX',
|
||||
'specVersion': SPEC_VERSION,
|
||||
'serialNumber': 'urn:uuid:%s' % serial,
|
||||
'version': 1,
|
||||
'metadata': {
|
||||
'timestamp': timestamp,
|
||||
'tools': {'components': [{
|
||||
'type': 'application', 'name': GENERATOR, 'version': app_version,
|
||||
}]},
|
||||
'authors': [{'name': 'GE Aerospace'}],
|
||||
'supplier': {'name': 'GE Aerospace'},
|
||||
'component': {
|
||||
'type': 'application',
|
||||
'bom-ref': root_ref,
|
||||
'name': 'shopdb-flask',
|
||||
'version': app_version,
|
||||
'description': 'Asset management for the shop floor.',
|
||||
},
|
||||
},
|
||||
'components': components,
|
||||
'dependencies': depends_on,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('repo')
|
||||
parser.add_argument('-o', '--output', default='-')
|
||||
# Pinned by the caller, or by SOURCE_DATE_EPOCH, so two builds of one commit
|
||||
# produce byte-identical files.
|
||||
parser.add_argument('--timestamp', default=None)
|
||||
parser.add_argument('--appversion', default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
timestamp = args.timestamp
|
||||
if not timestamp:
|
||||
epoch = os.environ.get('SOURCE_DATE_EPOCH')
|
||||
moment = (datetime.fromtimestamp(int(epoch), timezone.utc) if epoch
|
||||
else datetime.now(timezone.utc))
|
||||
timestamp = moment.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
version = args.appversion or read_app_version(args.repo)
|
||||
document = build(args.repo, timestamp, version)
|
||||
text = json.dumps(document, indent=2, sort_keys=False) + '\n'
|
||||
|
||||
if args.output == '-':
|
||||
sys.stdout.write(text)
|
||||
else:
|
||||
directory = os.path.dirname(args.output)
|
||||
if directory and not os.path.isdir(directory):
|
||||
os.makedirs(directory)
|
||||
with open(args.output, 'w') as fh:
|
||||
fh.write(text)
|
||||
counts = {}
|
||||
for component in document['components']:
|
||||
key = component['properties'][0]['value']
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
shipped = sum(1 for component in document['components']
|
||||
if component['scope'] == 'required')
|
||||
print('%s: %d components (%s), %d shipped' % (
|
||||
args.output, len(document['components']),
|
||||
', '.join('%s %d' % (k, counts[k]) for k in sorted(counts)), shipped))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
61
scripts/resolve_plugin_closure.py
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve a site profile's chosen plugins plus their hard-dependency closure.
|
||||
|
||||
Printed as a comma-separated list, dependencies before dependants, so the caller
|
||||
can stage them in order.
|
||||
|
||||
This lives in its own file because TWO builders need the same answer:
|
||||
scripts/build-site.sh (Linux) and deploy/windows/installer/build-installer.ps1
|
||||
(Windows). It was inline in build-site.sh; a PowerShell reimplementation would
|
||||
have been a second copy of the closure rules, free to drift from this one and
|
||||
produce a bundle whose plugin set did not match the profile it was built from.
|
||||
|
||||
Usage: resolve_plugin_closure.py <site-profile.json> <repo-root>
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def load_dependencies(plugins_dir, name):
|
||||
manifest = os.path.join(plugins_dir, name, 'manifest.json')
|
||||
if not os.path.exists(manifest):
|
||||
sys.exit('profile plugin not found on disk: %s' % name)
|
||||
with open(manifest) as fh:
|
||||
declared = json.load(fh).get('dependencies', [])
|
||||
names = []
|
||||
for dep in declared:
|
||||
# name-only (strip any PEP440 range)
|
||||
for sep in '><=!~ ':
|
||||
dep = dep.split(sep)[0]
|
||||
names.append(dep.strip())
|
||||
return names
|
||||
|
||||
|
||||
def resolve(profile_path, repo):
|
||||
with open(profile_path) as fh:
|
||||
chosen = json.load(fh).get('plugins', [])
|
||||
plugins_dir = os.path.join(repo, 'plugins')
|
||||
closure, seen = [], set()
|
||||
|
||||
def add(name):
|
||||
if name in seen:
|
||||
return
|
||||
seen.add(name)
|
||||
for dep in load_dependencies(plugins_dir, name):
|
||||
add(dep)
|
||||
closure.append(name)
|
||||
|
||||
for name in chosen:
|
||||
add(name)
|
||||
return closure
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit('usage: resolve_plugin_closure.py <site-profile.json> <repo-root>')
|
||||
print(','.join(resolve(sys.argv[1], sys.argv[2])))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -90,7 +90,7 @@ else
|
||||
python3 -m venv "$WORKDIR/venv"
|
||||
PYTHON="$WORKDIR/venv/bin/python"
|
||||
"$PYTHON" -m pip install --upgrade pip >/dev/null
|
||||
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
|
||||
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements-dev.txt"
|
||||
fi
|
||||
|
||||
echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"
|
||||
|
||||
@@ -98,6 +98,31 @@ def preflight():
|
||||
elif version:
|
||||
ok('MySQL 5.7+/8.0 - no extra index flags needed')
|
||||
|
||||
# The database's own default charset. Tables are forced to utf8mb4 by the
|
||||
# compiler hook (shopdb/utils/mysql_charset.py), so a latin1 default does not
|
||||
# break the schema - but anything created OUTSIDE that path inherits it, and
|
||||
# a database created without an explicit CHARSET is the usual cause. Cheap to
|
||||
# check here, invisible until characters come back mangled.
|
||||
if version:
|
||||
try:
|
||||
row = db.session.execute(text(
|
||||
'SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA '
|
||||
'WHERE SCHEMA_NAME = DATABASE()')).fetchone()
|
||||
charset = row[0] if row else None
|
||||
if charset is None:
|
||||
warn('Could not read the database default charset',
|
||||
'Not fatal - tables are forced to utf8mb4 regardless.')
|
||||
elif str(charset).lower() == 'utf8mb4':
|
||||
ok('Database default charset is utf8mb4')
|
||||
else:
|
||||
warn(f'Database default charset is {charset}, not utf8mb4',
|
||||
'Tables this application creates are forced to utf8mb4, so the '
|
||||
'schema is correct. Fix the default so anything created outside '
|
||||
'the migrations matches: ALTER DATABASE <name> CHARACTER SET '
|
||||
'utf8mb4 COLLATE utf8mb4_unicode_ci;')
|
||||
except Exception as exc:
|
||||
warn('Could not read the database default charset', f'({exc})')
|
||||
|
||||
click.echo('')
|
||||
if failures:
|
||||
click.echo(click.style(f'{len(failures)} blocker(s). Fix them before installing.', fg='red', bold=True))
|
||||
|
||||
@@ -34,6 +34,14 @@ from typing import Iterable
|
||||
from alembic import context
|
||||
from sqlalchemy import MetaData, pool
|
||||
|
||||
# Registers the compiler hook that forces utf8mb4 + DYNAMIC on MySQL. Imported
|
||||
# for the side effect. It used to live inline in migrations/env.py, so it applied
|
||||
# to the CORE chain only: a plugin's baseline tables were created at the server's
|
||||
# default charset while core's were utf8mb4, on the same database. On a server
|
||||
# defaulting to latin1 that difference is invisible until a join between the two
|
||||
# stops using an index, or a character comes back mangled.
|
||||
import shopdb.utils.mysql_charset # noqa: F401,E402
|
||||
|
||||
logger = logging.getLogger('alembic.env.plugin')
|
||||
|
||||
# Explicit table-ownership map. Adding tables to a plugin requires updating
|
||||
|
||||
40
shopdb/utils/mysql_charset.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Force every table created on MySQL to utf8mb4 + DYNAMIC row format.
|
||||
|
||||
Importing this module installs a SQLAlchemy compiler hook. It is a side effect on
|
||||
purpose: the hook has to be registered before any CreateTable is compiled, and
|
||||
every place that creates tables needs it.
|
||||
|
||||
WHY. Without it a CREATE TABLE inherits the SERVER's default charset. A MySQL box
|
||||
defaulting to latin1 - common on older installs, and the West Jefferson 5.6
|
||||
server is one - silently builds a latin1 schema that drifts from the utf8mb4
|
||||
production target. Nothing fails at create time; it surfaces later as mangled
|
||||
characters, or as a join between a utf8mb4 and a latin1 column that cannot use an
|
||||
index.
|
||||
|
||||
DYNAMIC row format is the other half: it keeps utf8mb4 indexes under the 767-byte
|
||||
prefix limit on pre-5.7 InnoDB, which is what error 1071 ("key too long") is.
|
||||
|
||||
This lived inline in migrations/env.py, so it covered the CORE chain only. Plugin
|
||||
chains (ADR-008) run through shopdb/plugins/alembic_template.py, which never
|
||||
imported it - so a plugin's baseline tables were created at the server default
|
||||
while core's were utf8mb4, on the same database. Both import this now.
|
||||
|
||||
Scoped to the mysql dialect, so the SQLite test database is untouched.
|
||||
"""
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
TABLE_SUFFIX = (
|
||||
" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
" COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC"
|
||||
)
|
||||
|
||||
|
||||
@compiles(CreateTable, "mysql")
|
||||
def _mysql_create_table_utf8mb4(element, compiler, **kw):
|
||||
sql = compiler.visit_create_table(element, **kw)
|
||||
# An explicit CHARSET in the model's __table_args__ wins - this is a default,
|
||||
# not an override.
|
||||
if "CHARSET" not in sql.upper():
|
||||
sql = sql.rstrip().rstrip(";") + TABLE_SUFFIX
|
||||
return sql
|
||||
231
tests/test_bundle_lock.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""The installer bundle lock: fail-closed behaviour, and parity between the two
|
||||
implementations that read it.
|
||||
|
||||
There are two verifiers on purpose - bundle-lock.ps1 runs at install time on a
|
||||
server where PowerShell is the only thing present, verify_bundle_lock.py runs in
|
||||
the Linux builder without adding pwsh as a build dependency. Two implementations
|
||||
of one rule drift. These tests run BOTH against the same fixtures and fail if
|
||||
they disagree, so the drift shows up here rather than as a bundle that one of
|
||||
them waves through.
|
||||
|
||||
The PowerShell half is skipped where pwsh is absent; the Python half always runs.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
INSTALLER = Path(__file__).resolve().parents[1] / 'deploy' / 'windows' / 'installer'
|
||||
VERIFY_PY = INSTALLER / 'verify_bundle_lock.py'
|
||||
LOCK_PS1 = INSTALLER / 'bundle-lock.ps1'
|
||||
PWSH = shutil.which('pwsh') or shutil.which('powershell')
|
||||
|
||||
pytestmark = pytest.mark.skipif(not VERIFY_PY.exists(), reason='installer not in this tree')
|
||||
|
||||
|
||||
def build_bundle(root):
|
||||
"""A minimal bundle holding every REQUIRED payload directory."""
|
||||
for name, files in (
|
||||
('wheels', {'alembic-1.18.4-py3-none-any.whl': b'wheel-a',
|
||||
'cffi-2.1.0-cp314-cp314-win_amd64.whl': b'wheel-b'}),
|
||||
('python', {'python-3.14.6-amd64.exe': b'py'}),
|
||||
('httpplatformhandler', {'httpPlatformHandler_amd64.msi': b'hph'}),
|
||||
):
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True)
|
||||
for filename, content in files.items():
|
||||
(directory / filename).write_bytes(content)
|
||||
return root
|
||||
|
||||
|
||||
def write_lock(bundle, lock_path):
|
||||
"""Generate the lock the same way refresh-bundle-lock.ps1 does."""
|
||||
sys.path.insert(0, str(INSTALLER))
|
||||
import verify_bundle_lock as verifier
|
||||
|
||||
payloads = {}
|
||||
for name, required, _what in verifier.PAYLOADS:
|
||||
directory = bundle / name
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
payloads[name] = {'required': required, 'files': verifier.payload_files(str(directory))}
|
||||
lock_path.write_text(json.dumps({
|
||||
'schema': 1, 'generated': '2026-08-03T00:00:00Z',
|
||||
'pythontag': 'cp314', 'platform': 'win_amd64', 'payloads': payloads,
|
||||
}))
|
||||
|
||||
|
||||
def check_python(bundle, lock_path):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(VERIFY_PY), str(bundle), str(lock_path)],
|
||||
capture_output=True, text=True)
|
||||
problems = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
# Exit status and output must agree, or a caller that checks only one of them
|
||||
# gets a different answer from a caller that checks the other.
|
||||
assert (result.returncode != 0) == bool(problems), result.stdout
|
||||
return problems
|
||||
|
||||
|
||||
def check_powershell(bundle, lock_path):
|
||||
script = (
|
||||
". '%s'; $p = Test-BundleLock -BundleRoot '%s' -Lock (Read-BundleLock '%s'); "
|
||||
"if ($p) { $p -join \"`n\" }" % (LOCK_PS1, bundle, lock_path))
|
||||
result = subprocess.run([PWSH, '-NoProfile', '-Command', script],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def check_both(bundle, lock_path):
|
||||
"""Returns the Python verdict, asserting PowerShell reports the same set.
|
||||
|
||||
Compared as sets: both sort their output, but PowerShell's Sort-Object is
|
||||
culture-aware and Python's sorted() is ordinal, so the two can order the same
|
||||
findings differently. What must never differ is WHICH problems are found.
|
||||
"""
|
||||
problems = check_python(bundle, lock_path)
|
||||
if PWSH and LOCK_PS1.exists():
|
||||
assert sorted(check_powershell(bundle, lock_path)) == sorted(problems), (
|
||||
'verify_bundle_lock.py and bundle-lock.ps1 disagree')
|
||||
return problems
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def locked(tmp_path):
|
||||
bundle = build_bundle(tmp_path / 'bundle')
|
||||
lock = tmp_path / 'bundle-lock.json'
|
||||
write_lock(bundle, lock)
|
||||
return bundle, lock
|
||||
|
||||
|
||||
def test_untouched_bundle_passes(locked):
|
||||
bundle, lock = locked
|
||||
assert check_both(bundle, lock) == []
|
||||
|
||||
|
||||
def test_extra_file_fails(locked):
|
||||
"""The gap pip's own hash checking leaves: a stale wheel nobody asked for."""
|
||||
bundle, lock = locked
|
||||
(bundle / 'wheels' / 'stale-0.1-py3-none-any.whl').write_bytes(b'left over')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('stale-0.1' in p and 'NOT in the lock' in p for p in problems)
|
||||
|
||||
|
||||
def test_altered_file_fails(locked):
|
||||
bundle, lock = locked
|
||||
(bundle / 'wheels' / 'cffi-2.1.0-cp314-cp314-win_amd64.whl').write_bytes(b'swapped')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('does NOT match the lock' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_file_fails(locked):
|
||||
bundle, lock = locked
|
||||
(bundle / 'wheels' / 'alembic-1.18.4-py3-none-any.whl').unlink()
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('missing from the bundle' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_required_payload_fails(locked):
|
||||
bundle, lock = locked
|
||||
shutil.rmtree(bundle / 'python')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any(p.startswith('python/') for p in problems)
|
||||
|
||||
|
||||
def test_unlocked_payload_directory_fails(locked):
|
||||
"""An optional payload dropped in after the lock was made is still unreviewed."""
|
||||
bundle, lock = locked
|
||||
(bundle / 'mysql').mkdir()
|
||||
(bundle / 'mysql' / 'mysql-8.0.46-winx64.msi').write_bytes(b'msi')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('mysql/ is present but is not in bundle-lock.json' in p for p in problems)
|
||||
|
||||
|
||||
def test_absent_optional_payload_is_fine(locked):
|
||||
bundle, lock = locked
|
||||
assert not (bundle / 'mysql').exists()
|
||||
assert check_both(bundle, lock) == []
|
||||
|
||||
|
||||
def test_missing_lock_fails_closed(locked):
|
||||
"""No lock is a failure, not a pass. The build must not fall through to 'fine'."""
|
||||
bundle, lock = locked
|
||||
lock.unlink()
|
||||
result = subprocess.run([sys.executable, str(VERIFY_PY), str(bundle), str(lock)],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode != 0
|
||||
assert 'no bundle-lock.json' in result.stdout
|
||||
|
||||
|
||||
def test_malformed_lock_reports_rather_than_crashing(locked):
|
||||
"""A truncated lock must produce a sentence, not a stack trace.
|
||||
|
||||
shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
|
||||
that is not there throws. Both verifiers have to survive a lock missing the
|
||||
section they read first.
|
||||
"""
|
||||
bundle, lock = locked
|
||||
lock.write_text(json.dumps({'schema': 1, 'pythontag': 'cp314'}))
|
||||
problems = check_both(bundle, lock)
|
||||
assert problems == ['bundle-lock.json has no "payloads" section']
|
||||
|
||||
|
||||
def test_lock_missing_files_section_is_not_a_silent_pass(locked):
|
||||
"""A payload entry with no file list must not read as 'nothing expected, fine'."""
|
||||
bundle, lock = locked
|
||||
data = json.loads(lock.read_text())
|
||||
del data['payloads']['wheels']['files']
|
||||
lock.write_text(json.dumps(data))
|
||||
problems = check_both(bundle, lock)
|
||||
assert problems, 'an entry with no file list let every wheel through'
|
||||
assert all('wheels/' in p for p in problems)
|
||||
|
||||
|
||||
def test_wheelhouse_must_cover_requirements(locked):
|
||||
"""The regression that motivated the check.
|
||||
|
||||
A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
|
||||
sys_platform markers against the downloading machine. The lock alone cannot
|
||||
catch it, because the lock records what is there, not what is needed.
|
||||
"""
|
||||
bundle, lock = locked
|
||||
app = bundle / 'app'
|
||||
app.mkdir()
|
||||
(app / 'requirements.txt').write_text(
|
||||
"alembic==1.18.4 \\\n --hash=sha256:abc\n"
|
||||
"colorama==0.4.6 ; sys_platform == 'win32' \\\n --hash=sha256:def\n")
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('colorama==0.4.6' in p for p in problems), problems
|
||||
assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'
|
||||
|
||||
|
||||
def test_wheelhouse_coverage_normalizes_names(locked):
|
||||
"""mysql-connector-python pins as dashes and ships as mysql_connector_python."""
|
||||
bundle, lock = locked
|
||||
app = bundle / 'app'
|
||||
app.mkdir()
|
||||
(app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
|
||||
assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))
|
||||
|
||||
wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
|
||||
wheel.write_bytes(b'connector')
|
||||
write_lock(bundle, lock) # re-lock so the new wheel is not an 'extra'
|
||||
assert check_both(bundle, lock) == []
|
||||
|
||||
|
||||
def test_payload_list_matches_powershell():
|
||||
"""Both files enumerate the payload directories. Same names, same required
|
||||
flags, same order - a directory that is required in one and optional in the
|
||||
other means the builder and the installer disagree about what may be absent.
|
||||
"""
|
||||
sys.path.insert(0, str(INSTALLER))
|
||||
import verify_bundle_lock as verifier
|
||||
|
||||
declared = re.findall(r"Name\s*=\s*'([a-z]+)'\s*;\s*Required\s*=\s*\$(true|false)",
|
||||
LOCK_PS1.read_text())
|
||||
assert declared, 'could not find $script:BundlePayloads in bundle-lock.ps1'
|
||||
assert declared == [(name, str(required).lower()) for name, required, _ in verifier.PAYLOADS]
|
||||
55
tests/test_installer_defaults.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""The installer wizard's pre-ticked feature list must match the plugin manifests.
|
||||
|
||||
The wizard cannot read manifest.json - Inno's Pascal Script has no JSON parser -
|
||||
so the default set is a hardcoded list in ShopDBFlask.iss. It drifted: two plugins
|
||||
that ship "default_enabled": false were pre-ticked, so every site taking the
|
||||
defaults installed and enabled them against their own manifests.
|
||||
|
||||
A hardcoded list is fine; a hardcoded list nobody checks is not.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
ISS = REPO / 'deploy' / 'windows' / 'installer' / 'ShopDBFlask.iss'
|
||||
PLUGINS = REPO / 'plugins'
|
||||
|
||||
pytestmark = pytest.mark.skipif(not ISS.exists(), reason='installer not in this tree')
|
||||
|
||||
|
||||
def manifests_defaulting_off():
|
||||
off = set()
|
||||
for manifest in PLUGINS.glob('*/manifest.json'):
|
||||
try:
|
||||
data = json.loads(manifest.read_text())
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if data.get('default_enabled') is False:
|
||||
off.add(manifest.parent.name)
|
||||
return off
|
||||
|
||||
|
||||
def wizard_excludes():
|
||||
"""The names PluginDefault returns False for."""
|
||||
body = re.search(r'function PluginDefault.*?\nend;', ISS.read_text(), re.S)
|
||||
assert body, 'PluginDefault not found in ShopDBFlask.iss'
|
||||
return set(re.findall(r"Name <> '([a-z_]+)'", body.group(0)))
|
||||
|
||||
|
||||
def test_wizard_defaults_match_the_manifests():
|
||||
off = manifests_defaulting_off()
|
||||
assert off, 'no plugin declares default_enabled false - has the field moved?'
|
||||
assert wizard_excludes() == off, (
|
||||
'ShopDBFlask.iss PluginDefault disagrees with the manifests.\n'
|
||||
' manifests default_enabled=false: %s\n'
|
||||
' wizard leaves unticked: %s' % (sorted(off), sorted(wizard_excludes())))
|
||||
|
||||
|
||||
def test_every_wizard_exclusion_is_a_real_plugin():
|
||||
"""A typo in the .iss list silently pre-ticks the plugin it meant to exclude."""
|
||||
for name in wizard_excludes():
|
||||
assert (PLUGINS / name / 'manifest.json').exists(), (
|
||||
'%s is excluded by the wizard but has no manifest' % name)
|
||||
154
tests/test_sbom.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""The generated SBOM: shape, completeness, and the properties it is relied on for.
|
||||
|
||||
The point of shipping an SBOM to an air-gapped site is answering "are we exposed
|
||||
to this CVE, and where" without scanning the box. That only works if the document
|
||||
actually lists everything that ships, at the right versions, with identifiers a
|
||||
scanner recognises. These tests hold it to that.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
GENERATOR = REPO / 'scripts' / 'generate_sbom.py'
|
||||
FIXED_TIMESTAMP = '2026-01-01T00:00:00Z'
|
||||
|
||||
|
||||
def generate(tmp_path, name='sbom.cdx.json'):
|
||||
out = tmp_path / name
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR), str(REPO), '-o', str(out),
|
||||
'--timestamp', FIXED_TIMESTAMP],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(out.read_text()), out
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def sbom(tmp_path_factory):
|
||||
document, _ = generate(tmp_path_factory.mktemp('sbom'))
|
||||
return document
|
||||
|
||||
|
||||
def test_is_valid_cyclonedx(sbom):
|
||||
assert sbom['bomFormat'] == 'CycloneDX'
|
||||
assert sbom['specVersion'] == '1.6'
|
||||
assert sbom['serialNumber'].startswith('urn:uuid:')
|
||||
assert sbom['version'] == 1
|
||||
|
||||
|
||||
def test_carries_the_ntia_minimum_elements(sbom):
|
||||
"""Supplier, component name, version, unique identifier, dependency
|
||||
relationship, author, timestamp."""
|
||||
meta = sbom['metadata']
|
||||
assert meta['supplier']['name']
|
||||
assert meta['authors']
|
||||
assert meta['timestamp'] == FIXED_TIMESTAMP
|
||||
assert meta['component']['name'] and meta['component']['version']
|
||||
assert sbom['dependencies']
|
||||
for component in sbom['components']:
|
||||
assert component['name']
|
||||
assert component['version']
|
||||
assert component['purl'], '%s has no unique identifier' % component['name']
|
||||
|
||||
|
||||
def test_every_python_pin_is_present(sbom):
|
||||
"""The SBOM must not be quietly narrower than what the installer installs."""
|
||||
import re
|
||||
pinned = set()
|
||||
for line in (REPO / 'requirements.txt').read_text().splitlines():
|
||||
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line.strip())
|
||||
if match:
|
||||
pinned.add((match.group(1).lower().replace('_', '-'), match.group(2)))
|
||||
listed = {(c['name'].lower().replace('_', '-'), c['version'])
|
||||
for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
|
||||
assert pinned == listed
|
||||
|
||||
|
||||
def test_marked_out_dependencies_are_still_listed(sbom):
|
||||
"""colorama is win32-only. It installs on the target, so it must appear -
|
||||
the same blind spot that left it out of the wheelhouse."""
|
||||
names = {c['name'] for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
|
||||
assert 'colorama' in names
|
||||
|
||||
|
||||
def test_frontend_packages_are_covered(sbom):
|
||||
"""The npm tree is the reason this exists: nothing else records what version
|
||||
of leaflet or dompurify ends up inside the compiled SPA."""
|
||||
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
|
||||
assert len(npm) > 100
|
||||
for shipped in ('leaflet', 'dompurify', 'vue'):
|
||||
assert shipped in npm, '%s is missing from the SBOM' % shipped
|
||||
assert npm[shipped]['scope'] == 'required'
|
||||
|
||||
|
||||
def test_build_only_packages_are_marked_not_dropped(sbom):
|
||||
"""Dev packages do not ship. Recorded as 'excluded' rather than omitted, so a
|
||||
reader can tell 'not here' from 'not looked for'."""
|
||||
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
|
||||
assert npm['vite']['scope'] == 'excluded'
|
||||
assert any(c['scope'] == 'required' for c in npm.values())
|
||||
|
||||
|
||||
def test_bom_refs_are_unique(sbom):
|
||||
"""CycloneDX forbids duplicate bom-refs, and scanners reject a document that
|
||||
has them. npm installs the same package at several depths - node_modules/vite
|
||||
and node_modules/vitest/node_modules/vite - which emitted it twice."""
|
||||
refs = [c['bom-ref'] for c in sbom['components']]
|
||||
duplicates = {ref for ref in refs if refs.count(ref) > 1}
|
||||
assert not duplicates, 'duplicate bom-refs: %s' % sorted(duplicates)[:5]
|
||||
|
||||
|
||||
def test_a_package_present_outside_the_dev_tree_counts_as_shipped(tmp_path):
|
||||
"""Merging duplicates must not mark a shipped package build-only, which would
|
||||
hide it from a CVE search on the server."""
|
||||
sys.path.insert(0, str(REPO / 'scripts'))
|
||||
import generate_sbom
|
||||
|
||||
lock = tmp_path / 'package-lock.json'
|
||||
lock.write_text(json.dumps({'lockfileVersion': 3, 'packages': {
|
||||
'': {'name': 'x', 'version': '1.0.0', 'dependencies': {'shared': '^1'}},
|
||||
'node_modules/shared': {'version': '1.0.0'},
|
||||
'node_modules/builder': {'version': '2.0.0', 'dev': True},
|
||||
'node_modules/builder/node_modules/shared': {'version': '1.0.0', 'dev': True},
|
||||
}}))
|
||||
packages, direct = generate_sbom.parse_package_lock(str(lock))
|
||||
shared = [p for p in packages if p['name'] == 'shared']
|
||||
assert len(shared) == 1, 'the two copies of shared were not merged'
|
||||
assert shared[0]['dev'] is False
|
||||
assert direct == ['shared']
|
||||
|
||||
|
||||
def test_components_carry_integrity_hashes(sbom):
|
||||
missing = [c['name'] for c in sbom['components'] if not c.get('hashes')]
|
||||
assert not missing, 'components without a hash: %s' % missing[:5]
|
||||
|
||||
|
||||
def test_dependency_graph_is_real_not_flat(sbom):
|
||||
"""A flat 'root depends on everything' graph cannot answer what was chosen
|
||||
versus what was dragged in."""
|
||||
root = sbom['metadata']['component']['bom-ref']
|
||||
edges = {entry['ref']: entry['dependsOn'] for entry in sbom['dependencies']}
|
||||
assert root in edges
|
||||
assert len(edges) > 1, 'no edges below the root'
|
||||
assert len(edges[root]) < len(sbom['components']), 'root depends on everything'
|
||||
|
||||
refs = {c['bom-ref'] for c in sbom['components']}
|
||||
for ref, children in edges.items():
|
||||
if ref == root:
|
||||
continue
|
||||
assert ref in refs, 'edge from an unknown component: %s' % ref
|
||||
for child in children:
|
||||
assert child in refs, 'edge to an unknown component: %s' % child
|
||||
|
||||
|
||||
def test_output_is_byte_identical_across_runs(tmp_path):
|
||||
"""Regenerating must not churn. A document that differs every build gets
|
||||
re-committed without being read."""
|
||||
first, first_path = generate(tmp_path, 'a.json')
|
||||
_, second_path = generate(tmp_path, 'b.json')
|
||||
assert first_path.read_bytes() == second_path.read_bytes()
|
||||
assert first['serialNumber'] == json.loads(second_path.read_text())['serialNumber']
|
||||