diff --git a/.dockerignore b/.dockerignore index 609a1b746..384ba4b99 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,16 @@ +.git +node_modules +.venv env +__pycache__ +*.pyc +*.pyo enferno/media enferno/imports logs +backups +.env +.env.* +.DS_Store +*.md +docs/node_modules diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..0d6952b42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Vendored third-party library: collapse in diffs and exclude from language stats +enferno/static/js/tinymce/** -diff linguist-vendored diff --git a/.gitignore b/.gitignore index fea7c8a77..f63a36744 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,9 @@ backups/* cookies.txt *.egg-info/ + +# Release signing: NEVER commit secret keys. The pinned public key is baked +# into the installer/updater, not stored as a loose file in the repo. +*.key +bayanat-release.key +bayanat-release.pub diff --git a/.retireignore b/.retireignore new file mode 100644 index 000000000..f1ba3240d --- /dev/null +++ b/.retireignore @@ -0,0 +1 @@ +enferno/static/js/tinymce \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd75c710..a852d9f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v4.0.2 + +### Security + +- Bumped vulnerable dependencies in `uv.lock`: + - `urllib3` 2.6.3 → 2.7.0 (high, [GHSA-48p4-8xcf-vxj5](https://github.com/advisories/GHSA-48p4-8xcf-vxj5) sensitive headers forwarded across origins in proxied redirects; [GHSA-pq67-6m6q-mj2v](https://github.com/advisories/GHSA-pq67-6m6q-mj2v) decompression-bomb bypass in streaming API) + - `lxml` 6.0.2 → 6.1.0 ([GHSA-pp7h-53gx-mx7r](https://github.com/advisories/GHSA-pp7h-53gx-mx7r), high, XXE in `iterparse`/`ETCompatXMLParser`) + - `pillow` 12.1.1 → 12.2.0 ([GHSA-2vfv-wwj6-7q47](https://github.com/advisories/GHSA-2vfv-wwj6-7q47), high, FITS GZIP decompression bomb) + - `pypdf` 6.10.0 → 6.10.2 (medium, three RAM-exhaustion advisories) + - `python-dotenv` 1.2.1 → 1.2.2 (medium, symlink-following in `set_key`) + - `Mako` 1.3.10 → 1.3.11 (medium, path traversal in `TemplateLookup`) + - `pytest` 9.0.2 → 9.0.3 (dev, medium, vulnerable `tmpdir` handling) +- Bumped `axios` 1.15.0 → 1.16.0 (frontend dep, [GHSA-4hjh-wcwx-04pq](https://github.com/advisories/GHSA-4hjh-wcwx-04pq) DoS via large response). + +### Fixed + +- Admin "Reload" button now actually reloads the app. `uwsgi.ini` was missing the `touch-reload=reload.ini` directive, so the maintenance task touched the file with no effect on the running workers. After upgrading, existing installs should also append `touch-reload=reload.ini` to `/bayanat/uwsgi.ini` if they have local edits to that file. +- Allowed-extensions validator now accepts up to 5-character file extensions (previously capped at 4 characters). The cap rejected valid extensions like `mhtml`, `xhtml`, and `jhtml` from `MEDIA_ALLOWED_EXTENSIONS` and `SHEETS_ALLOWED_EXTENSIONS`. +- Restored the native browser PDF viewer for inline preview. + +## v4.0.1 + +### Fixed + +- Bulk OCR: celery worker now consumes the `ocr` queue. The systemd unit written by the installer was only subscribing to the default `celery` queue, so tasks dispatched by bulk OCR (UI and `flask ocr process`) silently piled up in Redis. Single-media OCR was not affected. Existing installs can fix in place by adding `-Q celery,ocr` to `ExecStart` in `/etc/systemd/system/bayanat-celery.service`, then `systemctl daemon-reload && systemctl restart bayanat-celery`. + ## v4.0.0 ### Database Migrations (Alembic) diff --git a/SAFE_EXPUNGING.md b/SAFE_EXPUNGING.md new file mode 100644 index 000000000..cdc2eb497 --- /dev/null +++ b/SAFE_EXPUNGING.md @@ -0,0 +1,43 @@ +# Safe Expunging Process + +This document describes when and how history-altering operations are permitted on the Bayanat source repository, satisfying the SLSA v1.2 Source track "Safe Expunging Process" requirement. + +## Scope + +Applies to the public repository `sjacorg/bayanat` and the private release repository `sjacorg/bayanat.prod`, specifically to operations that remove or rewrite committed history on protected references (`main`, release tags matching `v*`). + +## Default + +History on protected references is append-only. Force-push, branch deletion, tag deletion, and retagging are blocked by repository rulesets. + +## Permitted Reasons to Expunge + +Expunging may be approved only for one of the following reasons: + +1. **Secret leak.** An unredacted credential, private key, or access token was committed. +2. **Personal data leak.** Non-public personal data of an identifiable individual was committed. +3. **Legal or safety order.** A verified order from counsel or a credible safety concern requires removal of specific content. +4. **Malicious injection.** Attacker-introduced code or data must be removed as part of incident response. + +Bug fixes, style corrections, and cleanup are never valid reasons. + +## Approval + +Both maintainers must approve in writing, recorded in the security advisory created for the incident. + +## Procedure + +1. File a private security advisory at https://github.com/sjacorg/bayanat/security/advisories with the reason, affected commits, and proposed action. +2. Record both maintainer approvals in the advisory. +3. If the reason involves a secret, rotate it before rewriting. +4. Rewrite with `git filter-repo` (not `filter-branch`), preserving commit signatures where possible. +5. Temporarily bypass branch protection, force-update the protected reference, then re-enable protection. +6. Invalidate and regenerate any affected release tags. Old tags are not reused. + +## Consumer Notification + +After any expunging action, publish a public security advisory that includes: + +- What was removed and why (redacted as needed). +- New commit hashes and release tags that replace the expunged revisions. +- Operator guidance (re-clone, re-verify signatures, check deployed commit against the new history). diff --git a/bayanat b/bayanat index c5d350958..181be75bd 100755 --- a/bayanat +++ b/bayanat @@ -5,7 +5,7 @@ # set -euo pipefail -readonly SCRIPT_VERSION="1.2.0" +readonly SCRIPT_VERSION="1.3.0" readonly BAYANAT_ROOT="/opt/bayanat" readonly RELEASES_DIR="$BAYANAT_ROOT/releases" readonly SHARED_DIR="$BAYANAT_ROOT/shared" @@ -13,7 +13,27 @@ readonly LOGS_DIR="$BAYANAT_ROOT/logs" readonly CURRENT_LINK="$BAYANAT_ROOT/current" readonly DEFAULT_REPO="sjacorg/bayanat" readonly GIT_URL="https://github.com/${BAYANAT_REPO:-$DEFAULT_REPO}.git" +# Pinned SJAC release-signing key (BAY-01-017). Release tarballs are verified +# against this minisign public key before install; an unsigned or mismatched +# tarball is refused. Baked into this root-owned script so it cannot be swapped +# at update time. Rotation needs a fresh install or a documented manual swap +# (minisign has no revocation); see docs/deployment/release-signing.md. +readonly RELEASE_PUBKEY="RWS7XvDVF0InHWTCh/86K8sXGcHU/PmzCl4uH9GUDjNnNzHhcX1BvGqZ" +# Service accounts (BAY-01-032): web and worker run as separate system users. +# $APP_USER remains the deploy/database identity (PG role name, peer auth for +# admin operations) and the shared group that grants read access to releases. readonly APP_USER="bayanat" +readonly APP_GROUP="bayanat" +readonly WEB_USER="bayanat-web" +readonly CELERY_USER="bayanat-celery" +# App-writable runtime state shared between web and worker (config.json, +# reload/restart sentinels, celery beat schedule). Kept separate from +# $SHARED_DIR itself so .env never lives in a service-writable directory. +readonly RUNTIME_DIR="$SHARED_DIR/runtime" +# uv-managed Python interpreters go in a root-owned, world-readable location +# instead of /root, so the venv stays usable by the service accounts +# (releases are built by root, BAY-01-032). +export UV_PYTHON_INSTALL_DIR="/usr/local/share/uv/python" # --- Logging --- @@ -71,10 +91,13 @@ current_version() { } flask_run() { + # --no-sync: releases and their .venv are root-owned (BAY-01-032); deps + # are installed by _install_deps as root, so uv must not try to re-sync + # the environment while running as $APP_USER. local version="$1"; shift sudo -u "$APP_USER" \ - env FLASK_APP=run.py \ - /usr/local/bin/uv run \ + env FLASK_APP=run.py UV_PYTHON_INSTALL_DIR="$UV_PYTHON_INSTALL_DIR" \ + /usr/local/bin/uv run --no-sync \ --directory "$RELEASES_DIR/$version" \ flask "$@" } @@ -91,8 +114,9 @@ SNAPSHOT_RETENTION_DAYS="${BAYANAT_SNAPSHOT_RETENTION_DAYS:-30}" readonly SNAPSHOT_RETENTION_COUNT=5 _pg_load() { - # Loads POSTGRES_* from shared/.env into the current shell env WITHOUT - # eval. Must be invoked inside a subshell so the exports do not leak. + # Loads POSTGRES_* / REDIS_PASSWORD from shared/.env into the current + # shell env WITHOUT eval. Must be invoked inside a subshell so the + # exports do not leak. # # Why no eval: a malicious .env value (writable by the bayanat user) # could smuggle shell commands that would run as root under `bayanat @@ -103,7 +127,7 @@ _pg_load() { local key val while IFS='=' read -r key val; do case "$key" in - POSTGRES_HOST|POSTGRES_PORT|POSTGRES_USER|POSTGRES_PASSWORD|POSTGRES_DB) + POSTGRES_HOST|POSTGRES_PORT|POSTGRES_USER|POSTGRES_PASSWORD|POSTGRES_DB|REDIS_PASSWORD) # Strip matching surrounding quotes (common .env convention). if [[ ${#val} -ge 2 ]]; then if [[ "${val:0:1}" == '"' && "${val: -1}" == '"' ]] \ @@ -238,7 +262,6 @@ write_state() { # STATE_STARTED_AT / STATE_PROGRESS / STATE_ERROR_JSON from environment. local phase="$1" label="$2" mkdir -p "$STATE_DIR" - chown "$APP_USER:$APP_USER" "$STATE_DIR" 2>/dev/null || true local tmp="$STATE_FILE.tmp" cat > "$tmp" </dev/null || true } clear_state() { @@ -287,7 +309,6 @@ acquire_lock() { rm -f "$LOCK_FILE" fi echo $$ > "$LOCK_FILE" - chown "$APP_USER:$APP_USER" "$LOCK_FILE" 2>/dev/null || true } release_lock() { @@ -338,7 +359,7 @@ recover_state() { local snap prev snap=$(read_field snapshot || true) prev=$(read_field previous || true) - die "Operator intervention required. Snapshot: $snap Previous tag: $prev See: sudo -u $APP_USER bayanat snapshots" + die "Operator intervention required. Snapshot: $snap Previous tag: $prev See: bayanat snapshots" ;; MIGRATE|SWITCH|ROLLBACK) log "Recovery: $phase — attempting to restart services" @@ -364,9 +385,20 @@ recover_state() { # --- Health probe --- +_socket_path() { + # Hardened layout binds in /run/bayanat (releases are read-only, + # BAY-01-032); fall back to the legacy in-release socket for installs + # that predate it. + if [[ -S /run/bayanat/bayanat.sock ]]; then + echo /run/bayanat/bayanat.sock + else + echo "$CURRENT_LINK/bayanat.sock" + fi +} + _socket_health() { # curl the Flask /health over the unix socket. Returns 0 on HTTP 200. - curl -s --unix-socket "$CURRENT_LINK/bayanat.sock" \ + curl -s --unix-socket "$(_socket_path)" \ --max-time 3 -o /dev/null -w '%{http_code}' \ http://localhost/health 2>/dev/null | grep -q '^200$' } @@ -388,7 +420,16 @@ _db_ping() { } _redis_ping() { - redis-cli ping 2>/dev/null | grep -q '^PONG$' + # Redis requires auth on hardened installs (BAY-01-027). REDISCLI_AUTH + # keeps the password out of the process list, unlike -a. + ( + _pg_load + if [[ -n "${REDIS_PASSWORD:-}" ]]; then + REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli ping 2>/dev/null | grep -q '^PONG$' + else + redis-cli ping 2>/dev/null | grep -q '^PONG$' + fi + ) } _wait_healthy() { @@ -452,7 +493,7 @@ do_prepare() { [[ "$target" != "$current" ]] || die "already on $target" log "PREPARE: fetching $target" update_preflight - _clone_release "$target" + _fetch_release "$target" _install_deps "$target" _link_shared "$target" acquire_lock @@ -559,7 +600,8 @@ _install_system_packages() { git postgresql postgresql-contrib postgis redis-server \ python3 python3-dev build-essential \ libpq-dev libxml2-dev libxslt1-dev libssl-dev libffi-dev \ - libjpeg-dev libzip-dev libimage-exiftool-perl ffmpeg curl wget jq + libjpeg-dev libzip-dev libimage-exiftool-perl ffmpeg curl wget jq \ + minisign } _install_uv() { @@ -601,61 +643,207 @@ _setup_app_user() { fi log "Created user $APP_USER" fi - usermod -aG "$APP_USER" caddy 2>/dev/null || true + # Per-service accounts (BAY-01-032): uWSGI and Celery run as separate + # nologin users so a compromised worker cannot act as the web app. Both + # share $APP_GROUP for read access to releases and shared dirs. + local svc_user + for svc_user in "$WEB_USER" "$CELERY_USER"; do + if ! id "$svc_user" &>/dev/null; then + useradd --system --no-create-home -g "$APP_GROUP" \ + -s /usr/sbin/nologin "$svc_user" + log "Created service user $svc_user" + fi + done + usermod -aG "$APP_GROUP" caddy 2>/dev/null || true } _setup_database() { systemctl enable --now postgresql redis-server log "Setting up database..." - sudo -u postgres createuser -s "$APP_USER" 2>/dev/null || true + # Role is a plain owner with no instance-level privileges (BAY-01-031). + # Extensions are created by the postgres superuser below so the app role + # doesn't need that privilege. The role keeps table ownership because the + # dynamic-fields feature issues ALTER TABLE at runtime; a DML-only + # runtime role would break that product feature. + sudo -u postgres createuser "$APP_USER" 2>/dev/null || true + # Idempotent for upgrades: strip any instance-level privilege a previous + # install may have granted. + sudo -u postgres psql -c \ + "ALTER ROLE \"$APP_USER\" NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;" \ + 2>/dev/null || true sudo -u postgres createdb bayanat -O "$APP_USER" 2>/dev/null || true - - # Configure pg_hba trust auth for app user - local pg_hba + sudo -u postgres psql -d bayanat \ + -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" \ + -c "CREATE EXTENSION IF NOT EXISTS postgis;" >/dev/null + # Only the app role (and superuser) may connect to the app database. + sudo -u postgres psql \ + -c "REVOKE CONNECT ON DATABASE bayanat FROM PUBLIC;" \ + -c "GRANT CONNECT ON DATABASE bayanat TO \"$APP_USER\";" >/dev/null + + # Configure pg_hba peer auth for app user. Peer auth maps the OS user to + # the PG role over the local socket, so only mapped processes can connect + # as $APP_USER. Replaces the previous 'trust' rule which let any local OS + # user connect as the app role. The ident map admits the per-service + # accounts (BAY-01-032) plus $APP_USER itself for admin operations. + local pg_hba pg_ident pg_hba=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) [[ -n "$pg_hba" ]] || die "Cannot find pg_hba.conf" + pg_ident=$(find /etc/postgresql -name pg_ident.conf 2>/dev/null | head -1) + [[ -n "$pg_ident" ]] || die "Cannot find pg_ident.conf" - local rule="local all $APP_USER trust" + local os_user + for os_user in "$APP_USER" "$WEB_USER" "$CELERY_USER"; do + if ! grep -qE "^bayanat[[:space:]]+${os_user}[[:space:]]" "$pg_ident"; then + printf '%-15s %-23s %s\n' "bayanat" "$os_user" "$APP_USER" >> "$pg_ident" + fi + done + + local rule="local all $APP_USER peer map=bayanat" if grep -qF "$rule" "$pg_hba"; then log "pg_hba.conf already configured" else log "Configuring pg_hba.conf for $APP_USER" + # Remove any prior rule for this user from a previous install + sed -i "/^local[[:space:]]\+all[[:space:]]\+${APP_USER}[[:space:]]/d" "$pg_hba" # Insert before the "local all all" catch-all, or append if grep -q "^local.*all.*all" "$pg_hba"; then sed -i "/^local.*all.*all/i $rule" "$pg_hba" else echo "$rule" >> "$pg_hba" fi - systemctl reload postgresql fi + systemctl reload postgresql +} + +REDIS_PW="" + +_setup_redis() { + # Require password auth on the loopback Redis listener (BAY-01-027). + # The password is generated once, stored in shared/.env (read by the app + # via REDIS_PASSWORD), and asserted into redis.conf on every run. + local pw="" + if [[ -f "$SHARED_DIR/.env" ]]; then + pw=$(grep -E '^REDIS_PASSWORD=' "$SHARED_DIR/.env" | head -1 \ + | cut -d= -f2- | tr -d "'\"") || true + fi + if [[ -z "$pw" ]]; then + pw=$(openssl rand -hex 24) + if [[ -f "$SHARED_DIR/.env" ]]; then + printf "REDIS_PASSWORD='%s'\n" "$pw" >> "$SHARED_DIR/.env" + fi + fi + REDIS_PW="$pw" + + local conf="/etc/redis/redis.conf" + [[ -f "$conf" ]] || die "Cannot find $conf" + if grep -qE '^[[:space:]]*requirepass ' "$conf"; then + sed -i -E "s|^[[:space:]]*requirepass .*|requirepass $pw|" "$conf" + else + printf '\n# Managed by bayanat installer (BAY-01-027)\nrequirepass %s\n' "$pw" >> "$conf" + fi + systemctl restart redis-server } _create_directories() { - mkdir -p "$RELEASES_DIR" "$SHARED_DIR/media" "$SHARED_DIR/backups" \ + mkdir -p "$RELEASES_DIR" "$SHARED_DIR/media" "$SHARED_DIR/exports" \ + "$SHARED_DIR/imports" "$SHARED_DIR/backups" "$RUNTIME_DIR" \ "$LOGS_DIR" "$STATE_DIR" - chown -R "$APP_USER:$APP_USER" "$STATE_DIR" + # STATE_DIR stays root-owned (BAY-01-013): the updater writes it as root and + # the app only reads it. Keeping it out of the service user's reach removes + # the symlink-race chown primitive into root-owned files. + _secure_state_dir } -_clone_release() { +_secure_state_dir() { + # Root-owned updater state dir. World-readable so the app (service user) + # can read update.json for the status display, but not writable by it. + chown -R root:root "$STATE_DIR" + chmod 755 "$STATE_DIR" + [[ -f "$STATE_FILE" ]] && chmod 644 "$STATE_FILE" + return 0 +} + +_set_permissions() { + # Least-privilege install layout (BAY-01-030/032). Root owns the tree; + # service users reach it only through $APP_GROUP, and "other" is locked + # out everywhere. Top-level dirs deny traversal, so files inside need no + # recursive sweep (kept non-recursive for media, which can be large). + chown root:"$APP_GROUP" "$BAYANAT_ROOT" "$RELEASES_DIR" "$SHARED_DIR" + chmod 750 "$BAYANAT_ROOT" "$RELEASES_DIR" "$SHARED_DIR" + + # App-writable shared areas: setgid keeps new files in $APP_GROUP so the + # web and worker accounts can read each other's output. + local d + for d in "$SHARED_DIR/media" "$SHARED_DIR/exports" "$SHARED_DIR/imports" \ + "$SHARED_DIR/backups" "$RUNTIME_DIR" "$LOGS_DIR"; do + chown root:"$APP_GROUP" "$d" + chmod 2770 "$d" + done + + # Secrets: group-readable only (the services read them), never writable + # by the services and never world-readable. + local f + for f in "$SHARED_DIR/.env" "$SHARED_DIR/uwsgi-prod.ini"; do + if [[ -f "$f" ]]; then + chown root:"$APP_GROUP" "$f" + chmod 640 "$f" + fi + done + + # Log files created by admin CLI runs (umask 022) must stay appendable + # by both service accounts. + find "$LOGS_DIR" -type f -exec chgrp "$APP_GROUP" {} + -exec chmod 660 {} + 2>/dev/null || true + + _secure_state_dir +} + +# Fetch a release as a signed tarball and verify it before install (BAY-01-017). +# Replaces the old unsigned `git clone`: an unsigned or tampered release is +# refused, so a reachable update trigger can at worst install authentic SJAC +# code. SJAC attaches `bayanat-.tar.gz` + `.minisig` to each GitHub +# release; the signing procedure is in docs/deployment/release-signing.md. +_fetch_release() { local tag="$1" local dest="$RELEASES_DIR/$tag" + local asset="bayanat-$tag.tar.gz" + local url="https://github.com/${BAYANAT_REPO:-$DEFAULT_REPO}/releases/download/$tag/$asset" - if [[ -d "$dest/.git" ]]; then - if sudo -u "$APP_USER" git -C "$dest" rev-parse HEAD &>/dev/null; then - log "Release $tag already cloned" - return 0 - fi - warn "Partial clone detected, removing $dest" - rm -rf "$dest" - elif [[ -d "$dest" ]]; then - warn "Invalid release directory, removing $dest" - rm -rf "$dest" + # A complete release is marked done; anything else is a partial fetch. + if [[ -f "$dest/.bayanat-release" ]]; then + log "Release $tag already fetched" + return 0 fi - - log "Cloning $tag..." - git clone --depth 1 --branch "$tag" "$GIT_URL" "$dest" - chown -R "$APP_USER:$APP_USER" "$dest" + rm -rf "$dest" + + local tmp + tmp=$(mktemp -d) + + log "Downloading $tag..." + curl -fsSL --max-time 300 -o "$tmp/$asset" "$url" \ + || die "Could not download $asset from the $tag release" \ + "Confirm the release exists and ships a signed tarball asset." + curl -fsSL --max-time 60 -o "$tmp/$asset.minisig" "$url.minisig" \ + || die "Release $tag is unsigned (no $asset.minisig)" \ + "Refusing to install unverified code. Update manually only if you trust this release." + + log "Verifying signature against the SJAC release key..." + minisign -Vm "$tmp/$asset" -P "$RELEASE_PUBKEY" \ + || die "Signature verification FAILED for $tag" \ + "The tarball does not match SJAC's signing key. Refusing to install." + + log "Extracting $tag..." + mkdir -p "$dest" + tar -xzf "$tmp/$asset" --strip-components=1 -C "$dest" \ + || die "Failed to extract $asset" + touch "$dest/.bayanat-release" + rm -rf "$tmp" + + # Releases are root-owned and read-only for the service accounts + # (BAY-01-030/032): group gets read+exec via $APP_GROUP, no write, and + # "other" is locked out. + chown -R root:"$APP_GROUP" "$dest" + chmod -R o-rwx,g-w "$dest" } _generate_env() { @@ -680,12 +868,15 @@ FORCE_HTTPS=$force_https LOG_DIR=$LOGS_DIR BACKUPS_LOCAL_PATH=$SHARED_DIR/backups +BAYANAT_CONFIG_FILE=$RUNTIME_DIR/config.json POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=$APP_USER POSTGRES_PASSWORD= POSTGRES_DB=$APP_USER + +REDIS_PASSWORD='$REDIS_PW' EOF } @@ -695,18 +886,40 @@ _link_shared() { [[ -f "$SHARED_DIR/.env" ]] && ln -sfn "$SHARED_DIR/.env" "$release/.env" - if [[ -d "$release/enferno/media" ]] && [[ ! -L "$release/enferno/media" ]]; then - rm -rf "$release/enferno/media" - fi - ln -sfn "$SHARED_DIR/media" "$release/enferno/media" + # Releases are read-only for the services (BAY-01-032), so every + # app-written path is redirected into shared/. + local d + for d in media exports imports; do + if [[ -d "$release/enferno/$d" ]] && [[ ! -L "$release/enferno/$d" ]]; then + rm -rf "$release/enferno/$d" + fi + ln -sfn "$SHARED_DIR/$d" "$release/enferno/$d" + done + + # Sentinels the app touches to request a web reload / worker restart. + # uWSGI watches reload.ini (touch-reload); a systemd path unit watches + # restart-celery. Both live in the app-writable runtime dir. + mkdir -p "$RUNTIME_DIR" + local s + for s in reload.ini restart-celery; do + touch "$RUNTIME_DIR/$s" + chown root:"$APP_GROUP" "$RUNTIME_DIR/$s" + chmod 660 "$RUNTIME_DIR/$s" + ln -sfn "$RUNTIME_DIR/$s" "$release/$s" + done } _install_deps() { + # Runs as root: releases (including .venv) are read-only for the service + # accounts (BAY-01-032). local tag="$1" log "Installing Python dependencies..." - sudo -u "$APP_USER" \ - /usr/local/bin/uv sync --frozen --python 3.12 \ + /usr/local/bin/uv sync --frozen --python 3.12 \ --directory "$RELEASES_DIR/$tag" + # The venv is created after _fetch_release's ownership pass, so it needs + # its own: group-readable for the service accounts, not writable. + chown -R root:"$APP_GROUP" "$RELEASES_DIR/$tag/.venv" + chmod -R o-rwx,g-w "$RELEASES_DIR/$tag/.venv" } _init_database() { @@ -724,7 +937,40 @@ _init_database() { fi } +ADMIN_USERNAME="" +ADMIN_PASSWORD="" + +_bootstrap_admin() { + # Provision the initial admin out-of-band. Replaces the deleted + # /api/create-admin wizard endpoint, which was unauthenticated and + # claimable by the first network client during the install window. + # The password is fed to flask install over stdin (--password-stdin) + # so it is never visible in /proc//cmdline or `ps`. + local tag="$1" + local pw + pw=$(python3 -c "import secrets; print(secrets.token_urlsafe(20))" 2>/dev/null) || \ + pw=$(openssl rand -base64 24 | tr -d '/+=' | head -c 24) + + log "Bootstrapping initial admin user..." + local out + out=$(printf '%s\n' "$pw" \ + | flask_run "$tag" install --username admin --password-stdin 2>&1) || true + + if echo "$out" | grep -q "already installed"; then + log "Admin user already exists, skipping bootstrap" + elif echo "$out" | grep -q "installed successfully"; then + ADMIN_USERNAME="admin" + ADMIN_PASSWORD="$pw" + else + warn "Admin bootstrap unexpected output:" + warn "$out" + fi +} + _install_uwsgi_config() { + # Socket lives in /run/bayanat (systemd RuntimeDirectory): the release + # dir is read-only for the service user (BAY-01-032). touch-reload + # watches the shared runtime sentinel the app can write. cat > "$SHARED_DIR/uwsgi-prod.ini" << EOF [uwsgi] virtualenv=.venv @@ -732,27 +978,60 @@ module=run:app master=true processes=1 threads=2 -http-socket=$CURRENT_LINK/bayanat.sock +http-socket=/run/bayanat/bayanat.sock chmod-socket=660 vacuum=true buffer-size=8192 -touch-reload=reload.ini +touch-reload=$RUNTIME_DIR/reload.ini reload-mercy=5 worker-reload-mercy=3 EOF } _install_systemd() { + # Sandboxing directives (BAY-01-033): no privilege escalation, read-only + # host filesystem outside the explicit shared/log paths, isolated tmp and + # devices, restricted /proc and kernel interfaces, no retained + # capabilities. HOME points at the per-service cache dir for libraries + # that write font/model caches. + local hardening + hardening="NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=$SHARED_DIR $LOGS_DIR +PrivateTmp=true +PrivateDevices=true +ProtectProc=invisible +ProcSubset=pid +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +RestrictNamespaces=true +RestrictSUIDSGID=true +RestrictRealtime=true +LockPersonality=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +SystemCallFilter=@system-service +CapabilityBoundingSet=" + cat > /etc/systemd/system/bayanat.service << EOF [Unit] Description=Bayanat web application After=network.target postgresql.service redis.service [Service] -User=$APP_USER -Group=$APP_USER +User=$WEB_USER +Group=$APP_GROUP +UMask=0007 WorkingDirectory=$CURRENT_LINK EnvironmentFile=$SHARED_DIR/.env +Environment=HOME=/var/cache/bayanat-web +CacheDirectory=bayanat-web +RuntimeDirectory=bayanat +RuntimeDirectoryMode=0750 ExecStart=$CURRENT_LINK/.venv/bin/uwsgi --ini $SHARED_DIR/uwsgi-prod.ini Restart=always RestartSec=3 @@ -761,6 +1040,7 @@ KillSignal=SIGQUIT TimeoutStopSec=10 Type=notify NotifyAccess=all +$hardening [Install] WantedBy=multi-user.target @@ -772,16 +1052,43 @@ Description=Bayanat Celery worker After=network.target postgresql.service redis.service [Service] -User=$APP_USER -Group=$APP_USER +User=$CELERY_USER +Group=$APP_GROUP +UMask=0007 WorkingDirectory=$CURRENT_LINK EnvironmentFile=$SHARED_DIR/.env -ExecStart=$CURRENT_LINK/.venv/bin/celery -A enferno.tasks worker --autoscale 2,5 -B +Environment=HOME=/var/cache/bayanat-celery +CacheDirectory=bayanat-celery +ExecStart=$CURRENT_LINK/.venv/bin/celery -A enferno.tasks worker --autoscale 2,5 -B -Q celery,ocr -s $RUNTIME_DIR/celerybeat-schedule Restart=always RestartSec=3 +$hardening [Install] WantedBy=multi-user.target +EOF + + # Celery restart requests (BAY-01-032): the web app cannot (and must not) + # sudo; it touches the runtime sentinel instead and this path unit + # performs the restart as root. + cat > /etc/systemd/system/bayanat-celery-restart.path << EOF +[Unit] +Description=Watch for Bayanat Celery restart requests + +[Path] +PathChanged=$RUNTIME_DIR/restart-celery + +[Install] +WantedBy=multi-user.target +EOF + + cat > /etc/systemd/system/bayanat-celery-restart.service << 'EOF' +[Unit] +Description=Restart Bayanat Celery worker on request + +[Service] +Type=oneshot +ExecStart=/usr/bin/systemctl restart bayanat-celery.service EOF } @@ -791,7 +1098,7 @@ _configure_caddy() { if [[ "$domain" == "localhost" ]]; then cat > /etc/caddy/Caddyfile << 'EOF' :80 { - reverse_proxy unix//opt/bayanat/current/bayanat.sock + reverse_proxy unix//run/bayanat/bayanat.sock handle_path /static/* { root * /opt/bayanat/current/enferno/static file_server @@ -804,7 +1111,7 @@ EOF else cat > /etc/caddy/Caddyfile << EOF $domain { - reverse_proxy unix//opt/bayanat/current/bayanat.sock + reverse_proxy unix//run/bayanat/bayanat.sock handle_path /static/* { root * /opt/bayanat/current/enferno/static file_server @@ -817,33 +1124,6 @@ EOF fi } -_install_sudoers() { - cat > /etc/sudoers.d/bayanat << 'EOF' -bayanat ALL=(root) NOPASSWD: /usr/local/sbin/bayanat-start-update -bayanat ALL=(root) NOPASSWD: /usr/local/bin/bayanat status -bayanat ALL=(root) NOPASSWD: /usr/local/bin/bayanat snapshots -bayanat ALL=(root) NOPASSWD: /usr/bin/systemctl restart bayanat-celery -EOF - chmod 440 /etc/sudoers.d/bayanat - visudo -cf /etc/sudoers.d/bayanat >/dev/null || die "sudoers syntax invalid" -} - -_install_update_wrapper() { - # Root-owned wrapper. Launches `bayanat update` as a transient systemd - # unit so the update outlives Flask restart, SSH disconnect, and - # browser close. Must be in sudoers at this exact path. - install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/bayanat-start-update <<'EOF' -#!/bin/bash -# Installed root:root 0755 by `bayanat install`. Do not edit. -set -euo pipefail -exec /usr/bin/systemd-run \ - --unit=bayanat-update \ - --collect \ - --property=Restart=no \ - /usr/local/bin/bayanat update -EOF -} - _install_self() { # Copy the bayanat CLI from the given release directory to # /usr/local/bin/bayanat. Source is $RELEASES_DIR/$tag/bayanat, NOT $0 — @@ -874,6 +1154,7 @@ cmd_install() { # Users and database _setup_app_user _setup_database + _setup_redis # Application _create_directories @@ -883,18 +1164,19 @@ cmd_install() { [[ -n "$tag" ]] || die "No release tags found" log "Latest release: $tag" - _clone_release "$tag" + _fetch_release "$tag" if [[ ! -f "$SHARED_DIR/.env" ]]; then log "Generating .env..." _generate_env "$domain" fi - chown -R "$APP_USER:$APP_USER" "$BAYANAT_ROOT" + _set_permissions _link_shared "$tag" _install_deps "$tag" _init_database "$tag" + _bootstrap_admin "$tag" # Activate release swap_symlink "$RELEASES_DIR/$tag" @@ -902,24 +1184,46 @@ cmd_install() { # System integration _install_uwsgi_config - _install_systemd _configure_caddy "$domain" - _install_sudoers - _install_update_wrapper + _install_systemd _install_self "$tag" - chown -R "$APP_USER:$APP_USER" "$BAYANAT_ROOT" + # Services no longer get any sudo grant (BAY-01-032); drop the file a + # previous install may have written. + rm -f /etc/sudoers.d/bayanat + + _set_permissions systemctl daemon-reload - systemctl enable --now bayanat bayanat-celery + systemctl enable --now bayanat bayanat-celery bayanat-celery-restart.path systemctl restart caddy _verify_service_health - log "Installation complete" + local access_url if [[ "$domain" == "localhost" ]]; then - log "Access: http://$(hostname -I | awk '{print $1}')" + access_url="http://$(hostname -I | awk '{print $1}')" else - log "Access: https://$domain" + access_url="https://$domain" + fi + + log "Installation complete" + log "Access: $access_url" + + if [[ -n "$ADMIN_PASSWORD" ]]; then + log "" + log "============================================================" + log " Bayanat is ready. Sign in to finish setup:" + log "" + log " URL : $access_url/login" + log " Username : $ADMIN_USERNAME" + log " Password : $ADMIN_PASSWORD" + log "" + log " Save these credentials now - the password is not stored" + log " in plaintext anywhere. After signing in, the setup wizard" + log " will walk you through language, default data, and other" + log " configuration. Change the password from your account" + log " settings." + log "============================================================" fi } @@ -938,11 +1242,11 @@ _verify_service_health() { fi done - # Wait for app to respond on socket - local sock="$CURRENT_LINK/bayanat.sock" + # Wait for the app's /health (DB + Redis) to go green; a bare / would + # pass on a redirect even when the app cannot reach its backends. local i for ((i=1; i<=retries; i++)); do - if curl -sf --unix-socket "$sock" http://localhost/ -o /dev/null 2>/dev/null; then + if _socket_health; then log "Application responding" return 0 fi @@ -953,6 +1257,24 @@ _verify_service_health() { # --- Update --- +_ensure_runtime_layout() { + # Layout migration for installs that predate shared/runtime + # (BAY-01-030/032). New releases are root-owned, so config.json must move + # out of the release dir before the app loses write access to it. + mkdir -p "$RUNTIME_DIR" "$SHARED_DIR/exports" "$SHARED_DIR/imports" + local d + for d in "$RUNTIME_DIR" "$SHARED_DIR/exports" "$SHARED_DIR/imports"; do + chown root:"$APP_GROUP" "$d" + chmod 2770 "$d" + done + if ! grep -q '^BAYANAT_CONFIG_FILE=' "$SHARED_DIR/.env" 2>/dev/null; then + if [[ -f "$CURRENT_LINK/config.json" ]]; then + cp -p "$CURRENT_LINK/config.json" "$RUNTIME_DIR/config.json" + fi + echo "BAYANAT_CONFIG_FILE=$RUNTIME_DIR/config.json" >> "$SHARED_DIR/.env" + fi +} + cmd_update() { local flag="${1:-}" case "$flag" in @@ -977,12 +1299,13 @@ cmd_update() { esac require_root recover_state + _ensure_runtime_layout local target="${1:-}" if [[ -z "$target" ]]; then target=$(latest_remote_tag) fi # Keep $target verbatim. Tags and release dir names use the v-prefix form - # (e.g. v4.0.0), matching the installer's convention in _clone_release and + # (e.g. v4.0.0), matching the installer's convention in _fetch_release and # _link_shared. Stripping is only for display / comparison. do_prepare "$target" do_migrate diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 63a6225c2..1141bb99d 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,7 +1,10 @@ services: postgres: container_name: postgres - image: 'postgis/postgis:15-3.3' + image: 'postgis/postgis:16-3.5@sha256:a780ff6331b384e4c6d1033d5c42f4fe1270721b3e19260448241fa1b64a7637' + # postgis/postgis publishes amd64 only; runs emulated on ARM hosts + platform: linux/amd64 + user: postgres environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -14,7 +17,7 @@ services: security_opt: - no-new-privileges:true tmpfs: - - /var/run/postgresql + - /var/run/postgresql healthcheck: test: "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_USER}" interval: 3s @@ -22,17 +25,23 @@ services: redis: container_name: redis - image: 'redis:latest' + image: 'redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99' + user: redis expose: - '6379' - command: redis-server --requirepass '${REDIS_PASSWORD}' + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + # password is written to a config file in-container, never passed via argv + command: sh -c 'echo "requirepass $$REDIS_PASSWORD" > /tmp/redis.conf && exec redis-server /tmp/redis.conf' read_only: true security_opt: - no-new-privileges:true + tmpfs: + - /tmp volumes: - - 'redis_dev_data:/var/lib/redis/data:rw' + - 'redis_dev_data:/data:rw' healthcheck: - test: [ "CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping" ] + test: [ "CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG" ] interval: 3s retries: 10 @@ -43,7 +52,6 @@ services: dockerfile: ./flask/Dockerfile args: - ROLE=flask - - ENV_FILE=${ENV_FILE:-.env.dev} volumes: - 'bayanat_dev_backups:/app/backups/:rw' - 'bayanat_dev_media:/app/enferno/media/:rw' @@ -67,7 +75,6 @@ services: dockerfile: ./flask/Dockerfile args: - ROLE=celery - - ENV_FILE=${ENV_FILE:-.env.dev} volumes_from: - bayanat read_only: true diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 31c37a9b5..2364f54f2 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -1,7 +1,10 @@ services: postgres: container_name: postgres - image: 'postgis/postgis:15-3.3' + image: 'postgis/postgis:16-3.5@sha256:a780ff6331b384e4c6d1033d5c42f4fe1270721b3e19260448241fa1b64a7637' + # postgis/postgis publishes amd64 only; runs emulated on ARM hosts + platform: linux/amd64 + user: postgres environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -22,17 +25,23 @@ services: redis: container_name: redis - image: 'redis:latest' + image: 'redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99' + user: redis expose: - '6379' - command: redis-server --requirepass '${REDIS_PASSWORD}' + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + # password is written to a config file in-container, never passed via argv + command: sh -c 'echo "requirepass $$REDIS_PASSWORD" > /tmp/redis.conf && exec redis-server /tmp/redis.conf' read_only: true security_opt: - no-new-privileges:true + tmpfs: + - /tmp volumes: - - 'redis_test_data:/var/lib/redis/data:rw' + - 'redis_test_data:/data:rw' healthcheck: - test: [ "CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping" ] + test: [ "CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG" ] interval: 3s retries: 10 diff --git a/docker-compose.yml b/docker-compose.yml index 3d7cf788e..ae9a42b60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,11 @@ services: postgres: container_name: postgres - image: 'postgis/postgis:15-3.3' + # PG major upgrades (15 -> 16) require a dump/restore of postgres_data + image: 'postgis/postgis:16-3.5@sha256:a780ff6331b384e4c6d1033d5c42f4fe1270721b3e19260448241fa1b64a7637' + # postgis/postgis publishes amd64 only; runs emulated on ARM hosts + platform: linux/amd64 + user: postgres environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -22,36 +26,41 @@ services: redis: container_name: redis - image: 'redis:latest' + image: 'redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99' + user: redis expose: - '6379' - command: redis-server --requirepass '${REDIS_PASSWORD}' + # password is read from the mounted secret, never passed via argv + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + # password is written to a config file in-container, never passed via argv + command: sh -c 'echo "requirepass $$REDIS_PASSWORD" > /tmp/redis.conf && exec redis-server /tmp/redis.conf' read_only: true security_opt: - no-new-privileges:true + tmpfs: + - /tmp volumes: - - 'redis_data:/var/lib/redis/data:rw' + - 'redis_data:/data:rw' healthcheck: - test: [ "CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping" ] + test: [ "CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG" ] interval: 3s retries: 10 bayanat: container_name: bayanat - image: 'bayanat/bayanat:latest' build: context: . dockerfile: ./flask/Dockerfile args: - ROLE=flask - - ENV_FILE=${ENV_FILE:-.env} volumes: - '${PWD}/backups:/app/backups/:rw' - '${MEDIA_PATH:-./enferno/media}:/app/enferno/media/:rw' - '${PWD}/enferno/imports:/app/enferno/imports/:rw' - '${PWD}/logs/:/app/logs/:rw' - '${PWD}/config.json:/app/config.json:rw' - - '${PWD}/${ENV_FILE:-.env}:/app/.env:ro' + - '${PWD}/${ENV_FILE:-.env.docker}:/app/.env:ro' depends_on: postgres: condition: service_healthy @@ -72,7 +81,6 @@ services: dockerfile: ./flask/Dockerfile args: - ROLE=celery - - ENV_FILE=${ENV_FILE:-.env} volumes_from: - bayanat read_only: true @@ -93,7 +101,6 @@ services: dockerfile: ./flask/Dockerfile args: - ROLE=celery-ocr - - ENV_FILE=${ENV_FILE:-.env} volumes_from: - bayanat read_only: true @@ -112,9 +119,8 @@ services: restart: always build: context: ./nginx - target: prod ports: - - '80:80' + - '80:8080' volumes: - './enferno/static/:/app/static/:ro' depends_on: @@ -123,13 +129,11 @@ services: security_opt: - no-new-privileges:true tmpfs: - - /opt/bitnami/nginx/tmp/ - - /opt/bitnami/nginx/logs/ - - /opt/bitnami/nginx/conf/bitnami/certs/ + - /tmp healthcheck: - test: [ "CMD", "service", "nginx", "status" ] - interval: 3s - retries: 10 + test: [ "CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/healthz" ] + interval: 10s + retries: 5 volumes: redis_data: diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b18f6b7f7..e8a9ea1dd 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -59,6 +59,7 @@ export default withMermaid( { text: "Data Import", link: "/guide/data-import" }, { text: "Bulk Operations", link: "/guide/bulk-operations" }, { text: "Media Management", link: "/guide/media" }, + { text: "Redaction", link: "/guide/redaction" }, { text: "Notifications", link: "/guide/notifications" }, { text: "Map Visualization", link: "/guide/map-visualization" }, { text: "Dynamic Fields", link: "/guide/dynamic-fields" }, diff --git a/docs/deployment/auto-update-runbook.md b/docs/deployment/auto-update-runbook.md index f6a598588..b4c032079 100644 --- a/docs/deployment/auto-update-runbook.md +++ b/docs/deployment/auto-update-runbook.md @@ -43,6 +43,15 @@ and wait for a manual click. Caddy returns `502 Bad Gateway` during the maintenance window. Browsers retry automatically; partners see a brief "service unavailable" view. +## Release verification + +The updater downloads each release as a signed tarball and verifies it against +SJAC's pinned minisign key before installing (BAY-01-017). An unsigned or +tampered release is refused during PREPARE with `Release is unsigned` or +`Signature verification FAILED`, and nothing is installed. If you hit this on a +legitimate release, the release is missing its `.minisig` asset; see +[release-signing.md](release-signing.md). + ## If something goes wrong ### Migration failed (Alembic transaction rolled back) @@ -97,8 +106,6 @@ sudo bayanat update --recover | Path | Purpose | |---|---| | `/usr/local/bin/bayanat` | The CLI script | -| `/usr/local/sbin/bayanat-start-update` | Root wrapper the UI invokes via sudo | -| `/etc/sudoers.d/bayanat` | Granted commands for the `bayanat` user | | `/opt/bayanat/state/update.json` | Current update state (sanitized JSON) | | `/opt/bayanat/state/update.lock` | PID lock file | | `/opt/bayanat/shared/backups/` | Pre-update snapshots | @@ -106,10 +113,12 @@ sudo bayanat update --recover ## Admin UI surface -- Nav-bar banner chip: shows when `latest != current` -- Progress dialog: polls `/admin/api/updates/status` every 2 s during an - active update -- Settings toggle: System Administration -> "Auto-apply patch releases" +The UI is read-only for updates: it surfaces availability but never applies +an update. Updates run from the CLI as root (`sudo bayanat update`). + +- Nav-bar banner chip: shows when `latest != current`, with the CLI command + to run on the server +- Status: `/admin/api/updates/status` reflects a CLI-initiated update's state - Snapshots page: `/admin/snapshots/` (read-only list; restore stays on the CLI) diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 7652ec481..1a118b8a5 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -6,31 +6,53 @@ Docker Compose deployment is still in beta. For production environments, [native ## Prerequisites -- Docker and Docker Compose installed -- `.env` file configured (see [Configuration](/deployment/configuration)) +- Docker Engine with the Compose v2 plugin (`docker compose`, not the legacy `docker-compose` binary) +- `.env.docker` file configured (see [Configuration](/deployment/configuration)) ## Quick Start ```bash -docker-compose up -d +docker compose --env-file .env.docker up -d ``` This starts PostgreSQL, Redis, the Flask app, NGINX, and Celery. -## Create Admin User +::: tip +The `--env-file .env.docker` flag is required so Compose can substitute `${POSTGRES_USER}`, `${POSTGRES_PASSWORD}`, and `${REDIS_PASSWORD}` placeholders in `docker-compose.yml`. Without it, those services boot with empty credentials and the Flask container fails to connect. +::: + +## First Admin User + +The entrypoint creates an `admin` user automatically on the first startup +(when the database has no schema yet) and prints a one-time random +password to the container logs. Retrieve it with: ```bash -docker-compose exec bayanat uv run flask install +docker compose --env-file .env.docker logs bayanat | grep -A4 "Generated password" ``` +Sign in at the Bayanat URL with `admin` and the printed password. The +setup wizard runs after first login. Change the admin password from your +account settings afterwards. + +If the auto-bootstrap was missed or the admin account was deleted, run +the CLI directly: + +```bash +docker compose --env-file .env.docker exec bayanat uv run flask install -u admin +``` + +It generates a fresh password and prints it. If an admin already exists +the command exits without changing anything. + ## Development ```bash -docker-compose -f docker-compose-dev.yml up +docker compose -f docker-compose-dev.yml up ``` ## Testing ```bash -docker-compose -f docker-compose-test.yml up +docker compose -f docker-compose-test.yml up ``` diff --git a/docs/deployment/installation.md b/docs/deployment/installation.md index 49d20529d..ca918fce0 100644 --- a/docs/deployment/installation.md +++ b/docs/deployment/installation.md @@ -34,7 +34,7 @@ This will: - Set up systemd services for Bayanat and Celery - Start everything -Once complete, open your domain in a browser. The setup wizard will guide you through creating an admin account and configuring the application. +Once complete, the installer prints the initial `admin` username and a one-time generated password to the terminal. Open your domain in a browser, sign in with those credentials, then the setup wizard will guide you through configuring the application. Change the admin password from your account settings after first login. **Check status:** @@ -150,7 +150,7 @@ uv run flask install uv run flask run ``` -Access at [http://127.0.0.1:5000](http://127.0.0.1:5000). The setup wizard will guide further configuration. +Access at [http://127.0.0.1:5000](http://127.0.0.1:5000). Sign in with the credentials printed by `flask install`, then the setup wizard will guide further configuration. ::: warning `flask run` is development mode only. Continue with the steps below for production. @@ -243,14 +243,19 @@ sudo systemctl enable --now bayanat-celery Docker deployment is still in beta. For production, native deployment is recommended. ::: -After [configuring](/deployment/configuration) and generating a `.env` file: +After [configuring](/deployment/configuration) and generating a `.env.docker` file: ```bash -docker-compose up -d +docker compose --env-file .env.docker up -d ``` -Install the admin user: +The first startup creates an `admin` user and prints a generated +password to the container logs. Retrieve it with: ```bash -docker-compose exec bayanat uv run flask install +docker compose --env-file .env.docker logs bayanat | grep -A4 "Generated password" ``` + +If the auto-bootstrap was missed or the admin was deleted, run +`docker compose --env-file .env.docker exec bayanat uv run flask install -u admin` +to mint a fresh credential. diff --git a/docs/deployment/release-signing.md b/docs/deployment/release-signing.md new file mode 100644 index 000000000..0fb8854f5 --- /dev/null +++ b/docs/deployment/release-signing.md @@ -0,0 +1,80 @@ +# Release Signing + +Bayanat releases are signed with [minisign](https://jedisct1.github.io/minisign/). +The `bayanat` CLI verifies every release tarball against a pinned public key +before installing it, so `sudo bayanat update` (and the installer) will refuse +an unsigned or tampered release. This is the BAY-01-017 control. + +## What the updater expects + +For each GitHub release tagged `` (e.g. `v4.1.0`), two assets must be +attached: + +| Asset | What it is | +|---|---| +| `bayanat-.tar.gz` | the release source tree | +| `bayanat-.tar.gz.minisig` | its minisign signature | + +The updater downloads both, runs `minisign -V` against the pinned key, and only +extracts the tarball if the signature verifies. A missing `.minisig` is treated +as unsigned and refused. + +## The pinned key + +The verifying public key is baked into the `bayanat` script as `RELEASE_PUBKEY` +(root-owned, not swappable at update time): + +``` +RWS7XvDVF0InHWTCh/86K8sXGcHU/PmzCl4uH9GUDjNnNzHhcX1BvGqZ +``` + +key ID `1D274217D5F05EBB`. + +The matching **secret key is held offline** (never in CI, never in the repo). +It signs releases on a maintainer's machine. CI and GitHub only ever carry the +already-made `.minisig`. + +## Signing a release + +On the machine that holds the secret key, from a clean checkout at the tag: + +```bash +TAG=v4.1.0 + +# 1. Build the exact tree the tag points at. +git archive --format=tar.gz --prefix="bayanat-$TAG/" -o "bayanat-$TAG.tar.gz" "$TAG" + +# 2. Sign it (prompts for the key password). +minisign -Sm "bayanat-$TAG.tar.gz" + +# 3. Verify locally against the pinned public key before publishing. +minisign -Vm "bayanat-$TAG.tar.gz" -P RWS7XvDVF0InHWTCh/86K8sXGcHU/PmzCl4uH9GUDjNnNzHhcX1BvGqZ +``` + +Then attach `bayanat-$TAG.tar.gz` and `bayanat-$TAG.tar.gz.minisig` to the +GitHub release for `$TAG`. + +`git archive` is deterministic for a given tree, and the signature covers the +exact bytes you upload, so there is no cross-machine reproducibility +requirement: the updater verifies the same file you signed. + +## Key custody + +- Working copy: `~/.config/minisign/bayanat-release.key` (chmod 600). +- Password: stored in a password manager. minisign cannot regenerate the key + from the password alone, so the key file **and** the password must both + survive. Keep an encrypted backup. +- More than one maintainer should hold the key file and password (bus factor). + +## Rotation + +minisign has no revocation. If the key is lost or compromised, generate a new +keypair, update `RELEASE_PUBKEY` in the `bayanat` script, and ship the new +pinned key in a fresh install or a documented manual swap. Until a host runs a +`bayanat` build carrying the new key, it will keep trusting the old one, so a +rotation reaches existing hosts only through an update they install with the +old key still trusted, or through a manual key swap on the host. + +The manual update path always remains available, so a lost key never bricks an +install: an operator can still update the host by hand per +[upgrading.md](upgrading.md). diff --git a/docs/guide/redaction.md b/docs/guide/redaction.md new file mode 100644 index 000000000..b53c78b5c --- /dev/null +++ b/docs/guide/redaction.md @@ -0,0 +1,50 @@ +# Redaction + +Bayanat includes a built-in redaction tool for permanently removing sensitive information from documents and images, directly in the browser. Use it to black out names, faces, addresses, signatures, or any detail that must be protected before a file is shared, exported, or published. + +Redaction is available to Admin and Data Analyst roles, from any PDF or image attached to a Bulletin. + +## True Redaction, Not a Cover-Up + +The distinction matters for human rights work, where a leaked identity can put someone at risk. Bayanat does not simply draw a black box on top of the content: + +- **PDFs** — the text and graphics beneath each box are permanently deleted from the file, and document metadata is scrubbed. There is no hidden layer to copy, search, or peel back. +- **Images** — the selected regions are painted out and the image is re-encoded, so the original pixels are gone from the redacted file. + +What you see is what remains. Nothing sensitive survives underneath. + +## The Original Is Never Touched + +Redaction always produces a **separate redacted copy**. Your original, unredacted file stays intact and attached to the Bulletin as the authoritative master. This means you can: + +- Keep the complete evidence internally while sharing only a safe version +- Produce different redactions of the same source for different audiences +- Re-redact later if more information needs protecting, always working from the clean original + +::: tip Immutable by design +The original media can never be overwritten by the redaction tool. Edits only ever apply to a redacted copy. +::: + +## How to Redact a File + +1. Open a PDF or image from a Bulletin's media +2. Choose the redact option to open the redaction editor +3. Draw boxes over the regions to remove. For multi-page PDFs, move through the pages and mark regions on each. +4. Save as a redacted copy + +The new copy is created alongside the original, tagged with the **Redaction** media category and grouped with its source in the media view so the relationship is always clear. + +## Revising a Redacted Copy + +If you need to adjust a redaction, you can reopen a redacted copy and either save the result as another new copy or overwrite that copy in place. Overwriting is only ever permitted on a redacted copy; the original remains protected. + +## Supported Files + +- **Documents:** PDF +- **Images:** JPG, JPEG, PNG + +## Good Practice + +- Redact from the original each time rather than redacting a redaction, so you always start from a known-clean source. +- Review the saved copy before sharing it, especially multi-page documents, to confirm every sensitive region was covered. +- Share or export only the redacted copy. Keep the original within Bayanat under its normal access controls. diff --git a/enferno/admin/models/Actor.py b/enferno/admin/models/Actor.py index 968848586..0626ca08c 100644 --- a/enferno/admin/models/Actor.py +++ b/enferno/admin/models/Actor.py @@ -28,7 +28,7 @@ ) from enferno.admin.models.Country import Country from enferno.admin.models.Ethnography import Ethnography -from enferno.admin.models.utils import check_roles +from enferno.admin.models.utils import check_roles, can_view_media from enferno.extensions import db from enferno.utils.base import BaseMixin from enferno.utils.csv_utils import convert_simple_relation, convert_complex_relation @@ -456,75 +456,36 @@ def from_json(self, json: dict[str, Any]) -> "Actor": # Related Actors (actor_relations) if "actor_relations" in json: - # collect related actors ids (helps with finding removed ones) - rel_ids = [] - for relation in json["actor_relations"]: - actor = db.session.get(Actor, relation["actor"]["id"]) - - # Extra (check those actors exit) - - if actor: - rel_ids.append(actor.id) - # this will update/create the relationship (will flush to db!) - self.relate_actor(actor, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination actor not in the related ids - - for r in self.actor_relations: - # get related actor (in or out) - rid = r.get_other_id(self.id) - if not (rid in rel_ids): - r.delete() - - # -revision related - db.session.get(Actor, rid).create_revision() + self.sync_relations( + json["actor_relations"], + Actor, + "actor", + self.relate_actor, + self.actor_relations, + lambda r: r.get_other_id(self.id), + ) # Related Bulletins (bulletin_relations) if "bulletin_relations" in json: - # collect related bulletin ids (helps with finding removed ones) - rel_ids = [] - for relation in json["bulletin_relations"]: - bulletin = db.session.get(Bulletin, relation["bulletin"]["id"]) - - # Extra (check those bulletins exit) - if bulletin: - rel_ids.append(bulletin.id) - # this will update/create the relationship (will flush to db!) - self.relate_bulletin(bulletin, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination bulletin not in the related ids - for r in self.bulletin_relations: - if not (r.bulletin_id in rel_ids): - rel_bulletin = r.bulletin - r.delete() - - # -revision related - rel_bulletin.create_revision() - - # Related Incidents (incidents_relations) + self.sync_relations( + json["bulletin_relations"], + Bulletin, + "bulletin", + self.relate_bulletin, + self.bulletin_relations, + lambda r: r.bulletin_id, + ) + + # Related Incidents (incident_relations) if "incident_relations" in json: - # collect related incident ids (helps with finding removed ones) - rel_ids = [] - for relation in json["incident_relations"]: - incident = db.session.get(Incident, relation["incident"]["id"]) - if incident: - rel_ids.append(incident.id) - # helper method to update/create the relationship (will flush to db) - self.relate_incident(incident, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination incident no in the related ids - - for r in self.incident_relations: - # get related bulletin (in or out) - if not (r.incident_id in rel_ids): - rel_incident = r.incident - r.delete() - - # -revision related incident - rel_incident.create_revision() + self.sync_relations( + json["incident_relations"], + Incident, + "incident", + self.relate_incident, + self.incident_relations, + lambda r: r.incident_id, + ) if "comments" in json: self.comments = json["comments"] @@ -767,9 +728,9 @@ def to_dict(self, mode: Optional[str] = None) -> dict[str, Any]: for event in self.events: events_json.append(event.to_dict()) - # medias json + # medias json (hidden from users without media access, BAY-01-012) medias_json = [] - if self.medias and len(self.medias): + if can_view_media() and self.medias and len(self.medias): for media in self.medias: medias_json.append(media.to_dict()) diff --git a/enferno/admin/models/Bulletin.py b/enferno/admin/models/Bulletin.py index 333d58746..296fe0334 100644 --- a/enferno/admin/models/Bulletin.py +++ b/enferno/admin/models/Bulletin.py @@ -31,7 +31,7 @@ bulletin_verlabels, bulletin_events, ) -from enferno.admin.models.utils import check_roles +from enferno.admin.models.utils import check_roles, can_view_media logger = get_logger() @@ -61,6 +61,7 @@ class Bulletin(db.Model, BaseMixin): "User", backref="assigned_to_bulletins", foreign_keys=[assigned_to_id] ) description = db.Column(db.Text) + public_description = db.Column(db.Text) reliability_score = db.Column(db.Integer, default=0) @@ -311,6 +312,9 @@ def from_json(self, json: dict[str, Any]) -> "Bulletin": self.first_peer_reviewer_id = json["first_peer_reviewer"]["id"] self.description = json["description"] if "description" in json else None + self.public_description = ( + json["public_description"] if "public_description" in json else None + ) self.comments = json["comments"] if "comments" in json else None self.source_link = json["source_link"] if "source_link" in json else None self.source_link_type = json.get("source_link_type", False) @@ -411,74 +415,36 @@ def from_json(self, json: dict[str, Any]) -> "Bulletin": # Related Bulletins (bulletin_relations) if "bulletin_relations" in json: - # collect related bulletin ids (helps with finding removed ones) - rel_ids = [] - for relation in json["bulletin_relations"]: - bulletin = db.session.get(Bulletin, relation["bulletin"]["id"]) - # Extra (check those bulletins exit) - - if bulletin: - rel_ids.append(bulletin.id) - # this will update/create the relationship (will flush to db) - self.relate_bulletin(bulletin, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination bulletin no in the related ids - - for r in self.bulletin_relations: - # get related bulletin (in or out) - rid = r.get_other_id(self.id) - if not (rid in rel_ids): - r.delete() - - # ------- create revision on the other side of the relationship - db.session.get(Bulletin, rid).create_revision() - - # Related Actors (actors_relations) + self.sync_relations( + json["bulletin_relations"], + Bulletin, + "bulletin", + self.relate_bulletin, + self.bulletin_relations, + lambda r: r.get_other_id(self.id), + ) + + # Related Actors (actor_relations) if "actor_relations" in json: - # collect related bulletin ids (helps with finding removed ones) - rel_ids = [] - for relation in json["actor_relations"]: - actor = db.session.get(Actor, relation["actor"]["id"]) - if actor: - rel_ids.append(actor.id) - # helper method to update/create the relationship (will flush to db) - self.relate_actor(actor, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination actor no in the related ids - - for r in self.actor_relations: - # get related bulletin (in or out) - if not (r.actor_id in rel_ids): - rel_actor = r.actor - r.delete() - - # --revision relation - rel_actor.create_revision() - - # Related Incidents (incidents_relations) + self.sync_relations( + json["actor_relations"], + Actor, + "actor", + self.relate_actor, + self.actor_relations, + lambda r: r.actor_id, + ) + + # Related Incidents (incident_relations) if "incident_relations" in json: - # collect related incident ids (helps with finding removed ones) - rel_ids = [] - for relation in json["incident_relations"]: - incident = db.session.get(Incident, relation["incident"]["id"]) - if incident: - rel_ids.append(incident.id) - # helper method to update/create the relationship (will flush to db) - self.relate_incident(incident, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination incident no in the related ids - - for r in self.incident_relations: - # get related bulletin (in or out) - if not (r.incident_id in rel_ids): - rel_incident = r.incident - r.delete() - - # --revision relation - rel_incident.create_revision() + self.sync_relations( + json["incident_relations"], + Incident, + "incident", + self.relate_incident, + self.incident_relations, + lambda r: r.incident_id, + ) self.publish_date = json.get("publish_date", None) if self.publish_date == "": @@ -530,6 +496,7 @@ def to_compact(self) -> dict[str, Any]: "locations": locations_json, "sources": sources_json, "description": self.description or None, + "public_description": self.public_description or None, "source_link": self.source_link or None, "source_link_type": getattr(self, "source_link_type", False), "publish_date": DateHelper.serialize_datetime(self.publish_date), @@ -750,9 +717,9 @@ def to_dict(self, mode: Optional[str] = None) -> dict[str, Any]: for event in self.events: events_json.append(event.to_dict()) - # medias json + # medias json (hidden from users without media access, BAY-01-012) medias_json = [] - if self.medias and len(self.medias): + if can_view_media() and self.medias and len(self.medias): for media in self.medias: medias_json.append(media.to_dict()) @@ -801,6 +768,7 @@ def to_dict(self, mode: Optional[str] = None) -> dict[str, Any]: "actor_relations": actor_relations_dict, "incident_relations": incident_relations_dict, "description": self.description or None, + "public_description": self.public_description or None, "comments": self.comments or None, "source_link": self.source_link or None, "source_link_type": self.source_link_type or None, @@ -852,6 +820,7 @@ def to_mode2(self) -> dict[str, Any]: "locations": locations_json, "sources": sources_json, "description": self.description or None, + "public_description": self.public_description or None, "comments": self.comments or None, "source_link": self.source_link or None, "publish_date": DateHelper.serialize_datetime(self.publish_date), diff --git a/enferno/admin/models/Incident.py b/enferno/admin/models/Incident.py index 09bb06fd6..aeba2732e 100644 --- a/enferno/admin/models/Incident.py +++ b/enferno/admin/models/Incident.py @@ -283,76 +283,36 @@ def from_json(self, json: dict[str, Any]) -> "Incident": # Related Actors (actor_relations) if "actor_relations" in json and "check_ar" in json: - # collect related actors ids (helps with finding removed ones) - rel_ids = [] - for relation in json["actor_relations"]: - actor = db.session.get(Actor, relation["actor"]["id"]) - - # Extra (check those actors exit) - - if actor: - rel_ids.append(actor.id) - # this will update/create the relationship (will flush to db!) - self.relate_actor(actor, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination actor not in the related ids - - for r in self.actor_relations: - if not (r.actor_id in rel_ids): - rel_actor = r.actor - r.delete() - - # -revision related actor - rel_actor.create_revision() + self.sync_relations( + json["actor_relations"], + Actor, + "actor", + self.relate_actor, + self.actor_relations, + lambda r: r.actor_id, + ) # Related Bulletins (bulletin_relations) if "bulletin_relations" in json and "check_br" in json: - # collect related bulletin ids (helps with finding removed ones) - rel_ids = [] - for relation in json["bulletin_relations"]: - bulletin = db.session.get(Bulletin, relation["bulletin"]["id"]) - - # Extra (check those bulletins exit) - if bulletin: - rel_ids.append(bulletin.id) - # this will update/create the relationship (will flush to db!) - self.relate_bulletin(bulletin, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination bulletin not in the related ids - for r in self.bulletin_relations: - if not (r.bulletin_id in rel_ids): - rel_bulletin = r.bulletin - r.delete() - - # -revision related bulletin - rel_bulletin.create_revision() - - # Related Incidnets (incident_relations) + self.sync_relations( + json["bulletin_relations"], + Bulletin, + "bulletin", + self.relate_bulletin, + self.bulletin_relations, + lambda r: r.bulletin_id, + ) + + # Related Incidents (incident_relations) if "incident_relations" in json and "check_ir" in json: - # collect related incident ids (helps with finding removed ones) - rel_ids = [] - for relation in json["incident_relations"]: - incident = db.session.get(Incident, relation["incident"]["id"]) - # Extra (check those incidents exit) - - if incident: - rel_ids.append(incident.id) - # this will update/create the relationship (will flush to db) - self.relate_incident(incident, relation=relation) - - # Find out removed relations and remove them - # just loop existing relations and remove if the destination incident no in the related ids - - for r in self.incident_relations: - # get related incident (in or out) - rid = r.get_other_id(self.id) - if not (rid in rel_ids): - r.delete() - - # - revision related incident - db.session.get(Incident, rid).create_revision() + self.sync_relations( + json["incident_relations"], + Incident, + "incident", + self.relate_incident, + self.incident_relations, + lambda r: r.get_other_id(self.id), + ) if "comments" in json: self.comments = json["comments"] diff --git a/enferno/admin/models/Media.py b/enferno/admin/models/Media.py index e0d4c7fc9..1c6011581 100644 --- a/enferno/admin/models/Media.py +++ b/enferno/admin/models/Media.py @@ -1,5 +1,6 @@ import json import pathlib +import secrets from pathlib import Path from typing import Any from unidecode import unidecode @@ -102,6 +103,8 @@ def to_dict(self) -> dict[str, Any]: DateHelper.serialize_datetime(self.updated_at) if self.updated_at else None ), "extraction": self.extraction.to_compact_dict() if self.extraction else None, + "isRedaction": self.redaction is not None, + "originalMediaId": self.redaction.original_media_id if self.redaction else None, } def to_json(self) -> str: @@ -145,6 +148,20 @@ def generate_file_name(filename: str) -> str: decoded = secure_filename(unidecode(filename)).lower() return f"{DateHelper.utcnow().strftime('%Y%m%d-%H%M%S')}-{decoded}" + @staticmethod + def generate_inline_file_name(filename: str) -> str: + """Opaque, unguessable name for inline rich-text uploads (BAY-01-020). + + Inline media is served on a session-only route with no per-item access + check, so the old timestamp+basename name let any authenticated user + reconstruct a filename and fetch media for items they can't access. A + random token makes the URL a capability only held by viewers of the + (access-controlled) description that embeds it. + """ + decoded = secure_filename(unidecode(filename)).lower().rsplit(".", 1) + suffix = f".{decoded[1]}" if len(decoded) == 2 and decoded[1] else "" + return f"{secrets.token_urlsafe(24)}{suffix}" + @staticmethod def validate_file_extension(filepath: str, allowed_extensions: list[str]) -> bool: """ diff --git a/enferno/admin/models/MediaRedaction.py b/enferno/admin/models/MediaRedaction.py new file mode 100644 index 000000000..8192c3b8e --- /dev/null +++ b/enferno/admin/models/MediaRedaction.py @@ -0,0 +1,46 @@ +from typing import Any + +from enferno.extensions import db +from enferno.utils.base import BaseMixin +from enferno.utils.date_helper import DateHelper + + +class MediaRedaction(db.Model, BaseMixin): + __tablename__ = "media_redaction" + + id = db.Column(db.Integer, primary_key=True) + source_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=False, + index=True, + ) + original_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=True, + index=True, + ) + result_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=False, + index=True, + ) + regions = db.Column(db.JSON, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="SET NULL"), index=True) + + source_media = db.relationship("Media", foreign_keys=[source_media_id]) + original_media = db.relationship("Media", foreign_keys=[original_media_id]) + result_media = db.relationship("Media", foreign_keys=[result_media_id], backref=db.backref("redaction", uselist=False)) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "source_media_id": self.source_media_id, + "original_media_id": self.original_media_id, + "result_media_id": self.result_media_id, + "regions": self.regions, + "user_id": self.user_id, + "created_at": DateHelper.serialize_datetime(self.created_at), + } diff --git a/enferno/admin/models/__init__.py b/enferno/admin/models/__init__.py index a3b57dca8..71a3c33c9 100644 --- a/enferno/admin/models/__init__.py +++ b/enferno/admin/models/__init__.py @@ -38,6 +38,7 @@ from .LocationType import LocationType from .Media import Media from .MediaCategory import MediaCategory +from .MediaRedaction import MediaRedaction from .PotentialViolation import PotentialViolation from .Query import Query from .Settings import Settings diff --git a/enferno/admin/models/core_fields.py b/enferno/admin/models/core_fields.py index e8f760667..2a361ff72 100644 --- a/enferno/admin/models/core_fields.py +++ b/enferno/admin/models/core_fields.py @@ -11,55 +11,56 @@ ("tags", "Tags", "select", 3, {"schema_config": {"allow_multiple": True}}), ("sources", "Sources", "select", 4, {"schema_config": {"allow_multiple": True}}), ("description", "Description", "long_text", 5), + ("public_description", "Public Description", "long_text", 6), ( "labels", "Labels", "select", - 6, + 7, {"schema_config": {"allow_multiple": True}, "ui_config": {"width": "w-50"}}, ), ( "ver_labels", "Verified Labels", "select", - 7, + 8, {"schema_config": {"allow_multiple": True}, "ui_config": {"width": "w-50"}}, ), - ("locations", "Locations", "select", 8, {"schema_config": {"allow_multiple": True}}), + ("locations", "Locations", "select", 9, {"schema_config": {"allow_multiple": True}}), # HTML Blocks - complex components managed through dynamic fields - ("global_map", "Global Map", "html_block", 9, {"html_template": "global_map"}), - ("events_section", "Events", "html_block", 10, {"html_template": "events_section"}), - ("geo_locations", "Geo Locations", "html_block", 11, {"html_template": "geo_locations"}), + ("global_map", "Global Map", "html_block", 10, {"html_template": "global_map"}), + ("events_section", "Events", "html_block", 11, {"html_template": "events_section"}), + ("geo_locations", "Geo Locations", "html_block", 12, {"html_template": "geo_locations"}), ( "related_bulletins", "Related Bulletins", "html_block", - 12, + 13, {"html_template": "related_bulletins"}, ), - ("related_actors", "Related Actors", "html_block", 13, {"html_template": "related_actors"}), + ("related_actors", "Related Actors", "html_block", 14, {"html_template": "related_actors"}), ( "related_incidents", "Related Incidents", "html_block", - 14, + 15, {"html_template": "related_incidents"}, ), - ("source_link", "Source Link", "text", 15, {"ui_config": {"width": "w-50"}}), - ("publish_date", "Publish Date", "datetime", 16, {"ui_config": {"width": "w-50"}}), + ("source_link", "Source Link", "text", 16, {"ui_config": {"width": "w-50"}}), + ("publish_date", "Publish Date", "datetime", 17, {"ui_config": {"width": "w-50"}}), ( "documentation_date", "Documentation Date", "datetime", - 17, + 18, {"ui_config": {"width": "w-50", "align": "right"}}, ), - ("comments", "Comments", "long_text", 18, {"ui_config": {"width": "w-50"}}), + ("comments", "Comments", "long_text", 19, {"ui_config": {"width": "w-50"}}), ( "status", "Status", "select", - 19, + 20, {"schema_config": {"allow_multiple": False}, "ui_config": {"width": "w-50"}}, ), ] diff --git a/enferno/admin/models/utils.py b/enferno/admin/models/utils.py index a79857eb5..947841c5c 100644 --- a/enferno/admin/models/utils.py +++ b/enferno/admin/models/utils.py @@ -5,6 +5,20 @@ # Role based Access Control Decorator for Bulletins / Actors / Incidents # +def can_view_media(): + """Whether the current actor may see media metadata in entity payloads. + + CLI/Celery (no request context) are trusted. Otherwise mirror the + _require_media_access gate on the direct media endpoints: Admins and users + with can_access_media only. Without this, a user blocked from the media + endpoints could still read filenames, etags and OCR text embedded in a + parent bulletin/actor payload (BAY-01-012). + """ + if not has_request_context(): + return True + return current_user.has_role("Admin") or current_user.can_access_media + + def check_roles(method): """ Decorator to check if the current user has access to the resource. If the diff --git a/enferno/admin/templates/admin/activity.html b/enferno/admin/templates/admin/activity.html index 70a6899d8..3d9c992fd 100644 --- a/enferno/admin/templates/admin/activity.html +++ b/enferno/admin/templates/admin/activity.html @@ -194,6 +194,7 @@ + @@ -433,7 +434,8 @@ app.component('MediaGrid', MediaGrid); app.component('PdfViewer', PdfViewer); - app.component('DocxViewer', DocxViewer); + app.component('NativePdfViewer', NativePdfViewer); + app.component('DocxViewer', DocxViewer); app.component('ImageViewer', ImageViewer); app.component('InlineMediaRenderer', InlineMediaRenderer); diff --git a/enferno/admin/templates/admin/actors.html b/enferno/admin/templates/admin/actors.html index 18446b052..cbd43d099 100644 --- a/enferno/admin/templates/admin/actors.html +++ b/enferno/admin/templates/admin/actors.html @@ -6,6 +6,7 @@ {% endblock %} {% block content %} + {% include 'admin/partials/media_transcription_dialog.html' %} {% include 'admin/partials/actor_drawer.html' %} {% include 'admin/partials/bulk_actor_drawer.html' %} @@ -338,7 +339,7 @@ {% endblock %} {% block js %} @@ -402,8 +403,10 @@ + + @@ -448,6 +451,7 @@ valid: false, leftDialogProps: null, rightDialogProps: null, + redaction: { dialog: false, media: null }, translations: window.translations, validationRules: validationRules, advFeatures: ('{{ config.ADV_ANALYSIS }}' === 'True'), @@ -833,6 +837,41 @@ this.refreshEditedMedia(media.id); if (this.$route.params.id) this.showActor(this.$route.params.id); }, + openRedactor(media) { + this.redaction.media = media; + this.redaction.dialog = true; + }, + removeRedaction(redaction) { + if (!redaction.isRedaction) { + this.showSnack(window.translations.onlyRedactionsCanBeDeleted_); + return; + } + api.delete(`/admin/api/media/${redaction.id}/redact`) + .then(() => { + this.onRedactionDeleted(redaction); + }) + .catch(err => { + this.showSnack(handleRequestError(err)); + }); + }, + refreshRedactionState() { + const id = this.$route.params.id || this.actor?.id; + if (id) { + this.showActor(id); + } else if (this.editedItem?.id) { + api.get(`/admin/api/actor/${this.editedItem.id}`).then(response => { + this.editedItem.medias = response.data?.medias; + }).catch(err => this.showSnack(handleRequestError(err))); + } + }, + onRedactionSaved() { + this.showSnack("{{ _('Redaction saved') }}"); + this.refreshRedactionState(); + }, + onRedactionDeleted() { + this.showSnack("{{ _('Redaction deleted') }}"); + this.refreshRedactionState(); + }, handleRoute({ initialLoad = false } = {}) { if (this.$route.params.id) this.showActor(this.$route.params.id); if (this.$route.query.reltob) return this.filter_related_to_bulletin(this.$route.query.reltob); @@ -1802,6 +1841,7 @@ app.component('MediaThumbnail', MediaThumbnail); app.component('Visualization', Visualization); app.component('PdfViewer', PdfViewer); + app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.component('SnapshotDialog', SnapshotDialog); app.component('PreviewCard', PreviewCard); @@ -1814,6 +1854,7 @@ app.component('FieldRenderer', FieldRenderer); app.component('OcrTextLayer', OcrTextLayer); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); + app.component('MediaRedactor', MediaRedactor); app.component('RelatedBulletinsCard', RelatedBulletinsCard); app.component('RelatedActorsCard', RelatedActorsCard); diff --git a/enferno/admin/templates/admin/bulletins.html b/enferno/admin/templates/admin/bulletins.html index 0ce557e67..0263ef312 100644 --- a/enferno/admin/templates/admin/bulletins.html +++ b/enferno/admin/templates/admin/bulletins.html @@ -11,6 +11,7 @@ + {% include 'admin/partials/media_transcription_dialog.html' %} {% include 'admin/partials/bulletin_advsearch.html' %} {% include 'admin/partials/bulletin_drawer.html' %} @@ -370,7 +371,7 @@ {% block js %} @@ -423,6 +424,7 @@ + @@ -430,6 +432,7 @@ + @@ -477,6 +480,7 @@ data: () => ({ itemsPerPageOptions: window.itemsPerPageOptions, helper: {}, // helper object for review items + redaction: { dialog: false, media: null }, valid: false, leftDialogProps: { @@ -674,6 +678,7 @@ editedIndex: -1, editedItem: { title: "", + public_description: "", events: [], medias: [], bulletin_relations: [], @@ -683,6 +688,7 @@ defaultItem: { title: "", description: "", + public_description: "", // related events events: [], @@ -913,6 +919,43 @@ this.refreshEditedMedia(media.id); if (this.$route.params.id) this.showBulletin(this.$route.params.id); }, + openRedactor(media) { + this.redaction.media = media; + this.redaction.dialog = true; + }, + removeRedaction(redaction) { + if (!redaction.isRedaction) { + this.showSnack(window.translations.onlyRedactionsCanBeDeleted_); + return; + } + api.delete(`/admin/api/media/${redaction.id}/redact`) + .then(() => { + this.onRedactionDeleted(redaction); + }) + .catch(err => { + this.showSnack(handleRequestError(err)); + }); + }, + refreshRedactionState() { + const id = this.$route.params.id || this.bulletin?.id; + if (id) { + // Media redacted on bulletin drawer, refresh the bulletin to get the updated media list + this.showBulletin(id); + } else if (this.editedItem?.id) { + // Media redacted on bulletin editor dialog, refresh the bulletin to get the updated media list + api.get(`/admin/api/bulletin/${this.editedItem.id}`).then(response => { + this.editedItem.medias = response.data?.medias; + }).catch(err => this.showSnack(handleRequestError(err))); + } + }, + onRedactionSaved() { + this.showSnack("{{ _('Redaction saved') }}"); + this.refreshRedactionState(); + }, + onRedactionDeleted() { + this.showSnack("{{ _('Redaction deleted') }}"); + this.refreshRedactionState(); + }, handleRoute({ initialLoad = false } = {}) { if (this.$route.params.id) this.showBulletin(this.$route.params.id); if (this.$route.query.reltob) return this.filter_related_to_bulletin(this.$route.query.reltob); @@ -1343,29 +1386,9 @@ showBulletin(id) { this.bulletinLoader = true - - // Save current s3urls before reload - const s3urlCache = {}; - if (this.bulletin?.medias) { - this.bulletin.medias.forEach(media => { - if (media.s3url) { - s3urlCache[media.id] = media.s3url; - } - }); - } - api.get(`/admin/api/bulletin/${id}?mode=3`).then(response => { this.bulletin = response.data; this.bulletinDrawer = true; - - // Restore s3urls after reload - if (this.bulletin?.medias) { - this.bulletin.medias.forEach(media => { - if (s3urlCache[media.id]) { - media.s3url = s3urlCache[media.id]; - } - }); - } }).catch(error => { this.bulletinDrawer = false; }).finally(() => { @@ -1793,6 +1816,7 @@ app.component('MediaThumbnail', MediaThumbnail); app.component('Visualization', Visualization); app.component('PdfViewer', PdfViewer); + app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.component('SnapshotDialog', SnapshotDialog); app.component('PreviewCard', PreviewCard); @@ -1813,6 +1837,7 @@ app.component('EventsSection', EventsSection); app.component('FieldRenderer', FieldRenderer); app.component('OcrTextLayer', OcrTextLayer); + app.component('MediaRedactor', MediaRedactor); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); app.use(router).use(vuetify); diff --git a/enferno/admin/templates/admin/incidents.html b/enferno/admin/templates/admin/incidents.html index 047f61920..b092f2adb 100644 --- a/enferno/admin/templates/admin/incidents.html +++ b/enferno/admin/templates/admin/incidents.html @@ -301,7 +301,7 @@ {% block js %} @@ -361,6 +361,7 @@ + @@ -1562,6 +1563,7 @@ app.component('MediaThumbnail', MediaThumbnail); app.component('Visualization', Visualization); app.component('PdfViewer', PdfViewer); + app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.component('ImageViewer', ImageViewer); app.component('InlineMediaRenderer', InlineMediaRenderer); diff --git a/enferno/admin/templates/admin/jsapi.jinja2 b/enferno/admin/templates/admin/jsapi.jinja2 index 49fc98622..d95b84d17 100644 --- a/enferno/admin/templates/admin/jsapi.jinja2 +++ b/enferno/admin/templates/admin/jsapi.jinja2 @@ -361,6 +361,7 @@ originid_: "{{ _('Origin ID') }}", edit_: "{{ _('Edit') }}", visualize_: "{{ _('Visualize') }}", description_ : "{{ _('Description') }}", +publicDescription_ : "{{ _('Public Description') }}", title_: "{{ _('Title') }}", originalTitle_: "{{ _('Original Title') }}", sources_: "{{ _('Sources') }}", @@ -626,6 +627,11 @@ etag_ : "{{ _('File Hash') }}", filename_ : "{{ _('Filename') }}", previewNotAvailable_ : "{{ _('Preview not available') }}", downloadFile_ : "{{ _('Download file') }}", +onlyRedactionsCanBeDeleted_: "{{ _('Only redactions can be deleted') }}", +redact_: "{{ _('Redact') }}", +youreAboutToDeleteARedactedCopy_: "{{ _('You\'re about to delete a redacted copy') }}", +deletingTheRedactedCopyWillNotDeleteTheOriginalMedia_: "{{ _('Deleting this redacted copy will not delete the original media.') }}", +deleteRedaction_: "{{ _('Delete Redaction') }}", // System Administration defaultsLoaded_: "{{ _('Default settings loaded. Click Save to apply changes.') }}", @@ -842,4 +848,3 @@ translations['whisperModels'] = [ {"en": "{{ model['model_label'] }}", "tr": "{{ _(model['model_label']) }}"}, {% endfor %} ]; - diff --git a/enferno/admin/templates/admin/locations.html b/enferno/admin/templates/admin/locations.html index c357a4f62..16f5dca4f 100644 --- a/enferno/admin/templates/admin/locations.html +++ b/enferno/admin/templates/admin/locations.html @@ -147,7 +147,7 @@ {% endblock %} {% block js %} diff --git a/enferno/admin/templates/admin/media-dashboard.html b/enferno/admin/templates/admin/media-dashboard.html index 4a2d015bd..411aebad8 100644 --- a/enferno/admin/templates/admin/media-dashboard.html +++ b/enferno/admin/templates/admin/media-dashboard.html @@ -48,6 +48,12 @@ + + ${formatDate(item.updated_at)} + + @@ -234,6 +256,8 @@ + + @@ -255,6 +279,8 @@ drawer: drawer, loading: true, bulkLoading: false, + isCurrentUserAdmin: window.__isAdmin__ || false, + isCurrentUserDA: window.__isDA__ || false, itemsPerPageOptions: window.itemsPerPageOptions, hasOcrProvider: Boolean('{{ config.OCR_PROVIDER }}'), processingIds: new Set(), // Track media IDs being processed @@ -270,7 +296,8 @@ { title: "{{_('Filename')}}", value: "filename" }, { title: "{{_('Bulletin')}}", value: "bulletin", sortable: false }, { title: "{{_('OCR Status')}}", value: "ocr_status" }, - { title: "{{_('Date')}}", value: "updated_at" } + { title: "{{_('Date')}}", value: "updated_at" }, + { title: "{{_('Actions')}}", value: "actions", sortable: false, align: "end" } ], selected: [], @@ -289,6 +316,10 @@ bulletin_id: null, dateRange: null }, + redaction: { + dialog: false, + media: null + }, lowWordCount: 20 }), @@ -497,6 +528,24 @@ // Also fetch processing IDs in case this item just finished this.fetchProcessingIds(); }, + canRedact(item) { + if (!this.isCurrentUserAdmin && !this.isCurrentUserDA) return false; + const fileType = item.fileType || ''; + const filename = item.filename || ''; + return fileType.includes('pdf') + || fileType.includes('image') + || /\.(pdf|jpe?g|png)$/i.test(filename); + }, + openRedactor(item) { + if (!this.canRedact(item)) return; + this.redaction.media = item; + this.redaction.dialog = true; + }, + onMediaRedacted() { + this.showSnack("{{ _('Redaction saved') }}"); + this.refresh(this.options); + this.loadOCRStats(); + }, loadOCRStats() { api.get('/admin/api/ocr/stats').then(res => { this.ocrStats = res?.data; @@ -537,6 +586,8 @@ app.component('OcrTextLayer', OcrTextLayer); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); app.component('PdfViewer', PdfViewer); + app.component('MediaRedactor', MediaRedactor); + app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.component('ImageViewer', ImageViewer); app.component('UniField', UniField); @@ -551,4 +602,4 @@ window.app = app; }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/enferno/admin/templates/admin/partials/bulletin_dialog.html b/enferno/admin/templates/admin/partials/bulletin_dialog.html index 5fe840ae9..9808cfea6 100644 --- a/enferno/admin/templates/admin/partials/bulletin_dialog.html +++ b/enferno/admin/templates/admin/partials/bulletin_dialog.html @@ -104,8 +104,13 @@ :multiple="true" label="{{ _('Sources') }}">
+
{{ _('Description') }}
+
+
{{ _('Public Description') }}
+ +
diff --git a/enferno/admin/validation/models.py b/enferno/admin/validation/models.py index 65057f6e2..b6abb5619 100644 --- a/enferno/admin/validation/models.py +++ b/enferno/admin/validation/models.py @@ -246,6 +246,7 @@ class BulletinValidationModel(StrictValidationModel): assigned_to: Optional[PartialUserModel] = None first_peer_reviewer: Optional[PartialUserModel] = None description: Optional[SanitizedField] = None + public_description: Optional[SanitizedField] = None comments: str = Field(min_length=1) source_link: str = Field(min_length=1) source_link_type: Optional[bool] = None @@ -1704,7 +1705,7 @@ def validate_allowed_extensions(cls, v): raise ValueError( "MEDIA_ALLOWED_EXTENSIONS and SHEETS_ALLOWED_EXTENSIONS must be lists of strings" ) - if len(ext) < 2 or len(ext) > 4: + if len(ext) < 2 or len(ext) > 5: raise ValueError( "Invalid value for MEDIA_ALLOWED_EXTENSIONS or SHEETS_ALLOWED_EXTENSIONS" ) @@ -1902,7 +1903,6 @@ class FullConfigValidationModel(ConfigValidationModel): SECURITY_FRESHNESS: int = Field(gt=0) SECURITY_FRESHNESS_GRACE_PERIOD: int = Field(ge=0) DISABLE_MULTIPLE_SESSIONS: bool - AUTO_APPLY_PATCH_UPDATES: bool = False RECAPTCHA_ENABLED: bool RECAPTCHA_PUBLIC_KEY: Optional[str] = None RECAPTCHA_PRIVATE_KEY: Optional[str] = None @@ -2086,7 +2086,11 @@ def validate_url(cls, v: HttpUrl) -> str: if domain.startswith("www."): domain = domain[4:] allowed_domains = Config.get("YTDLP_ALLOWED_DOMAINS") - if not any(domain.endswith(allowed) for allowed in allowed_domains): + # Match the registered domain or a real subdomain, not any suffix + # (BAY-01-014): plain endswith let "evilyoutube.com" pass "youtube.com". + if not any( + domain == allowed or domain.endswith("." + allowed) for allowed in allowed_domains + ): raise ValueError(f"Imports not allowed from {domain}") return str(v) diff --git a/enferno/admin/views/__init__.py b/enferno/admin/views/__init__.py index a50c38de7..915a6dc44 100644 --- a/enferno/admin/views/__init__.py +++ b/enferno/admin/views/__init__.py @@ -3,7 +3,7 @@ import os from functools import wraps -from flask import Blueprint, g, request +from flask import Blueprint, current_app, g, request from flask_security.decorators import auth_required, current_user from enferno.admin.models import Activity @@ -100,6 +100,45 @@ def has_role_assignment_permission(roles: list) -> bool: return True +def fresh_auth(func): + """Require a freshly-authenticated session for privileged mutations. + + The freshness window is taken from the operator-configured + SECURITY_FRESHNESS / SECURITY_FRESHNESS_GRACE_PERIOD settings rather than a + hardcoded value (BAY-01-016), so admins re-authenticate before sensitive + state changes even on an otherwise-valid but stale session. + """ + return auth_required( + within=lambda: current_app.config["SECURITY_FRESHNESS"], + grace=lambda: current_app.config["SECURITY_FRESHNESS_GRACE_PERIOD"], + )(func) + + +PEER_REVIEW_LOCKED_STATUS = "Peer Review Assigned" + + +def reject_if_review_locked(item, entity: str, item_id): + """Block non-Admin edits to an item frozen for peer review (BAY-01-022). + + Once an item enters "Peer Review Assigned" the owner must not modify it via + the normal update API; the reviewer acts through the review endpoint and an + Admin can override. Returns a forbidden Response when locked, else None. + """ + if getattr(item, "status", None) == PEER_REVIEW_LOCKED_STATUS and not current_user.has_role( + "Admin" + ): + Activity.create( + current_user, + Activity.ACTION_UPDATE, + Activity.STATUS_DENIED, + request.json, + entity, + details=f"Attempt to edit {entity} {item_id} locked for peer review.", + ) + return HTTPResponse.forbidden("Item is locked for peer review") + return None + + @admin.before_request @auth_required("session") def before_request() -> None: @@ -119,7 +158,7 @@ def ctx() -> dict: Returns: - dict of users """ - users = User.query.order_by(User.username).all() + users = User.query.order_by(User.username).all() # noqa: F811 if current_user and current_user.is_authenticated: users = [u.to_compact() for u in users] return {"users": users} diff --git a/enferno/admin/views/actors.py b/enferno/admin/views/actors.py index d1040072e..becf7256e 100644 --- a/enferno/admin/views/actors.py +++ b/enferno/admin/views/actors.py @@ -25,7 +25,7 @@ from enferno.utils.search_utils import SearchUtils from enferno.utils.validation_utils import validate_with import enferno.utils.typing as t -from . import admin, PER_PAGE, REL_PER_PAGE, can_assign_roles +from . import admin, PER_PAGE, REL_PER_PAGE, can_assign_roles, reject_if_review_locked # Actor fields routes @@ -154,15 +154,9 @@ def api_actors(validated_data: dict) -> Response: "name": item.name, "name_ar": item.name_ar, "status": item.status, - "assigned_to": ( - {"id": item.assigned_to.id, "name": item.assigned_to.name} - if item.assigned_to - else None - ), + "assigned_to": (item.assigned_to.to_compact() if item.assigned_to else None), "first_peer_reviewer": ( - {"id": item.first_peer_reviewer.id, "name": item.first_peer_reviewer.name} - if item.first_peer_reviewer - else None + item.first_peer_reviewer.to_compact() if item.first_peer_reviewer else None ), "roles": ( [ @@ -293,6 +287,11 @@ def api_actor_update(id: t.id, validated_data: dict) -> Response: is_urgent=True, ) return HTTPResponse.forbidden("Restricted Access") + + review_locked = reject_if_review_locked(actor, "actor", id) + if review_locked: + return review_locked + actor = actor.from_json(validated_data["item"]) # Create a revision using latest values # this method automatically commits @@ -404,6 +403,7 @@ def api_actor_bulk_update( if not current_user.has_role("Admin"): # silently discard access roles bulk.pop("roles", None) + bulk.pop("rolesReplace", None) if ids and len(bulk): job = bulk_update_actors.delay(ids, bulk, current_user.id) diff --git a/enferno/admin/views/bulletins.py b/enferno/admin/views/bulletins.py index f6b9a62a3..feb10afcd 100644 --- a/enferno/admin/views/bulletins.py +++ b/enferno/admin/views/bulletins.py @@ -25,7 +25,7 @@ from enferno.utils.search_utils import SearchUtils from enferno.utils.validation_utils import validate_with import enferno.utils.typing as t -from . import admin, PER_PAGE, REL_PER_PAGE, can_assign_roles +from . import admin, PER_PAGE, REL_PER_PAGE, can_assign_roles, reject_if_review_locked # Bulletin fields routes @@ -139,15 +139,9 @@ def api_bulletins(validated_data: dict) -> Response: "sjac_title": item.sjac_title, "sjac_title_ar": item.sjac_title_ar, "status": item.status, - "assigned_to": ( - {"id": item.assigned_to.id, "name": item.assigned_to.name} - if item.assigned_to - else None - ), + "assigned_to": (item.assigned_to.to_compact() if item.assigned_to else None), "first_peer_reviewer": ( - {"id": item.first_peer_reviewer.id, "name": item.first_peer_reviewer.name} - if item.first_peer_reviewer - else None + item.first_peer_reviewer.to_compact() if item.first_peer_reviewer else None ), "roles": ( [ @@ -279,6 +273,16 @@ def api_bulletin_update(id: t.id, validated_data: dict) -> Response: ) return HTTPResponse.forbidden("Restricted Access") + review_locked = reject_if_review_locked(bulletin, "bulletin", id) + if review_locked: + return review_locked + + # Non-Admin owners cannot reassign or set reviewers via the normal update + # (BAY-01-022); assignment goes through the assign endpoint. + if not current_user.has_role("Admin"): + for field in ("assigned_to", "first_peer_reviewer", "second_peer_reviewer"): + validated_data["item"].pop(field, None) + bulletin = bulletin.from_json(validated_data["item"]) bulletin.create_revision() @@ -397,6 +401,7 @@ def api_bulletin_bulk_update( if not current_user.has_role("Admin"): # silently discard access roles bulk.pop("roles", None) + bulk.pop("rolesReplace", None) if ids and len(bulk): job = bulk_update_bulletins.delay(ids, bulk, current_user.id) diff --git a/enferno/admin/views/history.py b/enferno/admin/views/history.py index 99c19322b..9d95cd4c1 100644 --- a/enferno/admin/views/history.py +++ b/enferno/admin/views/history.py @@ -1,13 +1,38 @@ from __future__ import annotations from flask import Response +from flask_security.decorators import current_user from sqlalchemy import desc -from enferno.admin.models import BulletinHistory, ActorHistory, IncidentHistory, LocationHistory +from enferno.admin.models import ( + Activity, + Actor, + ActorHistory, + Bulletin, + BulletinHistory, + Incident, + IncidentHistory, + LocationHistory, +) +from enferno.extensions import db from enferno.utils.http_response import HTTPResponse import enferno.utils.typing as t from . import admin, require_view_history + +def _deny_history(parent_label: str, parent_id: int) -> Response: + """Log a denied history view and return a forbidden response.""" + Activity.create( + current_user, + Activity.ACTION_VIEW, + Activity.STATUS_DENIED, + {"id": parent_id}, + parent_label, + details=f"Unauthorized attempt to view history of restricted {parent_label} {parent_id}.", + ) + return HTTPResponse.forbidden("Restricted Access") + + # Bulletin History Helpers @@ -23,6 +48,12 @@ def api_bulletinhistory(bulletinid: t.id) -> Response: Returns: - json feed of item's history / error. """ + bulletin = db.session.get(Bulletin, bulletinid) + if not bulletin: + return HTTPResponse.not_found("Bulletin not found") + if not current_user.can_access(bulletin): + return _deny_history("bulletin", bulletinid) + result = ( BulletinHistory.query.filter_by(bulletin_id=bulletinid) .order_by(desc(BulletinHistory.created_at)) @@ -49,6 +80,12 @@ def api_actorhistory(actorid: t.id) -> Response: Returns: - json feed of item's history / error. """ + actor = db.session.get(Actor, actorid) + if not actor: + return HTTPResponse.not_found("Actor not found") + if not current_user.can_access(actor): + return _deny_history("actor", actorid) + result = ( ActorHistory.query.filter_by(actor_id=actorid).order_by(desc(ActorHistory.created_at)).all() ) @@ -72,6 +109,12 @@ def api_incidenthistory(incidentid: t.id) -> Response: Returns: - json feed of item's history / error. """ + incident = db.session.get(Incident, incidentid) + if not incident: + return HTTPResponse.not_found("Incident not found") + if not current_user.can_access(incident): + return _deny_history("incident", incidentid) + result = ( IncidentHistory.query.filter_by(incident_id=incidentid) .order_by(desc(IncidentHistory.created_at)) diff --git a/enferno/admin/views/incidents.py b/enferno/admin/views/incidents.py index 9bff00f73..078c80df4 100644 --- a/enferno/admin/views/incidents.py +++ b/enferno/admin/views/incidents.py @@ -25,7 +25,7 @@ from enferno.utils.search_utils import SearchUtils from enferno.utils.validation_utils import validate_with import enferno.utils.typing as t -from . import admin, PER_PAGE, REL_PER_PAGE, can_assign_roles +from . import admin, PER_PAGE, REL_PER_PAGE, can_assign_roles, reject_if_review_locked # Incident fields routes @@ -162,15 +162,9 @@ def api_incidents(validated_data: dict) -> Response: "title": item.title, "title_ar": item.title_ar, "status": item.status, - "assigned_to": ( - {"id": item.assigned_to.id, "name": item.assigned_to.name} - if item.assigned_to - else None - ), + "assigned_to": (item.assigned_to.to_compact() if item.assigned_to else None), "first_peer_reviewer": ( - {"id": item.first_peer_reviewer.id, "name": item.first_peer_reviewer.name} - if item.first_peer_reviewer - else None + item.first_peer_reviewer.to_compact() if item.first_peer_reviewer else None ), "roles": ( [ @@ -305,6 +299,10 @@ def api_incident_update(id: t.id, validated_data: dict) -> Response: ) return HTTPResponse.forbidden("Restricted Access") + review_locked = reject_if_review_locked(incident, "incident", id) + if review_locked: + return review_locked + incident = incident.from_json(validated_data["item"]) # Create a revision using latest values diff --git a/enferno/admin/views/media.py b/enferno/admin/views/media.py index 22c5554ba..48d58761b 100644 --- a/enferno/admin/views/media.py +++ b/enferno/admin/views/media.py @@ -4,6 +4,7 @@ import shutil from datetime import datetime, timedelta from functools import wraps +from hashlib import md5 from typing import Optional import boto3 @@ -24,13 +25,19 @@ from sqlalchemy import func, desc from werkzeug.utils import safe_join, secure_filename -from enferno.admin.models import Media, Activity, Extraction +from enferno.admin.models import Media, Activity, Extraction, MediaRedaction, MediaCategory from enferno.admin.models.tables import bulletin_roles, actor_roles from enferno.extensions import db, rds from enferno.utils.date_helper import DateHelper from enferno.utils.data_helpers import get_file_hash from enferno.utils.http_response import HTTPResponse from enferno.utils.logging_utils import get_logger +from enferno.utils.redaction_utils import ( + RedactionError, + redact_image_bytes, + redact_pdf_bytes, + rotate_rect_to_original, +) from enferno.utils.text_utils import normalize_arabic from enferno.utils.validation_utils import validate_with from enferno.admin.validation.models import MediaRequestModel @@ -111,6 +118,61 @@ def _media_url(media_file): ) +def _read_media_bytes(media: Media) -> bytes: + if current_app.config.get("FILESYSTEM_LOCAL"): + filepath = safe_join(str(Media.media_dir), media.media_file) + if not filepath or not os.path.exists(filepath): + raise FileNotFoundError(media.media_file) + with open(filepath, "rb") as f: + return f.read() + + s3 = boto3.client( + "s3", + config=BotoConfig(signature_version="s3v4"), + aws_access_key_id=current_app.config["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=current_app.config["AWS_SECRET_ACCESS_KEY"], + region_name=current_app.config["AWS_REGION"], + ) + return s3.get_object(Bucket=current_app.config["S3_BUCKET"], Key=media.media_file)[ + "Body" + ].read() + + +def _write_media_bytes(filename: str, data: bytes, content_type: str) -> None: + if current_app.config.get("FILESYSTEM_LOCAL"): + filepath = safe_join(str(Media.media_dir), filename) + if not filepath: + raise ValueError("Invalid media filename") + with open(filepath, "wb") as f: + f.write(data) + return + + s3 = boto3.resource( + "s3", + aws_access_key_id=current_app.config["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=current_app.config["AWS_SECRET_ACCESS_KEY"], + region_name=current_app.config["AWS_REGION"], + ) + s3.Bucket(current_app.config["S3_BUCKET"]).put_object( + Key=filename, + Body=data, + ContentType=content_type, + ) + + +def _file_extension(media: Media) -> str: + return os.path.splitext(media.media_file)[1].lower().lstrip(".") + + +def _duplicate_redacted_media(etag: str, source: Media) -> Media | None: + query = Media.query.filter(Media.etag == etag, Media.deleted == False) + if source.bulletin_id is not None: + query = query.filter(Media.bulletin_id == source.bulletin_id) + elif source.actor_id is not None: + query = query.filter(Media.actor_id == source.actor_id) + return query.first() + + # Media dashboard page route @admin.route("/media/", defaults={"id": None}) @admin.route("/media/") @@ -130,8 +192,10 @@ def api_medias_chunk() -> Response: """ file = request.files["file"] - # Check if upload is from the media import tool (Admin-only extended extensions) - import_upload = request.form.get("source") == "import" + # Check if upload is from the media import tool (Admin-only extended extensions). + # The source param lives in the query string because Dropzone drops `params` + # on chunked POSTs, so a form-body check returns None on every chunk. + import_upload = request.args.get("source") == "import" # validate file extensions based on user and source if import_upload: # uploads from media import tool @@ -324,6 +388,7 @@ def api_medias_upload() -> Response: # return signed url from s3 valid for some time @admin.route("/api/media/") +@_require_media_access def serve_media( filename: str, ) -> Response: @@ -411,6 +476,7 @@ def serve_media( @admin.route("/api/serve/media/") +@_require_media_access def api_local_serve_media( filename: str, ) -> Response: @@ -458,6 +524,7 @@ def api_local_serve_media( @admin.route("/api/media//proxy") @auth_required() +@_require_media_access def api_media_proxy(id: int) -> Response: """Proxy media file through Flask -- ensures same-origin inline display for PDFs.""" media = Media.query.get(id) @@ -509,13 +576,14 @@ def api_inline_medias_upload() -> Response: f"File exceeds maximum allowed size of {max_size_mb} MB", status=413 ) - # final file - filename = Media.generate_file_name(f.filename) + # final file: opaque, unguessable name so inline media can't be + # enumerated/reconstructed by other users (BAY-01-020) + filename = Media.generate_inline_file_name(f.filename) filepath = (Media.inline_dir / filename).as_posix() f.save(filepath) - response = {"location": filename} - return HTTPResponse.success(data=response) + # TinyMCE requires a flat {"location": "..."} response, not wrapped in {"data": ...} + return jsonify({"location": filename}) except Exception as e: logger.error(e, exc_info=True) return HTTPResponse.error("Request Failed", status=500) @@ -540,6 +608,7 @@ def api_local_serve_inline_media(filename: str) -> Response: @admin.get("/api/media/") @auth_required("session") +@_require_media_access def api_media_get(id: int): """Get a single media item by ID with extraction and bulletin info.""" media = Media.query.get(id) @@ -586,14 +655,14 @@ def api_media_update(id: t.id, validated_data: dict) -> Response: if media is None: return HTTPResponse.not_found("Media not found") - if not current_user.can_access(media): + if not current_user.can_edit(media): Activity.create( current_user, Activity.ACTION_VIEW, Activity.STATUS_DENIED, validated_data, "media", - details="Unauthorized attempt to update restricted media.", + details="Unauthorized attempt to update media outside edit boundary.", ) return HTTPResponse.forbidden("Restricted Access") @@ -611,6 +680,156 @@ def api_media_update(id: t.id, validated_data: dict) -> Response: return HTTPResponse.error("Error updating Media", status=500) +REDACTION_CATEGORY = "Redaction" + + +def _redaction_category_id() -> int: + """Resolve (creating once if missing) the category that tags redacted copies.""" + category = MediaCategory.find_by_title(REDACTION_CATEGORY) + if category is None: + category = MediaCategory(title=REDACTION_CATEGORY) + db.session.add(category) + db.session.flush() + return category.id + + +@admin.post("/api/media//redact") +@roles_accepted("Admin", "DA") +def api_media_redact(id: int) -> Response: + media = Media.query.get(id) + if media is None: + return HTTPResponse.not_found("Media not found") + if not current_user.can_access(media): + return HTTPResponse.forbidden("Restricted Access") + + payload = request.get_json(silent=True) or {} + pages = payload.get("pages", []) + title = (payload.get("title") or "").strip() + # Overwrite is only allowed on an existing redacted copy (never the immutable original). + overwrite = bool(payload.get("overwrite")) and media.redaction is not None + ext = _file_extension(media) + + try: + src = _read_media_bytes(media) + if ext == "pdf": + out = redact_pdf_bytes(src, pages) + out_ext = "pdf" + out_type = "application/pdf" + elif ext in {"jpg", "jpeg", "png"} or (media.media_file_type or "").startswith("image/"): + orientation = media.orientation or 0 + rects = [ + rotate_rect_to_original(rect, orientation) + for page in pages + for rect in page.get("rects", []) + ] + out = redact_image_bytes(src, rects) + out_ext = "jpg" + out_type = "image/jpeg" + else: + return HTTPResponse.error("This file type cannot be redacted", status=415) + except RedactionError as e: + return HTTPResponse.error(str(e), status=400) + except FileNotFoundError: + return HTTPResponse.not_found("Media file not found") + + etag = md5(out).hexdigest() + + # Edit in place: re-burn onto the same redacted copy, overwriting its file and row. + # The original stays untouched, so this is always safe and reversible from the original. + if overwrite: + _write_media_bytes(media.media_file, out, out_type) + media.etag = etag + media.media_file_type = out_type + if title: + media.title = title + media.redaction.source_media_id = media.id + media.redaction.regions = pages + media.redaction.user_id = current_user.id + db.session.commit() + Activity.create( + current_user, + Activity.ACTION_UPDATE, + Activity.STATUS_SUCCESS, + media.to_mini(), + "media", + details=f"Redacted copy {media.id} updated in place", + ) + return HTTPResponse.success(data=media.to_dict()) + + if _duplicate_redacted_media(etag, media): + return HTTPResponse.error("Redacted media already exists", status=409) + + filename = Media.generate_file_name(f"redacted.{out_ext}") + _write_media_bytes(filename, out, out_type) + + redacted = Media( + media_file=filename, + media_file_type=out_type, + etag=etag, + title=title or f"{media.title or media.media_file} (redacted)", + title_ar=media.title_ar, + comments=media.comments, + comments_ar=media.comments_ar, + category=_redaction_category_id(), + time=media.time, + orientation=media.orientation, + user_id=current_user.id, + bulletin_id=media.bulletin_id, + actor_id=media.actor_id, + ) + original_id = ( + media.redaction.original_media_id + if media.redaction and media.redaction.original_media_id + else media.id + ) + audit = MediaRedaction( + source_media_id=media.id, + original_media_id=original_id, + result_media=redacted, + regions=pages, + user_id=current_user.id, + ) + db.session.add(redacted) + db.session.add(audit) + db.session.commit() + + Activity.create( + current_user, + Activity.ACTION_CREATE, + Activity.STATUS_SUCCESS, + redacted.to_mini(), + "media", + details=f"Redacted copy created from media {media.id}", + ) + return HTTPResponse.success(data=redacted.to_dict()) + + +@admin.delete("/api/media//redact") +@roles_accepted("Admin", "DA") +def api_media_redact_delete(id: int) -> Response: + # Soft-delete a redacted copy only. The immutable original is never touched here. + media = Media.query.get(id) + if media is None: + return HTTPResponse.not_found("Media not found") + if not current_user.can_access(media): + return HTTPResponse.forbidden("Restricted Access") + if media.redaction is None: + return HTTPResponse.error("Not a redacted copy", status=400) + + media.deleted = True + db.session.commit() + + Activity.create( + current_user, + Activity.ACTION_DELETE, + Activity.STATUS_SUCCESS, + media.to_mini(), + "media", + details=f"Redacted copy {media.id} deleted", + ) + return HTTPResponse.success(data={"id": media.id, "deleted": True}) + + # OCR Extraction endpoints @admin.get("/api/media/dashboard") @auth_required("session") @@ -679,8 +898,6 @@ def api_media_dashboard(): def _media_dashboard_item(media): """Serialize a media item for the dashboard. Bypasses @check_roles since access is already enforced at the query level.""" - from enferno.admin.models import MediaCategory - media_category = MediaCategory.query.get(media.category) if media.category else None item = { "id": media.id, @@ -742,6 +959,7 @@ def api_ocr_stats(): @admin.get("/api/extraction/") @auth_required("session") +@_require_media_access def api_extraction_get(extraction_id: int): """Return full extraction data including text.""" extraction = Extraction.query.get(extraction_id) @@ -772,6 +990,14 @@ def api_extraction_update(extraction_id: int): if not extraction: return HTTPResponse.not_found("Extraction not found") + media = Media.query.get(extraction.media_id) + if not media: + return HTTPResponse.not_found("Parent media not found") + # Editing extracted text mutates the item: require the assignment edit + # boundary, not just visibility (BAY-01-009). + if not current_user.can_edit(media): + return HTTPResponse.forbidden("Restricted Access") + data = request.json or {} action = data.get("action") @@ -820,7 +1046,7 @@ def api_extraction_update(extraction_id: int): details=detail_map.get(action), ) - return jsonify(extraction.to_dict()) + return jsonify(extraction.to_compact_dict()) @admin.put("/api/media//orientation") @@ -832,7 +1058,9 @@ def api_media_orientation(id: int): if not media: return HTTPResponse.not_found("Media not found") - if not current_user.can_access(media): + # Rotating media mutates the item: require the assignment edit boundary, + # not just visibility (BAY-01-009). + if not current_user.can_edit(media): return HTTPResponse.forbidden("Restricted Access") data = request.json or {} @@ -895,7 +1123,9 @@ def api_ocr_process(media_id: int): media = db.session.get(Media, media_id) if not media: return HTTPResponse.not_found("Media not found") - if not current_user.can_access(media): + # Running OCR writes the item's extraction: require the assignment edit + # boundary, not just visibility (BAY-01-009). + if not current_user.can_edit(media): return HTTPResponse.forbidden("Restricted Access") result = process_media_extraction_task(media_id, force=True) @@ -939,12 +1169,13 @@ def api_ocr_bulk(): ocr_ext = current_app.config.get("OCR_EXT", []) ext_filters = [Media.media_file.ilike(f"%.{ext}") for ext in ocr_ext] if ocr_ext else [] - # Build media ID list + # Build media ID list. Every path is scoped to the caller's access via + # _apply_media_access_filter so a non-admin can't OCR restricted items. if process_all and not media_ids: stmt = select(Media.id).outerjoin(Extraction).where(Extraction.id.is_(None)) if ext_filters: stmt = stmt.where(or_(*ext_filters)) - stmt = stmt.limit(limit) + stmt = _apply_media_access_filter(stmt).limit(limit) media_ids = list(db.session.scalars(stmt)) elif bulletin_id and not media_ids: stmt = ( @@ -955,8 +1186,20 @@ def api_ocr_bulk(): ) if ext_filters: stmt = stmt.where(or_(*ext_filters)) - stmt = stmt.limit(limit) + stmt = _apply_media_access_filter(stmt).limit(limit) media_ids = list(db.session.scalars(stmt)) + elif media_ids: + # explicit list: drop any the caller can't access + stmt = _apply_media_access_filter(select(Media.id).where(Media.id.in_(media_ids))) + media_ids = list(db.session.scalars(stmt)) + + # OCR writes an extraction, so enforce the assignment edit boundary per + # item, matching the single-OCR endpoint (BAY-01-009). The query filter + # above only expresses visibility (can_access); can_edit needs the parent's + # assignment + status, so post-filter here. Admins always pass. + if media_ids and not current_user.has_role("Admin"): + candidates = db.session.scalars(select(Media).where(Media.id.in_(media_ids))) + media_ids = [m.id for m in candidates if current_user.can_edit(m)] if not media_ids: return HTTPResponse.error("No media to process") diff --git a/enferno/admin/views/system.py b/enferno/admin/views/system.py index e6c146052..c7e055305 100644 --- a/enferno/admin/views/system.py +++ b/enferno/admin/views/system.py @@ -2,10 +2,10 @@ from typing import Any -from flask import Response, request +from flask import Response, current_app, request from flask.templating import render_template from flask_babel import gettext -from flask_security.decorators import auth_required, current_user, roles_required +from flask_security.decorators import current_user, roles_required from enferno.admin.constants import Constants from enferno.admin.models import ( @@ -23,11 +23,11 @@ from enferno.utils.config_utils import ConfigManager from enferno.utils.http_response import HTTPResponse from enferno.utils.validation_utils import validate_with -from . import admin, PER_PAGE +from . import admin, PER_PAGE, fresh_auth @admin.get("/system-administration/") -@auth_required(within=15, grace=0) +@fresh_auth @roles_required("Admin") def system_admin() -> str: """Endpoint for system administration.""" @@ -76,6 +76,7 @@ def api_config() -> str: @admin.put("/api/configuration/") +@fresh_auth @roles_required("Admin") @validate_with(ConfigRequestModel) def api_config_write( @@ -107,6 +108,7 @@ def api_config_write( @admin.post("/api/reload/") +@fresh_auth @roles_required("Admin") def api_app_reload() -> Response: """ @@ -187,7 +189,7 @@ def get_data(table: str) -> list[dict[str, Any]] | list[dict[str, dict[str, Any import json -import subprocess +import time from pathlib import Path from enferno.extensions import rds @@ -233,31 +235,8 @@ def api_updates_available() -> Response: return HTTPResponse.success(data=payload) -@admin.post("/api/updates/start") -@auth_required(within=15, grace=0) -@roles_required("Admin") -def api_updates_start() -> Response: - """Launch `bayanat update` out-of-process via the sudoers-granted wrapper. - - Fresh-auth required (within 15 min) to limit stale-cookie exposure: a - compromised admin session cannot trigger a privileged update without a - recent password prompt. - """ - try: - subprocess.run( - ["sudo", "-n", "/usr/local/sbin/bayanat-start-update"], - check=True, - timeout=10, - ) - except subprocess.TimeoutExpired: - return HTTPResponse.error("Update start timed out", status=504) - except subprocess.CalledProcessError as e: - return HTTPResponse.error(f"Failed to start update: {e}", status=500) - return HTTPResponse.success(data={"status": "started"}) - - @admin.get("/snapshots/") -@auth_required(within=15, grace=0) +@fresh_auth @roles_required("Admin") def snapshots_page() -> str: """Render the snapshots list page.""" @@ -267,24 +246,30 @@ def snapshots_page() -> str: @admin.get("/api/snapshots/") @roles_required("Admin") def api_snapshots() -> Response: - """List pre-update snapshots by shelling `bayanat snapshots`.""" - try: - out = subprocess.run( - ["sudo", "-n", "/usr/local/bin/bayanat", "snapshots"], - check=True, - capture_output=True, - text=True, - timeout=5, - ).stdout - except subprocess.TimeoutExpired: - return HTTPResponse.error("Listing snapshots timed out", status=504) - except subprocess.CalledProcessError as e: - return HTTPResponse.error(f"Failed to list snapshots: {e}", status=500) + """List pre-update snapshots from the backups directory. + + Reads the directory the updater writes snapshots into instead of shelling + `sudo bayanat snapshots`, so the service account needs no sudo grant + (BAY-01-032). + """ + backups = Path(current_app.config.get("BACKUPS_LOCAL_PATH", "")) items = [] - for line in out.strip().splitlines()[1:]: # skip header row - parts = line.split() - if len(parts) >= 3: - items.append({"name": parts[0], "size": parts[1], "age": parts[2]}) + if backups.is_dir(): + now = time.time() + for p in sorted(backups.glob("pre-*.dump"), key=lambda p: p.stat().st_mtime, reverse=True): + st = p.stat() + size = st.st_size + for unit in ("B", "K", "M", "G", "T"): + if size < 1024: + break + size /= 1024 + items.append( + { + "name": p.name, + "size": f"{size:.0f}{unit}", + "age": f"{int((now - st.st_mtime) // 3600)}h", + } + ) return HTTPResponse.success(data=items) diff --git a/enferno/admin/views/users.py b/enferno/admin/views/users.py index 3745e0164..3dc6b686d 100644 --- a/enferno/admin/views/users.py +++ b/enferno/admin/views/users.py @@ -6,7 +6,7 @@ from flask import Response, request, current_app, session from flask.templating import render_template from flask_security import logout_user -from flask_security.decorators import auth_required, current_user, roles_accepted, roles_required +from flask_security.decorators import current_user, roles_accepted, roles_required from flask_security.twofactor import tf_disable from sqlalchemy import or_ @@ -26,7 +26,7 @@ from enferno.utils.logging_utils import get_logger from enferno.utils.validation_utils import validate_with import enferno.utils.typing as t -from . import admin, PER_PAGE +from . import admin, PER_PAGE, fresh_auth logger = get_logger() @@ -68,7 +68,7 @@ def api_users() -> Response: @admin.get("/users/", defaults={"id": None}) @admin.get("/users/") -@auth_required(within=15, grace=0) +@fresh_auth @roles_required("Admin") def users(id) -> str: """ @@ -158,6 +158,7 @@ def api_user_sessions(id: int) -> Any: @admin.delete("/api/session/logout") +@fresh_auth @roles_required("Admin") def logout_session() -> Response: """ @@ -201,6 +202,7 @@ def logout_session() -> Response: @admin.delete("/api/user//sessions/logout") +@fresh_auth @roles_required("Admin") def logout_all_sessions(user_id: int) -> Any: """ @@ -246,6 +248,7 @@ def logout_all_sessions(user_id: int) -> Any: @admin.delete("/api/user/revoke_2fa") +@fresh_auth @roles_required("Admin") def revoke_2fa() -> Response: """ @@ -273,6 +276,7 @@ def revoke_2fa() -> Response: @admin.post("/api/user/") +@fresh_auth @roles_required("Admin") @validate_with(UserRequestModel) def api_user_create( @@ -353,6 +357,7 @@ def api_user_check( @admin.put("/api/user/") +@fresh_auth @roles_required("Admin") @validate_with(UserRequestModel) def api_user_update( @@ -432,6 +437,7 @@ def api_check_password( @admin.post("/api/user/force-reset") +@fresh_auth @roles_required("Admin") @validate_with(UserForceResetRequestModel) def api_user_force_reset(validated_data: dict) -> Response: @@ -459,6 +465,7 @@ def api_user_force_reset(validated_data: dict) -> Response: @admin.post("/api/user/force-reset-all") +@fresh_auth @roles_required("Admin") def api_user_force_reset_all() -> Response: """ @@ -475,6 +482,7 @@ def api_user_force_reset_all() -> Response: @admin.delete("/api/user/") +@fresh_auth @roles_required("Admin") def api_user_delete( id: t.id, @@ -513,7 +521,7 @@ def api_user_delete( # Roles routes @admin.route("/roles/") -@auth_required(within=15, grace=0) +@fresh_auth @roles_required("Admin") def roles() -> str: """ @@ -554,6 +562,7 @@ def api_roles() -> Response: @admin.post("/api/role/") +@fresh_auth @roles_required("Admin") @validate_with(RoleRequestModel) def api_role_create( @@ -588,6 +597,7 @@ def api_role_create( @admin.put("/api/role/") +@fresh_auth @roles_required("Admin") @validate_with(RoleRequestModel) def api_role_update(id: t.id, validated_data: dict) -> Response: @@ -618,6 +628,7 @@ def api_role_update(id: t.id, validated_data: dict) -> Response: @admin.delete("/api/role/") +@fresh_auth @roles_required("Admin") def api_role_delete( id: t.id, @@ -659,6 +670,7 @@ def api_role_delete( @admin.post("/api/role/import/") +@fresh_auth @roles_required("Admin") def api_role_import() -> Response: """ diff --git a/enferno/app.py b/enferno/app.py index 46741a40a..03ee53108 100755 --- a/enferno/app.py +++ b/enferno/app.py @@ -2,7 +2,7 @@ import pandas as pd from urllib.parse import urlparse -from flask import Flask, render_template, current_app +from flask import Flask, render_template, current_app, request from flask_login import user_logged_in, user_logged_out from flask_security import Security, SQLAlchemyUserDatastore from flask_security import current_user @@ -32,6 +32,7 @@ ) from enferno.admin.views import admin from enferno.data_import.views import imports +from enferno.utils.soft_delete import register_soft_delete from enferno.extensions import ( db, migrate, @@ -57,7 +58,7 @@ from enferno.user.models import WebAuthn from enferno.user.views import bp_user from enferno.utils.logging_utils import get_logger -from enferno.utils.rate_limit_utils import ratelimit_handler +from enferno.utils.rate_limit_utils import get_real_ip, ratelimit_handler logger = get_logger() @@ -121,6 +122,7 @@ def register_extensions(app): """ db.init_app(app) migrate.init_app(app, db) + register_soft_delete(db) # Skip debug toolbar when CSP is enabled (they conflict) if not app.config.get("CSP_ENABLED", False): debug_toolbar.init_app(app) @@ -147,11 +149,38 @@ def register_extensions(app): mail.init_app(app) limiter.init_app(app) + _apply_login_rate_limit(app) # Initialize Talisman with security headers register_talisman(app) +def _apply_login_rate_limit(app): + """Stack per-username and per-IP Flask-Limiter limits on POST /login. + + The /login view is owned by Flask-Security; we wrap it post-registration + so the same limiter / Redis storage / 429 handler used elsewhere applies. + """ + login_view = app.view_functions.get("security.login") + if login_view is None: + return + + def _username_key(): + return f"login:user:{(request.form.get('username') or '').lower().strip()}" + + wrapped = limiter.limit( + app.config["LOGIN_RATE_LIMIT_PER_USERNAME"], + key_func=_username_key, + methods=["POST"], + )(login_view) + wrapped = limiter.limit( + app.config["LOGIN_RATE_LIMIT_PER_IP"], + key_func=get_real_ip, + methods=["POST"], + )(wrapped) + app.view_functions["security.login"] = wrapped + + def register_talisman(app): """ Register Flask-Talisman for security headers including CSP. @@ -242,7 +271,7 @@ def register_talisman(app): # Other security headers force_https=app.config.get("FORCE_HTTPS", False), # Don't force in dev force_https_permanent=False, - frame_options="DENY", + frame_options="SAMEORIGIN", strict_transport_security=app.config.get("FORCE_HTTPS", False), strict_transport_security_max_age=31536000, # 1 year strict_transport_security_include_subdomains=True, @@ -372,6 +401,7 @@ def register_commands(app): app.cli.add_command(commands.doctor) app.cli.add_command(commands.generate_config) app.cli.add_command(commands.ocr_cli) + app.cli.add_command(commands.export_cli) def register_errorhandlers(app): diff --git a/enferno/commands.py b/enferno/commands.py index 5674640ba..0f3ae28d3 100644 --- a/enferno/commands.py +++ b/enferno/commands.py @@ -3,16 +3,22 @@ import os from datetime import datetime, timezone +from typing import Optional import click from flask import current_app from flask.cli import AppGroup, with_appcontext from flask_security.utils import hash_password -from enferno.settings import Config +import json +import shutil +from pathlib import Path + from enferno.extensions import db +from enferno.settings import Config from enferno.user.models import User, Role from enferno.utils.config_utils import ConfigManager +from enferno.utils.date_helper import DateHelper from enferno.utils.data_helpers import ( import_default_data, generate_user_roles, @@ -21,11 +27,13 @@ ) from enferno.utils.db_alignment_helpers import DBAlignmentChecker from enferno.utils.logging_utils import get_logger +from geoalchemy2.shape import to_shape from sqlalchemy import text -from enferno.admin.models import Bulletin -from enferno.admin.models.DynamicField import DynamicField +from sqlalchemy.orm import subqueryload +from enferno.admin.models import Bulletin, Label +from enferno.admin.models.Media import Media +from enferno.admin.models.tables import bulletin_labels from enferno.admin.models.DynamicFormHistory import DynamicFormHistory -from enferno.utils.date_helper import DateHelper from enferno.utils.form_history_utils import record_form_history from enferno.utils.validation_utils import validate_password_policy @@ -114,39 +122,100 @@ def import_data() -> None: @click.command() +@click.option("-u", "--username", default=None, help="Admin username (prompted if not provided)") +@click.option("-p", "--password", default=None, help="Admin password (generated if not provided)") +@click.option( + "--password-stdin", + "password_stdin", + is_flag=True, + default=False, + help="Read admin password from stdin (avoids argv exposure)", +) @with_appcontext -def install() -> None: - """Install a default Admin user and add an Admin role to it.""" +def install(username: Optional[str], password: Optional[str], password_stdin: bool = False) -> None: + """Install a default Admin user and add an Admin role to it. + + Non-interactive use: + flask install -u admin # generate a password + flask install -u admin -p '' # supply via flag + echo '' | flask install -u admin --password-stdin + """ + import secrets + import sys + + if password_stdin: + if password: + click.echo("Cannot combine --password and --password-stdin.") + return + password = sys.stdin.readline().rstrip("\n") + if not password: + click.echo("Empty password on stdin.") + return + logger.info("Installing admin user.") admin_role = Role.query.filter(Role.name == "Admin").first() - # check if there's an existing admin if admin_role.users.all(): click.echo("An admin user is already installed.") logger.error("An admin user is already installed.") return - # to make sure username doesn't already exist - while True: - u = click.prompt("Admin username?", default="admin") - check = User.query.filter(User.username == u.lower()).first() - if check is not None: + # Resolve username + if username: + u = username.strip() + if User.query.filter(User.username == u.lower()).first() is not None: + click.echo(f"Username '{u}' already exists.") + logger.error("Install aborted: username already exists.") + return + else: + while True: + u = click.prompt("Admin username?", default="admin") + if User.query.filter(User.username == u.lower()).first() is None: + break click.echo("Username already exists.") - else: - break - while True: - p = click.prompt("Admin Password?", hide_input=True) + + # Resolve password (generate if not supplied; show it once) + generated = False + if password: try: - p = validate_password_policy(p) - break + p = validate_password_policy(password) except ValueError as e: click.echo(str(e)) + logger.error("Install aborted: password failed policy check.") + return + elif username: + # Non-interactive (username supplied, password not) → generate. + while True: + candidate = secrets.token_urlsafe(20) + try: + p = validate_password_policy(candidate) + generated = True + break + except ValueError: + # token_urlsafe is high-entropy; loop guard for the rare zxcvbn miss + continue + else: + while True: + p = click.prompt("Admin Password?", hide_input=True) + try: + p = validate_password_policy(p) + break + except ValueError as e: + click.echo(str(e)) + user = User(username=u, password=hash_password(p), active=1) user.name = "Admin" user.roles.append(admin_role) check = user.save() if check: - click.echo("Admin user installed successfully.") + if generated: + click.echo("=" * 60) + click.echo(f"Admin user installed: {u}") + click.echo(f"Generated password : {p}") + click.echo("Save this now — it is not stored in plaintext anywhere.") + click.echo("=" * 60) + else: + click.echo("Admin user installed successfully.") logger.info("Admin user installed successfully.") else: click.echo("Error installing admin user.") @@ -528,7 +597,6 @@ def fail(msg): fail("Redis not reachable") try: - from celery import current_app as celery_app from enferno.tasks import celery inspector = celery.control.inspect(timeout=2) @@ -771,7 +839,7 @@ def status() -> None: total_extracted = sum(s["count"] for s in status_map.values()) pending = total_media - total_extracted - click.echo(f"\nOCR Status Summary") + click.echo("\nOCR Status Summary") click.echo(f"{'─' * 40}") click.echo(f"Total media: {total_media:,}") click.echo(f"Pending (no OCR): {pending:,}") @@ -786,3 +854,247 @@ def status() -> None: click.echo(f"{'─' * 40}") click.echo(f"Total extracted: {total_extracted:,}\n") + + +# Public archive export commands +export_cli = AppGroup("export", short_help="Export commands for public archive") + +MEDIA_DIR = Media.media_dir + + +def serialize_bulletin(bulletin): + """Serialize a bulletin for the public archive export. + + Includes only public-facing fields. The internal ``description`` is + never exported; only the SJAC-authored ``public_description`` goes out. + Strips workflow, assignment, review, and internal metadata. + """ + labels = [ + {"id": l.id, "title": l.title, "title_ar": l.title_ar, "verified": l.verified} + for l in bulletin.labels + ] + + ver_labels = [ + {"id": l.id, "title": l.title, "title_ar": l.title_ar} for l in bulletin.ver_labels + ] + + sources = [{"id": s.id, "title": s.title} for s in bulletin.sources] + + locations = [ + { + "id": loc.id, + "title": loc.title, + "title_ar": loc.title_ar, + "lat": to_shape(loc.latlng).y if loc.latlng else None, + "lng": to_shape(loc.latlng).x if loc.latlng else None, + "location_type": loc.location_type.title if loc.location_type else None, + "country": loc.country.title if loc.country else None, + "full_location": loc.full_location, + } + for loc in bulletin.locations + ] + + geo_locations = [ + { + "id": geo.id, + "title": geo.title, + "lat": to_shape(geo.latlng).y if geo.latlng else None, + "lng": to_shape(geo.latlng).x if geo.latlng else None, + "type": geo.type.title if geo.type else None, + } + for geo in bulletin.geo_locations + ] + + events = [ + { + "id": e.id, + "title": e.title, + "title_ar": e.title_ar, + "type": e.eventtype.title if e.eventtype else None, + "from_date": DateHelper.serialize_datetime(e.from_date), + "to_date": DateHelper.serialize_datetime(e.to_date), + "location": e.location.title if e.location else None, + } + for e in bulletin.events + ] + + medias = [] + for media in bulletin.medias: + if media.deleted: + continue + entry = { + "id": media.id, + "filename": media.media_file, + "type": media.media_file_type, + "title": media.title, + "title_ar": media.title_ar, + } + if media.extraction: + entry["extraction"] = { + "text": media.extraction.text, + "original_text": media.extraction.original_text, + "confidence": media.extraction.confidence, + "language": media.extraction.language, + } + medias.append(entry) + + related_bulletins = [] + for rel in bulletin.bulletin_relations: + other = rel.bulletin_to if bulletin.id == rel.bulletin_id else rel.bulletin_from + related_bulletins.append( + { + "id": other.id, + "title": other.title, + "title_ar": other.title_ar, + "related_as": rel.related_as, + } + ) + + related_actors = [ + { + "id": rel.actor.id, + "name": rel.actor.name, + "type": rel.actor.type, + "related_as": rel.related_as or [], + } + for rel in bulletin.related_actors + ] + + related_incidents = [ + { + "id": rel.incident.id, + "title": rel.incident.title, + "title_ar": rel.incident.title_ar, + "related_as": rel.related_as, + } + for rel in bulletin.related_incidents + ] + + return { + "id": bulletin.id, + "title": bulletin.title, + "title_ar": bulletin.title_ar, + "public_description": bulletin.public_description, + "source_link": bulletin.source_link, + "publish_date": DateHelper.serialize_datetime(bulletin.publish_date), + "documentation_date": DateHelper.serialize_datetime(bulletin.documentation_date), + "labels": labels, + "verified_labels": ver_labels, + "sources": sources, + "locations": locations, + "geo_locations": geo_locations, + "events": events, + "media": medias, + "related_bulletins": related_bulletins, + "related_actors": related_actors, + "related_incidents": related_incidents, + } + + +def copy_media_files(bulletins, output_dir): + """Copy media files for exported bulletins to the output directory. + + Reads FILESYSTEM_LOCAL from app config to decide between local copy and S3 download. + """ + import boto3 + + cfg = current_app.config + use_s3 = not cfg.get("FILESYSTEM_LOCAL") + dest_dir = output_dir / "media" + dest_dir.mkdir(exist_ok=True) + + s3 = None + if use_s3: + s3 = boto3.client( + "s3", + aws_access_key_id=cfg["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=cfg["AWS_SECRET_ACCESS_KEY"], + region_name=cfg["AWS_REGION"], + ) + + copied = 0 + missing = 0 + for bulletin in bulletins: + for media in bulletin.medias: + if media.deleted or not media.media_file: + continue + dest = dest_dir / media.media_file + if use_s3: + try: + s3.download_file(cfg["S3_BUCKET"], media.media_file, str(dest)) + copied += 1 + except Exception: + logger.warning( + "S3 download failed: %s (bulletin %d)", media.media_file, bulletin.id + ) + missing += 1 + else: + src = MEDIA_DIR / media.media_file + if src.exists(): + shutil.copy2(src, dest) + copied += 1 + else: + logger.warning("Media file not found: %s (bulletin %d)", src, bulletin.id) + missing += 1 + + return copied, missing + + +@export_cli.command("public") +@click.option("--label", required=True, help="Label title to filter bulletins for export.") +@click.option("--output", required=True, type=click.Path(), help="Output directory for the export.") +@click.option("--copy-media/--no-copy-media", default=True, help="Copy media files to output.") +@with_appcontext +def export_public(label, output, copy_media): + """Export labeled bulletins as denormalized JSON for the public archive. + + Usage: + flask export public --label "public-archive" --output ./export/ + """ + output_dir = Path(output) + output_dir.mkdir(parents=True, exist_ok=True) + + target_label = Label.query.filter(Label.title == label).first() + if not target_label: + click.echo(f'Label "{label}" not found.') + raise SystemExit(1) + + bulletins = ( + Bulletin.query.join(bulletin_labels) + .filter(bulletin_labels.c.label_id == target_label.id) + .filter(Bulletin.deleted == False) + .options( + subqueryload(Bulletin.labels), + subqueryload(Bulletin.ver_labels), + subqueryload(Bulletin.sources), + subqueryload(Bulletin.locations), + subqueryload(Bulletin.geo_locations), + subqueryload(Bulletin.events), + subqueryload(Bulletin.medias).joinedload(Media.extraction), + subqueryload(Bulletin.bulletins_to), + subqueryload(Bulletin.bulletins_from), + subqueryload(Bulletin.related_actors), + subqueryload(Bulletin.related_incidents), + ) + .order_by(Bulletin.id) + .all() + ) + + if not bulletins: + click.echo(f'No bulletins found with label "{label}".') + raise SystemExit(1) + + click.echo(f"Exporting {len(bulletins)} bulletins...") + + documents = [serialize_bulletin(b) for b in bulletins] + + out_file = output_dir / "documents.json" + with open(out_file, "w", encoding="utf-8") as f: + json.dump(documents, f, ensure_ascii=False, indent=2) + click.echo(f"Wrote {len(documents)} documents to {out_file}") + + if copy_media: + copied, missing = copy_media_files(bulletins, output_dir) + click.echo(f"Copied {copied} media files ({missing} missing)") + + click.echo("Export complete.") diff --git a/enferno/data/media_categories.csv b/enferno/data/media_categories.csv index d60b6637f..0af080e4a 100644 --- a/enferno/data/media_categories.csv +++ b/enferno/data/media_categories.csv @@ -2,3 +2,4 @@ id,title,title_tr,created_at,updated_at,deleted 1,Generic,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f 2,Humans,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f 3,Signs/Text,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f +4,Redaction,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f diff --git a/enferno/data_import/templates/import-log.html b/enferno/data_import/templates/import-log.html index e92f1023a..65aceaed7 100644 --- a/enferno/data_import/templates/import-log.html +++ b/enferno/data_import/templates/import-log.html @@ -116,7 +116,8 @@ - + + @@ -306,6 +307,7 @@ app.component('IncidentResult', IncidentResult); app.component('UniField', UniField); app.component('PdfViewer', PdfViewer); + app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.component('ImageViewer', ImageViewer); app.component('InlineMediaRenderer', InlineMediaRenderer); diff --git a/enferno/data_import/templates/media-import.html b/enferno/data_import/templates/media-import.html index ccded6dc3..534b7ae23 100644 --- a/enferno/data_import/templates/media-import.html +++ b/enferno/data_import/templates/media-import.html @@ -138,7 +138,7 @@ - + {{ _('Attach data to files') }} @@ -377,7 +377,9 @@ batch_id: '', dzOpts: { - url: '/admin/api/media/chunk', + // source=import goes in the URL so it survives every chunk POST. + // Dropzone's `params` option is dropped on chunked requests. + url: '/admin/api/media/chunk?source=import', // accept any file acceptedFiles: document.querySelector('[data-allowed-media-types]').dataset.allowedMediaTypes, addRemoveLinks: true, @@ -388,9 +390,6 @@ thumbnailHeight: 80, parallelUploads: 1, maxFilesize: mediaUploadMaxFileSize, - // Signals the chunk endpoint to apply the Admin-only - // extended extension list (ETL_VID_EXT). - params: { source: 'import' }, }, diff --git a/enferno/data_import/templates/sheets-import.html b/enferno/data_import/templates/sheets-import.html index 686ddefaf..f6b114ebf 100644 --- a/enferno/data_import/templates/sheets-import.html +++ b/enferno/data_import/templates/sheets-import.html @@ -733,7 +733,7 @@
{{ _('Event Type') }}
- + {{ _('Sheet file:') }} {{ _('Selected') }} diff --git a/enferno/data_import/utils/media_import.py b/enferno/data_import/utils/media_import.py index 4a1754817..2ccb68c9c 100644 --- a/enferno/data_import/utils/media_import.py +++ b/enferno/data_import/utils/media_import.py @@ -9,6 +9,7 @@ from enferno.admin.models import Media, Bulletin, Source, Label, Location, Activity from enferno.data_import.models import DataImport from enferno.user.models import User, Role +from enferno.utils.validation_utils import sanitize_string from enferno.utils.data_helpers import get_file_hash, media_check_duplicates from enferno.utils.date_helper import DateHelper import arrow, shutil @@ -20,7 +21,6 @@ import enferno.utils.typing as t from enferno.extensions import db from sqlalchemy import any_ -from urllib.parse import urlparse logger = get_logger() @@ -568,6 +568,7 @@ def create_bulletin(self, info: dict) -> None: db.session.add(bulletin) def update_description(description): + description = sanitize_string(description or "") if bulletin.description: bulletin.description += f"
{description}" else: @@ -593,20 +594,19 @@ def update_description(description): channel_url = info.get("channel_url") channel = info.get("channel") - domain = info.get("extractor_key") + domain = info.get("extractor_key") or info.get("extractor") if not domain: - url = urlparse(info.get("source_url")).netloc.lower() - url = domain[4:] if domain.startswith("www.") else domain - domain = url.split(".")[0].first() - - main_source = Source.query.filter(Source.title == domain).first() - - if not main_source: - main_source = Source() - main_source.title = domain - main_source.etl_id = info.get("webpage_url_domain") or url - main_source.save() - bulletin.sources.append(main_source) + self.data_import.add_to_log( + "yt-dlp metadata missing extractor_key; skipping Source linkage." + ) + else: + main_source = Source.query.filter(Source.title == domain).first() + if not main_source: + main_source = Source() + main_source.title = domain + main_source.etl_id = info.get("webpage_url_domain") + main_source.save() + bulletin.sources.append(main_source) source = None @@ -665,12 +665,12 @@ def update_description(description): bulletin.publish_date = upload_date if description := info.get("description"): - bulletin.description = description + bulletin.description = sanitize_string(description) else: bulletin.source_link = info.get("old_path") if info.get("text_content"): - bulletin.description = info.get("text_content") + bulletin.description = sanitize_string(info.get("text_content")) if info.get("transcription"): update_description(info.get("transcription")) diff --git a/enferno/data_import/utils/sheet_import.py b/enferno/data_import/utils/sheet_import.py index 10e994af3..1d40d9d31 100644 --- a/enferno/data_import/utils/sheet_import.py +++ b/enferno/data_import/utils/sheet_import.py @@ -27,6 +27,7 @@ from enferno.utils.base import DatabaseException from enferno.utils.date_helper import DateHelper +from enferno.utils.validation_utils import sanitize_string from enferno.user.models import Role, User import enferno.utils.typing as t @@ -144,7 +145,7 @@ def parse_csv(filepath: str) -> dict: - A dictionary containing the columns and the head of the file. """ # read the file partially only for parsing purposes - df = pd.read_csv(filepath, keep_default_na=False) + df = pd.read_csv(filepath, keep_default_na=False, on_bad_lines="skip", index_col=False) df.dropna(how="all", axis=1, inplace=True) df = df.astype(str) @@ -165,11 +166,13 @@ def parse_excel(filepath: str, sheet: Any) -> dict: Returns: - A dictionary containing the columns and the head of the file. """ - df = pd.read_excel(filepath, sheet_name=sheet) + df = pd.read_excel(filepath, sheet_name=sheet, engine="openpyxl") df.dropna(how="all", axis=1, inplace=True) df = df.astype(str) - columns = df.columns.to_list() + # XLSX preserves numeric header cells as numeric column labels; coerce so the + # API contract (list[str]) holds regardless of header cell types. + columns = [str(c) for c in df.columns] # drop nan values before generating head rows df.fillna("", inplace=True) head = df.head().to_dict() @@ -187,7 +190,7 @@ def get_sheets(filepath: str) -> list: Returns: - A list of the sheet names in the Excel file. """ - xls = pd.ExcelFile(filepath) + xls = pd.ExcelFile(filepath, engine="openpyxl") return xls.sheet_names @staticmethod @@ -202,8 +205,8 @@ def sheet_to_df(filepath: str, sheet: Optional[list] = None) -> pd.DataFrame: Returns: - A DataFrame containing the parsed data. """ - if sheet: - df = pd.read_excel(filepath, sheet_name=sheet, keep_default_na=False) + if isinstance(sheet, (str, int)): + df = pd.read_excel(filepath, sheet_name=sheet, keep_default_na=False, engine="openpyxl") else: df = pd.read_csv(filepath, keep_default_na=False) @@ -369,7 +372,9 @@ def set_details(self, details: str, value: Any) -> None: setattr(self.actor_profile, field, {"opts": [], "details": ""}) else: setattr(self.actor_profile, field, {"opts": "", "details": ""}) - getattr(self.actor_profile, field)["details"] = str(value) + # Sanitize before persist: these MP detail fields are rendered as HTML + # downstream, same stored-XSS sink the report flagged (BAY-01-008/039). + getattr(self.actor_profile, field)["details"] = sanitize_string(str(value)) flag_modified(self.actor_profile, field) self.data_import.add_to_log(f"Processed {field}") @@ -554,7 +559,7 @@ def set_description(self, map_item: Any) -> None: description += "\n" if description: - self.actor_profile.description = description + self.actor_profile.description = sanitize_string(description) if old_description: self.actor_profile.description += old_description self.data_import.add_to_log("Processed description") @@ -707,7 +712,10 @@ def handle_mismatch(self, field: str, value: Any) -> None: None """ self.data_import.add_to_log(f"Field value mismatch {field}.\n Appending to description.") - self.actor_profile.description += f"

\n

{field}: {str(value)}" + # Sanitize untrusted field/value before the v-html sink (BAY-01-039). + self.actor_profile.description += ( + f"

\n

{sanitize_string(str(field))}: {sanitize_string(str(value))}" + ) def gen_value(self, field: str) -> None: """ diff --git a/enferno/data_import/views.py b/enferno/data_import/views.py index d1f3eaf78..cc5940af8 100644 --- a/enferno/data_import/views.py +++ b/enferno/data_import/views.py @@ -91,7 +91,7 @@ def api_imports() -> Response: per_page = request.args.get("per_page", PER_PAGE, int) q = request.json.get("q", None) - if q and (batch_id := q.get("batch_id")): + if isinstance(q, dict) and (batch_id := q.get("batch_id")): result = ( DataImport.query.filter(DataImport.batch_id == batch_id) .order_by(-DataImport.id) @@ -179,8 +179,11 @@ def etl_process() -> Response: - response contains the processing result """ - files = request.json.pop("files") - meta = request.json + body = request.json or {} + files = body.pop("files", None) + if not isinstance(files, list) or not files: + return HTTPResponse.error("Missing `files` array", status=417) + meta = body batch_id = shortuuid.uuid()[:9] process_files.delay(files=files, meta=meta, user_id=current_user.id, batch_id=batch_id) @@ -249,15 +252,43 @@ def api_local_csv_delete() -> str: return "" +def _resolve_import_path(filename: Optional[str]) -> Optional[str]: + """ + Resolve a user-supplied filename to a path inside IMPORT_DIR. + + Returns the resolved POSIX path string, or None if the filename is + missing or escapes the import directory (traversal attempt). + """ + if not filename: + return None + import_dir = Path(current_app.config.get("IMPORT_DIR")).resolve() + joined = safe_join(str(import_dir), filename) + if joined is None: + return None + candidate = Path(joined).resolve() + try: + candidate.relative_to(import_dir) + except ValueError: + return None + return candidate.as_posix() + + @imports.post("/api/csv/analyze") @roles_required("Admin") def api_csv_analyze() -> Response: """API endpoint to analyze a csv file.""" # locate file - filename = request.json.get("file").get("filename") - import_dir = Path(current_app.config.get("IMPORT_DIR")) + file_obj = request.json.get("file") + if not isinstance(file_obj, dict): + return HTTPResponse.error("Missing or malformed `file` field", status=417) + filename = file_obj.get("filename") + if not isinstance(filename, str) or not filename: + return HTTPResponse.error("Missing `file.filename`", status=417) + filepath = _resolve_import_path(filename) + if filepath is None: + logger.warning("Rejected CSV analyze for invalid path: %r", filename) + return HTTPResponse.error("Invalid file path", status=400) - filepath = (import_dir / filename).as_posix() result = SheetImport.parse_csv(filepath) if result: @@ -271,10 +302,17 @@ def api_csv_analyze() -> Response: @roles_required("Admin") def api_xls_sheet() -> Response: """API endpoint to get sheets from an excel file.""" - filename = request.json.get("file").get("filename") - import_dir = Path(current_app.config.get("IMPORT_DIR")) + file_obj = request.json.get("file") + if not isinstance(file_obj, dict): + return HTTPResponse.error("Missing or malformed `file` field", status=417) + filename = file_obj.get("filename") + if not isinstance(filename, str) or not filename: + return HTTPResponse.error("Missing `file.filename`", status=417) + filepath = _resolve_import_path(filename) + if filepath is None: + logger.warning("Rejected XLS sheets for invalid path: %r", filename) + return HTTPResponse.error("Invalid file path", status=400) - filepath = (import_dir / filename).as_posix() sheets = SheetImport.get_sheets(filepath) return HTTPResponse.success(data=sheets) @@ -285,11 +323,21 @@ def api_xls_sheet() -> Response: def api_xls_analyze() -> Response: """API endpoint to analyze an excel file.""" # locate file - filename = request.json.get("file").get("filename") - import_dir = Path(current_app.config.get("IMPORT_DIR")) + file_obj = request.json.get("file") + if not isinstance(file_obj, dict): + return HTTPResponse.error("Missing or malformed `file` field", status=417) + filename = file_obj.get("filename") + if not isinstance(filename, str) or not filename: + return HTTPResponse.error("Missing `file.filename`", status=417) + filepath = _resolve_import_path(filename) + if filepath is None: + logger.warning("Rejected XLS analyze for invalid path: %r", filename) + return HTTPResponse.error("Invalid file path", status=400) - filepath = (import_dir / filename).as_posix() sheet = request.json.get("sheet") + # bool is an int subclass, so exclude it explicitly + if isinstance(sheet, bool) or not isinstance(sheet, (str, int)): + return HTTPResponse.error("Missing or invalid `sheet` value", status=417) result = SheetImport.parse_excel(filepath, sheet) @@ -357,6 +405,8 @@ def api_mapping_update(id: t.id) -> Response: map = db.session.get(Mapping, id) if map: data = request.json.get("data") + if not isinstance(data, dict): + return HTTPResponse.error("Update request missing parameters data", status=417) m = data.get("map", None) name = request.json.get("name", None) if m and name: @@ -405,10 +455,15 @@ def api_process_sheet() -> Response: - success if save is successful, error otherwise. """ files = request.json.get("files") + if not isinstance(files, list) or not files: + return HTTPResponse.error("Missing `files` array", status=417) import_dir = Path(current_app.config.get("IMPORT_DIR")) map = request.json.get("map") vmap = request.json.get("vmap") sheet = request.json.get("sheet") + # sheet is optional (CSV has none), but bool/list/etc. would crash the parser + if sheet is not None and (isinstance(sheet, bool) or not isinstance(sheet, (str, int))): + return HTTPResponse.error("Invalid `sheet` value", status=417) lang = request.json.get("lang", "en") actor_config = request.json.get("actorConfig") roles = request.json.get("roles") diff --git a/enferno/deduplication/templates/deduplication.html b/enferno/deduplication/templates/deduplication.html index 3220a22ce..e1f55de13 100644 --- a/enferno/deduplication/templates/deduplication.html +++ b/enferno/deduplication/templates/deduplication.html @@ -142,7 +142,8 @@ - + + @@ -349,7 +350,8 @@ app.component('MediaGrid', MediaGrid); app.component('PdfViewer', PdfViewer); - app.component('DocxViewer', DocxViewer); + app.component('NativePdfViewer', NativePdfViewer); + app.component('DocxViewer', DocxViewer); app.component('ImageViewer', ImageViewer); app.component('InlineMediaRenderer', InlineMediaRenderer); diff --git a/enferno/export/models.py b/enferno/export/models.py index b1154b349..d795c01ca 100644 --- a/enferno/export/models.py +++ b/enferno/export/models.py @@ -1,11 +1,8 @@ -import os - from datetime import datetime as dt from pathlib import Path from typing import Any, Union import arrow -from flask import current_app from sqlalchemy import ARRAY from flask_security.decorators import current_user @@ -67,6 +64,20 @@ def expired(self): else: return True + @staticmethod + def _accessible_item_ids(table: str, items: Any) -> list: + """Filter requested export item IDs to those the current user may access + (BAY-01-026). Admins keep everything; others keep only in-scope items. + """ + from enferno.admin.models import Actor, Bulletin, Incident + + model = {"bulletin": Bulletin, "actor": Actor, "incident": Incident}.get(table) + if not model or not isinstance(items, list): + return [] + rows = model.query.filter(model.id.in_(items)).all() + allowed = {r.id for r in rows if current_user and current_user.can_access(r)} + return [i for i in items if i in allowed] + def from_json(self, table: str, json: dict) -> "Export": """ Export Deserializer. @@ -78,13 +89,19 @@ def from_json(self, table: str, json: dict) -> "Export": Returns: - Export object """ + if not isinstance(json, dict): + json = {} cfg = json.get("config") - items = json.get("items") + if not isinstance(cfg, dict): + cfg = {} self.requester = current_user self.table = table - self.items = items - self.tags = cfg.get("tags") if "tags" in cfg else [] + # Store only items the requester can access (BAY-01-026): keep crafted / + # out-of-scope IDs from ever being persisted, approved, or exported. The + # worker re-validates access at generation time too (BAY-01-003). + self.items = self._accessible_item_ids(table, json.get("items")) + self.tags = cfg.get("tags", []) self.comment = cfg.get("comment") self.file_format = cfg.get("format") self.include_media = cfg.get("includeMedia") diff --git a/enferno/export/templates/export-dashboard.html b/enferno/export/templates/export-dashboard.html index e58e7fee5..c9e927144 100644 --- a/enferno/export/templates/export-dashboard.html +++ b/enferno/export/templates/export-dashboard.html @@ -116,6 +116,7 @@ + @@ -350,6 +351,7 @@ app.component('InlineMediaRenderer', InlineMediaRenderer); app.component('ImageViewer', ImageViewer); app.component('PdfViewer', PdfViewer); + app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.use(router).use(vuetify); diff --git a/enferno/export/views.py b/enferno/export/views.py index 499bef737..97cecbb8e 100644 --- a/enferno/export/views.py +++ b/enferno/export/views.py @@ -168,8 +168,10 @@ def api_export_get(id: t.id) -> Response: if export is None: return HTTPResponse.not_found("Export not found") - else: - return HTTPResponse.success(data=export.to_dict(), message="Export retrieved successfully") + # Same ownership guard as the list/download routes (BAY-01-015). + if not current_user.has_role("Admin") and current_user.id != export.requester_id: + return HTTPResponse.forbidden("Forbidden") + return HTTPResponse.success(data=export.to_dict(), message="Export retrieved successfully") @export.post("/api/exports/") diff --git a/enferno/settings.py b/enferno/settings.py index f9db8f882..bcfaf7835 100644 --- a/enferno/settings.py +++ b/enferno/settings.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import os from datetime import timedelta +from urllib.parse import quote import bleach import redis @@ -46,23 +47,37 @@ class Config(object): if (POSTGRES_USER and POSTGRES_PASSWORD) or POSTGRES_HOST != "localhost": SQLALCHEMY_DATABASE_URI = ( - f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DB}" + f"postgresql://{quote(POSTGRES_USER, safe='')}:{quote(POSTGRES_PASSWORD, safe='')}" + f"@{POSTGRES_HOST}/{POSTGRES_DB}" ) + elif POSTGRES_USER: + # Socket connection as an explicit role: peer auth plus a pg_ident + # map lets the per-service OS users connect as the shared app role + # (BAY-01-032). + SQLALCHEMY_DATABASE_URI = f"postgresql://{quote(POSTGRES_USER, safe='')}@/{POSTGRES_DB}" else: SQLALCHEMY_DATABASE_URI = f"postgresql:///{POSTGRES_DB}" SQLALCHEMY_TRACK_MODIFICATIONS = False + # Validate pooled connections before use and recycle them periodically so + # workers don't hand out a dropped connection (Postgres idle timeouts, + # NAT/conntrack drops, restarts). + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + "pool_recycle": int(os.environ.get("SQLALCHEMY_POOL_RECYCLE", 300)), + } # Redis REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") - REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0" + _redis_pw_quoted = quote(REDIS_PASSWORD, safe="") + REDIS_URL = f"redis://:{_redis_pw_quoted}@{REDIS_HOST}:{REDIS_PORT}/0" # Celery # Has to be in small case - celery_broker_url = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/2" - result_backend = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/3" + celery_broker_url = f"redis://:{_redis_pw_quoted}@{REDIS_HOST}:{REDIS_PORT}/2" + result_backend = f"redis://:{_redis_pw_quoted}@{REDIS_HOST}:{REDIS_PORT}/3" # Security SECURITY_REGISTERABLE = manager.get_config("SECURITY_REGISTERABLE") @@ -102,6 +117,12 @@ class Config(object): security_freshness_grace_period = manager.get_config("SECURITY_FRESHNESS_GRACE_PERIOD") SECURITY_FRESHNESS_GRACE_PERIOD = timedelta(minutes=security_freshness_grace_period) + # Login brute-force throttle (Flask-Limiter, applied per-method=POST on /login). + LOGIN_RATE_LIMIT_PER_USERNAME = os.environ.get( + "LOGIN_RATE_LIMIT_PER_USERNAME", "10 per 15 minutes" + ) + LOGIN_RATE_LIMIT_PER_IP = os.environ.get("LOGIN_RATE_LIMIT_PER_IP", "30 per 15 minutes") + SECURITY_TWO_FACTOR_REQUIRED = manager.get_config("SECURITY_TWO_FACTOR_REQUIRED") SECURITY_PASSWORD_LENGTH_MIN = manager.get_config("SECURITY_PASSWORD_LENGTH_MIN") @@ -126,10 +147,6 @@ class Config(object): DISABLE_MULTIPLE_SESSIONS = manager.get_config("DISABLE_MULTIPLE_SESSIONS") SESSION_RETENTION_PERIOD = manager.get_config("SESSION_RETENTION_PERIOD") - # Auto-apply patch releases (e.g. 4.1.0 -> 4.1.1) in the background. - # Minor and major bumps always require manual approval regardless. - AUTO_APPLY_PATCH_UPDATES = manager.get_config("AUTO_APPLY_PATCH_UPDATES") - # Recaptcha RECAPTCHA_ENABLED = manager.get_config("RECAPTCHA_ENABLED") RECAPTCHA_PUBLIC_KEY = manager.get_config("RECAPTCHA_PUBLIC_KEY") @@ -137,8 +154,8 @@ class Config(object): # Session SESSION_TYPE = "redis" - SESSION_REDIS = redis.from_url(f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/1") - PERMANENT_SESSION_LIFETIME = 3600 + SESSION_REDIS = redis.from_url(f"redis://:{_redis_pw_quoted}@{REDIS_HOST}:{REDIS_PORT}/1") + PERMANENT_SESSION_LIFETIME = int(os.environ.get("SESSION_LIFETIME", 3600)) # Google 0Auth GOOGLE_OAUTH_ENABLED = manager.get_config("GOOGLE_OAUTH_ENABLED") @@ -384,7 +401,7 @@ class TestConfig: REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") - REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0" + REDIS_URL = f"redis://:{quote(REDIS_PASSWORD, safe='')}@{REDIS_HOST}:{REDIS_PORT}/0" # Celery - use in-memory for tests to avoid Redis dependency celery_broker_url = "memory://" @@ -416,6 +433,10 @@ class TestConfig: SECURITY_MULTI_FACTOR_RECOVERY_CODES_N = 3 SECURITY_MULTI_FACTOR_RECOVERY_CODES_KEYS = None SECURITY_MULTI_FACTOR_RECOVERY_CODE_TTL = None + + # Login throttle (Flask-Limiter, applied to security.login). Tighter in tests. + LOGIN_RATE_LIMIT_PER_USERNAME = "5 per 15 minutes" + LOGIN_RATE_LIMIT_PER_IP = "10 per 15 minutes" SECURITY_TWO_FACTOR_ENABLED_METHODS = ["authenticator"] SECURITY_TWO_FACTOR = True SECURITY_TWO_FACTOR_RESCUE_MAIL = "test@example.com" @@ -484,7 +505,8 @@ class TestConfig: # Media & File Upload MEDIA_ALLOWED_EXTENSIONS = ["mp4", "webm", "jpg", "gif", "png", "pdf", "doc", "txt"] MEDIA_UPLOAD_MAX_FILE_SIZE = 1000 - SHEETS_ALLOWED_EXTENSIONS = ["csv", "xls", "xlsx"] + # legacy binary .xls is unreadable by the pinned openpyxl engine; only xlsx/csv + SHEETS_ALLOWED_EXTENSIONS = ["csv", "xlsx"] # Data Tools ETL_TOOL = True @@ -611,9 +633,6 @@ class TestConfig: # Notifications NOTIFICATIONS = NOTIFICATIONS_DEFAULT_CONFIG - # Auto-update - AUTO_APPLY_PATCH_UPDATES = False - # Dependencies (from dep_utils) HAS_WHISPER = dep_utils.has_whisper # Use actual dependency detection HAS_TESSERACT = dep_utils.has_tesseract # Use actual dependency detection diff --git a/enferno/setup/views.py b/enferno/setup/views.py index 1e1f8cbc5..1374a6715 100644 --- a/enferno/setup/views.py +++ b/enferno/setup/views.py @@ -8,11 +8,10 @@ Response, current_app, ) -from flask_security import hash_password, login_user, roles_required, current_user +from flask_security import auth_required, roles_required, current_user from enferno.admin.models import Eventtype, PotentialViolation, ClaimedViolation -from enferno.extensions import db -from enferno.user.models import User, Role +from enferno.user.models import User from enferno.utils.config_utils import ConfigManager from enferno.utils.data_helpers import import_default_data from enferno.utils.http_response import HTTPResponse @@ -30,15 +29,19 @@ def check_installation() -> bool: @bp_setup.before_app_request def handle_installation_check() -> Optional[Response]: - """Redirect to setup wizard if the app is not installed.""" + """Redirect to setup wizard if the app is not installed. + + The admin user is created out-of-band by the installer's CLI + bootstrap (`flask install`), not by this blueprint, so the wizard + requires authentication. Pre-auth flow paths are exempted so the + operator can sign in. + """ excluded_paths = [ "/setup_wizard", "/static", "/assets", "/_debug_toolbar", "/favicon.ico", - "/api/create-admin", - "/api/check-admin", "/api/default-config", "/api/import-data", "/api/check-data-imported", @@ -49,73 +52,28 @@ def handle_installation_check() -> Optional[Response]: "/admin/api/reload", "/fs-static", "/health", - ] - login_flow_paths = [ "/login", "/wan-signin", "/tf-validate", "/tf-select", + "/auth", + "/logout", ] - # Add /login to excluded paths if users exist - if User.query.first() is not None: - excluded_paths.extend(login_flow_paths) - if not any(request.path.startswith(path) for path in excluded_paths): if check_installation(): return redirect("/setup_wizard") @bp_setup.route("/setup_wizard") +@auth_required("session") def setup_wizard() -> str: - """Render the setup wizard template.""" + """Render the setup wizard template (admin-only post-install).""" + if not current_user.has_role("Admin"): + return redirect("/") return render_template("setup_wizard.html") -@bp_setup.post("/api/create-admin") -def create_admin() -> Any: - """Create an admin user if one doesn't exist.""" - admin_role = Role.query.filter(Role.name == "Admin").first() - - if admin_role.users.all(): - return HTTPResponse.error("Admin user already exists") - - data = request.json - username = data.get("username") - password = data.get("password") - - if not username or not password: - return HTTPResponse.error("Username and password are required") - - if User.query.filter(User.username == username.lower()).first(): - return HTTPResponse.error("Username already exists") - - new_admin = User(username=username, password=hash_password(password), active=1, name="Admin") - new_admin.roles.append(admin_role) - - db.session.add(new_admin) - try: - db.session.commit() - login_user(new_admin) - return HTTPResponse.created( - message="Admin user installed successfully", - data={"item": new_admin.to_dict()}, - ) - except Exception: - db.session.rollback() - return HTTPResponse.error("Failed to create admin user", status=500) - - -@bp_setup.get("/api/check-admin") -def check_admin() -> Dict[str, str]: - """Check if an admin user exists.""" - admin_role = Role.query.filter(Role.name == "Admin").first() - if admin_role and admin_role.users.first(): - return HTTPResponse.success(data={"status": "exists"}, message="Admin user already exists") - else: - return HTTPResponse.success(data={"status": "not_found"}, message="No admin user found") - - @bp_setup.post("/api/import-data") @roles_required("Admin") def import_data() -> Response: diff --git a/enferno/static/js/axios.min.js b/enferno/static/js/axios.min.js index b2ea603ac..8378a0430 100644 --- a/enferno/static/js/axios.min.js +++ b/enferno/static/js/axios.min.js @@ -1,5 +1,5 @@ -/*! Axios v1.15.0 Copyright (c) 2026 Matt Zabriskie and contributors */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).axios=t()}(this,function(){"use strict";function e(e,t){this.v=e,this.k=t}function t(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);ne.length)&&(t=e.length);for(var n=0,r=Array(t);n3?(o=h===r)&&(s=i[(u=i[4])?5:(u=3,3)],i[4]=i[5]=e):i[0]<=p&&((o=n<2&&pr||r>h)&&(i[4]=n,i[5]=r,d.n=h,u=0))}if(o||n>1)return a;throw l=!0,r}return function(o,f,h){if(c>1)throw TypeError("Generator is already running");for(l&&1===f&&p(f,h),u=f,s=h;(t=u<2?e:s)||!l;){i||(u?u<3?(u>1&&(d.n=-1),p(u,s)):d.n=s:d.v=s);try{if(c=2,i){if(u||(o="next"),t=i[o]){if(!(t=t.call(i,s)))throw TypeError("iterator result is not an object");if(!t.done)return t;s=t.value,u<2&&(u=0)}else 1===u&&(t=i.return)&&t.call(i),u<2&&(s=TypeError("The iterator does not provide a '"+o+"' method"),u=1);i=e}else if((t=(l=d.n<0)?s:n.call(r,d))!==a)break}catch(t){i=e,u=1,s=t}finally{c=1}}return{value:t,done:l}}}(n,o,i),!0),c}var a={};function u(){}function s(){}function c(){}t=Object.getPrototypeOf;var f=[][r]?t(t([][r]())):(g(t={},r,function(){return this}),t),l=c.prototype=u.prototype=Object.create(f);function d(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,c):(e.__proto__=c,g(e,o,"GeneratorFunction")),e.prototype=Object.create(l),e}return s.prototype=c,g(l,"constructor",c),g(c,"constructor",s),s.displayName="GeneratorFunction",g(c,o,"GeneratorFunction"),g(l),g(l,o,"Generator"),g(l,r,function(){return this}),g(l,"toString",function(){return"[object Generator]"}),(m=function(){return{w:i,m:d}})()}function g(e,t,n,r){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}g=function(e,t,n,r){function i(t,n){g(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!r,configurable:!r,writable:!r}):e[t]=n:(i("next",0),i("throw",1),i("return",2))},g(e,t,n,r)}function w(e){if(null!=e){var t=e["function"==typeof Symbol&&Symbol.iterator||"@@iterator"],n=0;if(t)return t.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length))return{next:function(){return e&&n>=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}}}throw new TypeError(typeof e+" is not iterable")}function O(e,t){return O=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},O(e,t)}function E(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i,a,u=[],s=!0,c=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;s=!1}else for(;!(s=(r=i.call(n)).done)&&(u.push(r.value),u.length!==t);s=!0);}catch(e){c=!0,o=e}finally{try{if(!s&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(c)throw o}}return u}}(e,t)||A(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function R(e){return function(e){if(Array.isArray(e))return t(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||A(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function S(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function T(e){return T="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},T(e)}function A(e,n){if(e){if("string"==typeof e)return t(e,n);var r={}.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?t(e,n):void 0}}function j(e){return function(){return new k(e.apply(this,arguments))}}function k(t){var n,r;function o(n,r){try{var a=t[n](r),u=a.value,s=u instanceof e;Promise.resolve(s?u.v:u).then(function(e){if(s){var r="return"===n?"return":"next";if(!u.k||e.done)return o(r,e);e=t[r](e).value}i(a.done?"return":"normal",e)},function(e){o("throw",e)})}catch(e){i("throw",e)}}function i(e,t){switch(e){case"return":n.resolve({value:t,done:!0});break;case"throw":n.reject(t);break;default:n.resolve({value:t,done:!1})}(n=n.next)?o(n.key,n.arg):r=null}this._invoke=function(e,t){return new Promise(function(i,a){var u={key:e,arg:t,resolve:i,reject:a,next:null};r?r=r.next=u:(n=r=u,o(e,t))})},"function"!=typeof t.return&&(this.return=void 0)}function P(e){var t="function"==typeof Map?new Map:void 0;return P=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,n)}function n(){return function(e,t,n){if(v())return Reflect.construct.apply(null,arguments);var r=[null];r.push.apply(r,t);var o=new(e.bind.apply(e,r));return n&&O(o,n.prototype),o}(e,arguments,p(this).constructor)}return n.prototype=Object.create(e.prototype,{constructor:{value:n,enumerable:!1,writable:!0,configurable:!0}}),O(n,e)},P(e)}function _(e,t){return function(){return e.apply(t,arguments)}}k.prototype["function"==typeof Symbol&&Symbol.asyncIterator||"@@asyncIterator"]=function(){return this},k.prototype.next=function(e){return this._invoke("next",e)},k.prototype.throw=function(e){return this._invoke("throw",e)},k.prototype.return=function(e){return this._invoke("return",e)};var x,N=Object.prototype.toString,C=Object.getPrototypeOf,U=Symbol.iterator,F=Symbol.toStringTag,D=(x=Object.create(null),function(e){var t=N.call(e);return x[t]||(x[t]=t.slice(8,-1).toLowerCase())}),B=function(e){return e=e.toLowerCase(),function(t){return D(t)===e}},L=function(e){return function(t){return T(t)===e}},I=Array.isArray,q=L("undefined");function M(e){return null!==e&&!q(e)&&null!==e.constructor&&!q(e.constructor)&&J(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}var z=B("ArrayBuffer");var H=L("string"),J=L("function"),W=L("number"),K=function(e){return null!==e&&"object"===T(e)},V=function(e){if("object"!==D(e))return!1;var t=C(e);return!(null!==t&&t!==Object.prototype&&null!==Object.getPrototypeOf(t)||F in e||U in e)},G=B("Date"),X=B("File"),$=B("Blob"),Q=B("FileList");var Y="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},Z=void 0!==Y.FormData?Y.FormData:void 0,ee=B("URLSearchParams"),te=E(["ReadableStream","Request","Response","Headers"].map(B),4),ne=te[0],re=te[1],oe=te[2],ie=te[3];function ae(e,t){var n,r,o=(arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).allOwnKeys,i=void 0!==o&&o;if(null!=e)if("object"!==T(e)&&(e=[e]),I(e))for(n=0,r=e.length;n0;)if(t===(n=r[o]).toLowerCase())return n;return null}var se="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:global,ce=function(e){return!q(e)&&e!==se};var fe,le=(fe="undefined"!=typeof Uint8Array&&C(Uint8Array),function(e){return fe&&e instanceof fe}),de=B("HTMLFormElement"),pe=function(){var e=Object.prototype.hasOwnProperty;return function(t,n){return e.call(t,n)}}(),he=B("RegExp"),ve=function(e,t){var n=Object.getOwnPropertyDescriptors(e),r={};ae(n,function(n,o){var i;!1!==(i=t(n,o,e))&&(r[o]=i||n)}),Object.defineProperties(e,r)};var ye,be,me,ge,we=B("AsyncFunction"),Oe=(ye="function"==typeof setImmediate,be=J(se.postMessage),ye?setImmediate:be?(me="axios@".concat(Math.random()),ge=[],se.addEventListener("message",function(e){var t=e.source,n=e.data;t===se&&n===me&&ge.length&&ge.shift()()},!1),function(e){ge.push(e),se.postMessage(me,"*")}):function(e){return setTimeout(e)}),Ee="undefined"!=typeof queueMicrotask?queueMicrotask.bind(se):"undefined"!=typeof process&&process.nextTick||Oe,Re={isArray:I,isArrayBuffer:z,isBuffer:M,isFormData:function(e){var t;return e&&(Z&&e instanceof Z||J(e.append)&&("formdata"===(t=D(e))||"object"===t&&J(e.toString)&&"[object FormData]"===e.toString()))},isArrayBufferView:function(e){return"undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&z(e.buffer)},isString:H,isNumber:W,isBoolean:function(e){return!0===e||!1===e},isObject:K,isPlainObject:V,isEmptyObject:function(e){if(!K(e)||M(e))return!1;try{return 0===Object.keys(e).length&&Object.getPrototypeOf(e)===Object.prototype}catch(e){return!1}},isReadableStream:ne,isRequest:re,isResponse:oe,isHeaders:ie,isUndefined:q,isDate:G,isFile:X,isReactNativeBlob:function(e){return!(!e||void 0===e.uri)},isReactNative:function(e){return e&&void 0!==e.getParts},isBlob:$,isRegExp:he,isFunction:J,isStream:function(e){return K(e)&&J(e.pipe)},isURLSearchParams:ee,isTypedArray:le,isFileList:Q,forEach:ae,merge:function e(){for(var t=ce(this)&&this||{},n=t.caseless,r=t.skipUndefined,o={},i=function(t,i){if("__proto__"!==i&&"constructor"!==i&&"prototype"!==i){var a=n&&ue(o,i)||i;V(o[a])&&V(t)?o[a]=e(o[a],t):V(t)?o[a]=e({},t):I(t)?o[a]=t.slice():r&&q(t)||(o[a]=t)}},a=0,u=arguments.length;a3&&void 0!==arguments[3]?arguments[3]:{}).allOwnKeys}),e},trim:function(e){return e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")},stripBOM:function(e){return 65279===e.charCodeAt(0)&&(e=e.slice(1)),e},inherits:function(e,t,n,r){e.prototype=Object.create(t.prototype,r),Object.defineProperty(e.prototype,"constructor",{value:e,writable:!0,enumerable:!1,configurable:!0}),Object.defineProperty(e,"super",{value:t.prototype}),n&&Object.assign(e.prototype,n)},toFlatObject:function(e,t,n,r){var o,i,a,u={};if(t=t||{},null==e)return t;do{for(i=(o=Object.getOwnPropertyNames(e)).length;i-- >0;)a=o[i],r&&!r(a,e,t)||u[a]||(t[a]=e[a],u[a]=!0);e=!1!==n&&C(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},kindOf:D,kindOfTest:B,endsWith:function(e,t,n){e=String(e),(void 0===n||n>e.length)&&(n=e.length),n-=t.length;var r=e.indexOf(t,n);return-1!==r&&r===n},toArray:function(e){if(!e)return null;if(I(e))return e;var t=e.length;if(!W(t))return null;for(var n=new Array(t);t-- >0;)n[t]=e[t];return n},forEachEntry:function(e,t){for(var n,r=(e&&e[U]).call(e);(n=r.next())&&!n.done;){var o=n.value;t.call(e,o[0],o[1])}},matchAll:function(e,t){for(var n,r=[];null!==(n=e.exec(t));)r.push(n);return r},isHTMLForm:de,hasOwnProperty:pe,hasOwnProp:pe,reduceDescriptors:ve,freezeMethods:function(e){ve(e,function(t,n){if(J(e)&&-1!==["arguments","caller","callee"].indexOf(n))return!1;var r=e[n];J(r)&&(t.enumerable=!1,"writable"in t?t.writable=!1:t.set||(t.set=function(){throw Error("Can not rewrite read-only method '"+n+"'")}))})},toObjectSet:function(e,t){var n={},r=function(e){e.forEach(function(e){n[e]=!0})};return I(e)?r(e):r(String(e).split(t)),n},toCamelCase:function(e){return e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(e,t,n){return t.toUpperCase()+n})},noop:function(){},toFiniteNumber:function(e,t){return null!=e&&Number.isFinite(e=+e)?e:t},findKey:ue,global:se,isContextDefined:ce,isSpecCompliantForm:function(e){return!!(e&&J(e.append)&&"FormData"===e[F]&&e[U])},toJSONObject:function(e){var t=new Array(10),n=function(e,r){if(K(e)){if(t.indexOf(e)>=0)return;if(M(e))return e;if(!("toJSON"in e)){t[r]=e;var o=I(e)?[]:{};return ae(e,function(e,t){var i=n(e,r+1);!q(i)&&(o[t]=i)}),t[r]=void 0,o}}return e};return n(e,0)},isAsyncFn:we,isThenable:function(e){return e&&(K(e)||J(e))&&J(e.then)&&J(e.catch)},setImmediate:Oe,asap:Ee,isIterable:function(e){return null!=e&&J(e[U])}},Se=function(e){function t(e,n,r,o,i){var a;return c(this,t),a=s(this,t,[e]),Object.defineProperty(a,"message",{value:e,enumerable:!0,writable:!0,configurable:!0}),a.name="AxiosError",a.isAxiosError=!0,n&&(a.code=n),r&&(a.config=r),o&&(a.request=o),i&&(a.response=i,a.status=i.status),a}return h(t,e),l(t,[{key:"toJSON",value:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:Re.toJSONObject(this.config),code:this.code,status:this.status}}}],[{key:"from",value:function(e,n,r,o,i,a){var u=new t(e.message,n||e.code,r,o,i);return u.cause=e,u.name=e.name,null!=e.status&&null==u.status&&(u.status=e.status),a&&Object.assign(u,a),u}}])}(P(Error));Se.ERR_BAD_OPTION_VALUE="ERR_BAD_OPTION_VALUE",Se.ERR_BAD_OPTION="ERR_BAD_OPTION",Se.ECONNABORTED="ECONNABORTED",Se.ETIMEDOUT="ETIMEDOUT",Se.ERR_NETWORK="ERR_NETWORK",Se.ERR_FR_TOO_MANY_REDIRECTS="ERR_FR_TOO_MANY_REDIRECTS",Se.ERR_DEPRECATED="ERR_DEPRECATED",Se.ERR_BAD_RESPONSE="ERR_BAD_RESPONSE",Se.ERR_BAD_REQUEST="ERR_BAD_REQUEST",Se.ERR_CANCELED="ERR_CANCELED",Se.ERR_NOT_SUPPORT="ERR_NOT_SUPPORT",Se.ERR_INVALID_URL="ERR_INVALID_URL";function Te(e){return Re.isPlainObject(e)||Re.isArray(e)}function Ae(e){return Re.endsWith(e,"[]")?e.slice(0,-2):e}function je(e,t,n){return e?e.concat(t).map(function(e,t){return e=Ae(e),!n&&t?"["+e+"]":e}).join(n?".":""):t}var ke=Re.toFlatObject(Re,{},null,function(e){return/^is[A-Z]/.test(e)});function Pe(e,t,n){if(!Re.isObject(e))throw new TypeError("target must be an object");t=t||new FormData;var r=(n=Re.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(e,t){return!Re.isUndefined(t[e])})).metaTokens,o=n.visitor||c,i=n.dots,a=n.indexes,u=(n.Blob||"undefined"!=typeof Blob&&Blob)&&Re.isSpecCompliantForm(t);if(!Re.isFunction(o))throw new TypeError("visitor must be a function");function s(e){if(null===e)return"";if(Re.isDate(e))return e.toISOString();if(Re.isBoolean(e))return e.toString();if(!u&&Re.isBlob(e))throw new Se("Blob is not supported. Use a Buffer instead.");return Re.isArrayBuffer(e)||Re.isTypedArray(e)?u&&"function"==typeof Blob?new Blob([e]):Buffer.from(e):e}function c(e,n,o){var u=e;if(Re.isReactNative(t)&&Re.isReactNativeBlob(e))return t.append(je(o,n,i),s(e)),!1;if(e&&!o&&"object"===T(e))if(Re.endsWith(n,"{}"))n=r?n:n.slice(0,-2),e=JSON.stringify(e);else if(Re.isArray(e)&&function(e){return Re.isArray(e)&&!e.some(Te)}(e)||(Re.isFileList(e)||Re.endsWith(n,"[]"))&&(u=Re.toArray(e)))return n=Ae(n),u.forEach(function(e,r){!Re.isUndefined(e)&&null!==e&&t.append(!0===a?je([n],r,i):null===a?n:n+"[]",s(e))}),!1;return!!Te(e)||(t.append(je(o,n,i),s(e)),!1)}var f=[],l=Object.assign(ke,{defaultVisitor:c,convertValue:s,isVisitable:Te});if(!Re.isObject(e))throw new TypeError("data must be an object");return function e(n,r){if(!Re.isUndefined(n)){if(-1!==f.indexOf(n))throw Error("Circular reference detected in "+r.join("."));f.push(n),Re.forEach(n,function(n,i){!0===(!(Re.isUndefined(n)||null===n)&&o.call(t,n,Re.isString(i)?i.trim():i,r,l))&&e(n,r?r.concat(i):[i])}),f.pop()}}(e),t}function _e(e){var t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,function(e){return t[e]})}function xe(e,t){this._pairs=[],e&&Pe(e,this,t)}var Ne=xe.prototype;function Ce(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function Ue(e,t,n){if(!t)return e;var r,o=n&&n.encode||Ce,i=Re.isFunction(n)?{serialize:n}:n,a=i&&i.serialize;if(r=a?a(t,i):Re.isURLSearchParams(t)?t.toString():new xe(t,i).toString(o)){var u=e.indexOf("#");-1!==u&&(e=e.slice(0,u)),e+=(-1===e.indexOf("?")?"?":"&")+r}return e}Ne.append=function(e,t){this._pairs.push([e,t])},Ne.toString=function(e){var t=e?function(t){return e.call(this,t,_e)}:_e;return this._pairs.map(function(e){return t(e[0])+"="+t(e[1])},"").join("&")};var Fe=function(){return l(function e(){c(this,e),this.handlers=[]},[{key:"use",value:function(e,t,n){return this.handlers.push({fulfilled:e,rejected:t,synchronous:!!n&&n.synchronous,runWhen:n?n.runWhen:null}),this.handlers.length-1}},{key:"eject",value:function(e){this.handlers[e]&&(this.handlers[e]=null)}},{key:"clear",value:function(){this.handlers&&(this.handlers=[])}},{key:"forEach",value:function(e){Re.forEach(this.handlers,function(t){null!==t&&e(t)})}}])}(),De={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1,legacyInterceptorReqResOrdering:!0},Be={isBrowser:!0,classes:{URLSearchParams:"undefined"!=typeof URLSearchParams?URLSearchParams:xe,FormData:"undefined"!=typeof FormData?FormData:null,Blob:"undefined"!=typeof Blob?Blob:null},protocols:["http","https","file","blob","url","data"]},Le="undefined"!=typeof window&&"undefined"!=typeof document,Ie="object"===("undefined"==typeof navigator?"undefined":T(navigator))&&navigator||void 0,qe=Le&&(!Ie||["ReactNative","NativeScript","NS"].indexOf(Ie.product)<0),Me="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope&&"function"==typeof self.importScripts,ze=Le&&window.location.href||"http://localhost",He=b(b({},Object.freeze({__proto__:null,hasBrowserEnv:Le,hasStandardBrowserEnv:qe,hasStandardBrowserWebWorkerEnv:Me,navigator:Ie,origin:ze})),Be);function Je(e){function t(e,n,r,o){var i=e[o++];if("__proto__"===i)return!0;var a=Number.isFinite(+i),u=o>=e.length;return i=!i&&Re.isArray(r)?r.length:i,u?(Re.hasOwnProp(r,i)?r[i]=[r[i],n]:r[i]=n,!a):(r[i]&&Re.isObject(r[i])||(r[i]=[]),t(e,n,r[i],o)&&Re.isArray(r[i])&&(r[i]=function(e){var t,n,r={},o=Object.keys(e),i=o.length;for(t=0;t-1,i=Re.isObject(e);if(i&&Re.isHTMLForm(e)&&(e=new FormData(e)),Re.isFormData(e))return o?JSON.stringify(Je(e)):e;if(Re.isArrayBuffer(e)||Re.isBuffer(e)||Re.isStream(e)||Re.isFile(e)||Re.isBlob(e)||Re.isReadableStream(e))return e;if(Re.isArrayBufferView(e))return e.buffer;if(Re.isURLSearchParams(e))return t.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();if(i){if(r.indexOf("application/x-www-form-urlencoded")>-1)return function(e,t){return Pe(e,new He.classes.URLSearchParams,b({visitor:function(e,t,n,r){return He.isNode&&Re.isBuffer(e)?(this.append(t,e.toString("base64")),!1):r.defaultVisitor.apply(this,arguments)}},t))}(e,this.formSerializer).toString();if((n=Re.isFileList(e))||r.indexOf("multipart/form-data")>-1){var a=this.env&&this.env.FormData;return Pe(n?{"files[]":e}:e,a&&new a,this.formSerializer)}}return i||o?(t.setContentType("application/json",!1),function(e,t,n){if(Re.isString(e))try{return(t||JSON.parse)(e),Re.trim(e)}catch(e){if("SyntaxError"!==e.name)throw e}return(n||JSON.stringify)(e)}(e)):e}],transformResponse:[function(e){var t=this.transitional||We.transitional,n=t&&t.forcedJSONParsing,r="json"===this.responseType;if(Re.isResponse(e)||Re.isReadableStream(e))return e;if(e&&Re.isString(e)&&(n&&!this.responseType||r)){var o=!(t&&t.silentJSONParsing)&&r;try{return JSON.parse(e,this.parseReviver)}catch(e){if(o){if("SyntaxError"===e.name)throw Se.from(e,Se.ERR_BAD_RESPONSE,this,null,this.response);throw e}}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:He.classes.FormData,Blob:He.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};Re.forEach(["delete","get","head","post","put","patch"],function(e){We.headers[e]={}});var Ke=Re.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),Ve=Symbol("internals");function Ge(e,t){if(!1!==e&&null!=e)if(Re.isArray(e))e.forEach(function(e){return Ge(e,t)});else if(!function(e){return!/[\r\n]/.test(e)}(String(e)))throw new Error('Invalid character in header content ["'.concat(t,'"]'))}function Xe(e){return e&&String(e).trim().toLowerCase()}function $e(e){return!1===e||null==e?e:Re.isArray(e)?e.map($e):function(e){for(var t=e.length;t>0;){var n=e.charCodeAt(t-1);if(10!==n&&13!==n)break;t-=1}return t===e.length?e:e.slice(0,t)}(String(e))}function Qe(e,t,n,r,o){return Re.isFunction(r)?r.call(this,t,n):(o&&(t=n),Re.isString(t)?Re.isString(r)?-1!==t.indexOf(r):Re.isRegExp(r)?r.test(t):void 0:void 0)}var Ye=function(){return l(function e(t){c(this,e),t&&this.set(t)},[{key:"set",value:function(e,t,n){var r=this;function o(e,t,n){var o=Xe(t);if(!o)throw new Error("header name must be a non-empty string");var i=Re.findKey(r,o);(!i||void 0===r[i]||!0===n||void 0===n&&!1!==r[i])&&(Ge(e,t),r[i||t]=$e(e))}var i=function(e,t){return Re.forEach(e,function(e,n){return o(e,n,t)})};if(Re.isPlainObject(e)||e instanceof this.constructor)i(e,t);else if(Re.isString(e)&&(e=e.trim())&&!/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim()))i(function(e){var t,n,r,o={};return e&&e.split("\n").forEach(function(e){r=e.indexOf(":"),t=e.substring(0,r).trim().toLowerCase(),n=e.substring(r+1).trim(),!t||o[t]&&Ke[t]||("set-cookie"===t?o[t]?o[t].push(n):o[t]=[n]:o[t]=o[t]?o[t]+", "+n:n)}),o}(e),t);else if(Re.isObject(e)&&Re.isIterable(e)){var a,u,s,c={},f=function(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=A(e))||t){n&&(e=n);var r=0,o=function(){};return{s:o,n:function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,u=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){u=!0,i=e},f:function(){try{a||null==n.return||n.return()}finally{if(u)throw i}}}}(e);try{for(f.s();!(s=f.n()).done;){var l=s.value;if(!Re.isArray(l))throw TypeError("Object iterator must return a key-value pair");c[u=l[0]]=(a=c[u])?Re.isArray(a)?[].concat(R(a),[l[1]]):[a,l[1]]:l[1]}}catch(e){f.e(e)}finally{f.f()}i(c,t)}else null!=e&&o(t,e,n);return this}},{key:"get",value:function(e,t){if(e=Xe(e)){var n=Re.findKey(this,e);if(n){var r=this[n];if(!t)return r;if(!0===t)return function(e){for(var t,n=Object.create(null),r=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;t=r.exec(e);)n[t[1]]=t[2];return n}(r);if(Re.isFunction(t))return t.call(this,r,n);if(Re.isRegExp(t))return t.exec(r);throw new TypeError("parser must be boolean|regexp|function")}}}},{key:"has",value:function(e,t){if(e=Xe(e)){var n=Re.findKey(this,e);return!(!n||void 0===this[n]||t&&!Qe(0,this[n],n,t))}return!1}},{key:"delete",value:function(e,t){var n=this,r=!1;function o(e){if(e=Xe(e)){var o=Re.findKey(n,e);!o||t&&!Qe(0,n[o],o,t)||(delete n[o],r=!0)}}return Re.isArray(e)?e.forEach(o):o(e),r}},{key:"clear",value:function(e){for(var t=Object.keys(this),n=t.length,r=!1;n--;){var o=t[n];e&&!Qe(0,this[o],o,e,!0)||(delete this[o],r=!0)}return r}},{key:"normalize",value:function(e){var t=this,n={};return Re.forEach(this,function(r,o){var i=Re.findKey(n,o);if(i)return t[i]=$e(r),void delete t[o];var a=e?function(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,function(e,t,n){return t.toUpperCase()+n})}(o):String(o).trim();a!==o&&delete t[o],t[a]=$e(r),n[a]=!0}),this}},{key:"concat",value:function(){for(var e,t=arguments.length,n=new Array(t),r=0;r1?n-1:0),o=1;o2&&void 0!==arguments[2]?arguments[2]:3,r=0,o=function(e,t){e=e||10;var n,r=new Array(e),o=new Array(e),i=0,a=0;return t=void 0!==t?t:1e3,function(u){var s=Date.now(),c=o[a];n||(n=s),r[i]=u,o[i]=s;for(var f=a,l=0;f!==i;)l+=r[f++],f%=e;if((i=(i+1)%e)===a&&(a=(a+1)%e),!(s-n1&&void 0!==arguments[1]?arguments[1]:Date.now();o=i,n=null,r&&(clearTimeout(r),r=null),e.apply(void 0,R(t))};return[function(){for(var e=Date.now(),t=e-o,u=arguments.length,s=new Array(u),c=0;c=i?a(s,e):(n=s,r||(r=setTimeout(function(){r=null,a(n)},i-t)))},function(){return n&&a(n)}]}(function(n){var i=n.loaded,a=n.lengthComputable?n.total:void 0,u=i-r,s=o(u);r=i;var c=d({loaded:i,total:a,progress:a?i/a:void 0,bytes:u,rate:s||void 0,estimated:s&&a&&i<=a?(a-i)/s:void 0,event:n,lengthComputable:null!=a},t?"download":"upload",!0);e(c)},n)},ot=function(e,t){var n=null!=e;return[function(r){return t[0]({lengthComputable:n,total:e,loaded:r})},t[1]]},it=function(e){return function(){for(var t=arguments.length,n=new Array(t),r=0;r1?t-1:0),r=1;r1?"since :\n"+s.map(Pt).join("\n"):" "+Pt(s[0]):"as no adapter specified";throw new Se("There is no suitable adapter to dispatch the request "+c,"ERR_NOT_SUPPORT")}return r},adapters:kt};function Nt(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new tt(null,e)}function Ct(e){return Nt(e),e.headers=Ye.from(e.headers),e.data=Ze.call(e,e.transformRequest),-1!==["post","put","patch"].indexOf(e.method)&&e.headers.setContentType("application/x-www-form-urlencoded",!1),xt.getAdapter(e.adapter||We.adapter,e)(e).then(function(t){return Nt(e),t.data=Ze.call(e,e.transformResponse,t),t.headers=Ye.from(t.headers),t},function(t){return et(t)||(Nt(e),t&&t.response&&(t.response.data=Ze.call(e,e.transformResponse,t.response),t.response.headers=Ye.from(t.response.headers))),Promise.reject(t)})}var Ut="1.15.0",Ft={};["object","boolean","number","function","string","symbol"].forEach(function(e,t){Ft[e]=function(n){return T(n)===e||"a"+(t<1?"n ":" ")+e}});var Dt={};Ft.transitional=function(e,t,n){function r(e,t){return"[Axios v"+Ut+"] Transitional option '"+e+"'"+t+(n?". "+n:"")}return function(n,o,i){if(!1===e)throw new Se(r(o," has been removed"+(t?" in "+t:"")),Se.ERR_DEPRECATED);return t&&!Dt[o]&&(Dt[o]=!0,console.warn(r(o," has been deprecated since v"+t+" and will be removed in the near future"))),!e||e(n,o,i)}},Ft.spelling=function(e){return function(t,n){return console.warn("".concat(n," is likely a misspelling of ").concat(e)),!0}};var Bt={assertOptions:function(e,t,n){if("object"!==T(e))throw new Se("options must be an object",Se.ERR_BAD_OPTION_VALUE);for(var r=Object.keys(e),o=r.length;o-- >0;){var i=r[o],a=t[i];if(a){var u=e[i],s=void 0===u||a(u,i,e);if(!0!==s)throw new Se("option "+i+" must be "+s,Se.ERR_BAD_OPTION_VALUE)}else if(!0!==n)throw new Se("Unknown option "+i,Se.ERR_BAD_OPTION)}},validators:Ft},Lt=Bt.validators,It=function(){return l(function e(t){c(this,e),this.defaults=t||{},this.interceptors={request:new Fe,response:new Fe}},[{key:"request",value:(e=a(m().m(function e(t,n){var r,o,i,a,u,s;return m().w(function(e){for(;;)switch(e.p=e.n){case 0:return e.p=0,e.n=1,this._request(t,n);case 1:return e.a(2,e.v);case 2:if(e.p=2,(s=e.v)instanceof Error){r={},Error.captureStackTrace?Error.captureStackTrace(r):r=new Error,o=function(){if(!r.stack)return"";var e=r.stack.indexOf("\n");return-1===e?"":r.stack.slice(e+1)}();try{s.stack?o&&(i=o.indexOf("\n"),a=-1===i?-1:o.indexOf("\n",i+1),u=-1===a?"":o.slice(a+1),String(s.stack).endsWith(u)||(s.stack+="\n"+o)):s.stack=o}catch(e){}}throw s;case 3:return e.a(2)}},e,this,[[0,2]])})),function(t,n){return e.apply(this,arguments)})},{key:"_request",value:function(e,t){"string"==typeof e?(t=t||{}).url=e:t=e||{};var n=t=ft(this.defaults,t),r=n.transitional,o=n.paramsSerializer,i=n.headers;void 0!==r&&Bt.assertOptions(r,{silentJSONParsing:Lt.transitional(Lt.boolean),forcedJSONParsing:Lt.transitional(Lt.boolean),clarifyTimeoutError:Lt.transitional(Lt.boolean),legacyInterceptorReqResOrdering:Lt.transitional(Lt.boolean)},!1),null!=o&&(Re.isFunction(o)?t.paramsSerializer={serialize:o}:Bt.assertOptions(o,{encode:Lt.function,serialize:Lt.function},!0)),void 0!==t.allowAbsoluteUrls||(void 0!==this.defaults.allowAbsoluteUrls?t.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:t.allowAbsoluteUrls=!0),Bt.assertOptions(t,{baseUrl:Lt.spelling("baseURL"),withXsrfToken:Lt.spelling("withXSRFToken")},!0),t.method=(t.method||this.defaults.method||"get").toLowerCase();var a=i&&Re.merge(i.common,i[t.method]);i&&Re.forEach(["delete","get","head","post","put","patch","common"],function(e){delete i[e]}),t.headers=Ye.concat(a,i);var u=[],s=!0;this.interceptors.request.forEach(function(e){if("function"!=typeof e.runWhen||!1!==e.runWhen(t)){s=s&&e.synchronous;var n=t.transitional||De;n&&n.legacyInterceptorReqResOrdering?u.unshift(e.fulfilled,e.rejected):u.push(e.fulfilled,e.rejected)}});var c,f=[];this.interceptors.response.forEach(function(e){f.push(e.fulfilled,e.rejected)});var l,d=0;if(!s){var p=[Ct.bind(this),void 0];for(p.unshift.apply(p,u),p.push.apply(p,f),l=p.length,c=Promise.resolve(t);d0;)r._listeners[t](e);r._listeners=null}}),this.promise.then=function(e){var t,n=new Promise(function(e){r.subscribe(e),t=e}).then(e);return n.cancel=function(){r.unsubscribe(t)},n},t(function(e,t,o){r.reason||(r.reason=new tt(e,t,o),n(r.reason))})}return l(e,[{key:"throwIfRequested",value:function(){if(this.reason)throw this.reason}},{key:"subscribe",value:function(e){this.reason?e(this.reason):this._listeners?this._listeners.push(e):this._listeners=[e]}},{key:"unsubscribe",value:function(e){if(this._listeners){var t=this._listeners.indexOf(e);-1!==t&&this._listeners.splice(t,1)}}},{key:"toAbortSignal",value:function(){var e=this,t=new AbortController,n=function(e){t.abort(e)};return this.subscribe(n),t.signal.unsubscribe=function(){return e.unsubscribe(n)},t.signal}}],[{key:"source",value:function(){var t;return{token:new e(function(e){t=e}),cancel:t}}}])}();var Mt={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(Mt).forEach(function(e){var t=E(e,2),n=t[0],r=t[1];Mt[r]=n});var zt=function e(t){var n=new It(t),r=_(It.prototype.request,n);return Re.extend(r,It.prototype,n,{allOwnKeys:!0}),Re.extend(r,n,null,{allOwnKeys:!0}),r.create=function(n){return e(ft(t,n))},r}(We);return zt.Axios=It,zt.CanceledError=tt,zt.CancelToken=qt,zt.isCancel=et,zt.VERSION=Ut,zt.toFormData=Pe,zt.AxiosError=Se,zt.Cancel=zt.CanceledError,zt.all=function(e){return Promise.all(e)},zt.spread=function(e){return function(t){return e.apply(null,t)}},zt.isAxiosError=function(e){return Re.isObject(e)&&!0===e.isAxiosError},zt.mergeConfig=ft,zt.AxiosHeaders=Ye,zt.formToJSON=function(e){return Je(Re.isHTMLForm(e)?new FormData(e):e)},zt.getAdapter=xt.getAdapter,zt.HttpStatusCode=Mt,zt.default=zt,zt}); +var e,t,n="function"==typeof Symbol?Symbol:{},r=n.iterator||"@@iterator",o=n.toStringTag||"@@toStringTag";function i(n,r,o,i){var s=r&&r.prototype instanceof u?r:u,c=Object.create(s.prototype);return g(c,"_invoke",function(n,r,o){var i,u,s,c=0,f=o||[],l=!1,d={p:0,n:0,v:e,a:p,f:p.bind(e,4),d:function(t,n){return i=t,u=0,s=e,d.n=n,a}};function p(n,r){for(u=n,s=r,t=0;!l&&c&&!o&&t3?(o=h===r)&&(s=i[(u=i[4])?5:(u=3,3)],i[4]=i[5]=e):i[0]<=p&&((o=n<2&&pr||r>h)&&(i[4]=n,i[5]=r,d.n=h,u=0))}if(o||n>1)return a;throw l=!0,r}return function(o,f,h){if(c>1)throw TypeError("Generator is already running");for(l&&1===f&&p(f,h),u=f,s=h;(t=u<2?e:s)||!l;){i||(u?u<3?(u>1&&(d.n=-1),p(u,s)):d.n=s:d.v=s);try{if(c=2,i){if(u||(o="next"),t=i[o]){if(!(t=t.call(i,s)))throw TypeError("iterator result is not an object");if(!t.done)return t;s=t.value,u<2&&(u=0)}else 1===u&&(t=i.return)&&t.call(i),u<2&&(s=TypeError("The iterator does not provide a '"+o+"' method"),u=1);i=e}else if((t=(l=d.n<0)?s:n.call(r,d))!==a)break}catch(t){i=e,u=1,s=t}finally{c=1}}return{value:t,done:l}}}(n,o,i),!0),c}var a={};function u(){}function s(){}function c(){}t=Object.getPrototypeOf;var f=[][r]?t(t([][r]())):(g(t={},r,function(){return this}),t),l=c.prototype=u.prototype=Object.create(f);function d(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,c):(e.__proto__=c,g(e,o,"GeneratorFunction")),e.prototype=Object.create(l),e}return s.prototype=c,g(l,"constructor",c),g(c,"constructor",s),s.displayName="GeneratorFunction",g(c,o,"GeneratorFunction"),g(l),g(l,o,"Generator"),g(l,r,function(){return this}),g(l,"toString",function(){return"[object Generator]"}),(m=function(){return{w:i,m:d}})()}function g(e,t,n,r){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}g=function(e,t,n,r){function i(t,n){g(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!r,configurable:!r,writable:!r}):e[t]=n:(i("next",0),i("throw",1),i("return",2))},g(e,t,n,r)}function w(e){if(null!=e){var t=e["function"==typeof Symbol&&Symbol.iterator||"@@iterator"],n=0;if(t)return t.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length))return{next:function(){return e&&n>=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}}}throw new TypeError(typeof e+" is not iterable")}function O(e,t){return O=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},O(e,t)}function E(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i,a,u=[],s=!0,c=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;s=!1}else for(;!(s=(r=i.call(n)).done)&&(u.push(r.value),u.length!==t);s=!0);}catch(e){c=!0,o=e}finally{try{if(!s&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(c)throw o}}return u}}(e,t)||A(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function R(e){return function(e){if(Array.isArray(e))return t(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||A(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function S(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function _(e){return _="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_(e)}function A(e,n){if(e){if("string"==typeof e)return t(e,n);var r={}.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?t(e,n):void 0}}function T(e){return function(){return new j(e.apply(this,arguments))}}function j(t){var n,r;function o(n,r){try{var a=t[n](r),u=a.value,s=u instanceof e;Promise.resolve(s?u.v:u).then(function(e){if(s){var r="return"===n?"return":"next";if(!u.k||e.done)return o(r,e);e=t[r](e).value}i(a.done?"return":"normal",e)},function(e){o("throw",e)})}catch(e){i("throw",e)}}function i(e,t){switch(e){case"return":n.resolve({value:t,done:!0});break;case"throw":n.reject(t);break;default:n.resolve({value:t,done:!1})}(n=n.next)?o(n.key,n.arg):r=null}this._invoke=function(e,t){return new Promise(function(i,a){var u={key:e,arg:t,resolve:i,reject:a,next:null};r?r=r.next=u:(n=r=u,o(e,t))})},"function"!=typeof t.return&&(this.return=void 0)}function P(e){var t="function"==typeof Map?new Map:void 0;return P=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,n)}function n(){return function(e,t,n){if(y())return Reflect.construct.apply(null,arguments);var r=[null];r.push.apply(r,t);var o=new(e.bind.apply(e,r));return n&&O(o,n.prototype),o}(e,arguments,p(this).constructor)}return n.prototype=Object.create(e.prototype,{constructor:{value:n,enumerable:!1,writable:!0,configurable:!0}}),O(n,e)},P(e)}function k(e,t){return function(){return e.apply(t,arguments)}}j.prototype["function"==typeof Symbol&&Symbol.asyncIterator||"@@asyncIterator"]=function(){return this},j.prototype.next=function(e){return this._invoke("next",e)},j.prototype.throw=function(e){return this._invoke("throw",e)},j.prototype.return=function(e){return this._invoke("return",e)};var x,C=Object.prototype.toString,N=Object.getPrototypeOf,D=Symbol.iterator,U=Symbol.toStringTag,F=(x=Object.create(null),function(e){var t=C.call(e);return x[t]||(x[t]=t.slice(8,-1).toLowerCase())}),L=function(e){return e=e.toLowerCase(),function(t){return F(t)===e}},B=function(e){return function(t){return _(t)===e}},I=Array.isArray,q=B("undefined");function M(e){return null!==e&&!q(e)&&null!==e.constructor&&!q(e.constructor)&&J(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}var z=L("ArrayBuffer");var H=B("string"),J=B("function"),W=B("number"),K=function(e){return null!==e&&"object"===_(e)},V=function(e){if("object"!==F(e))return!1;var t=N(e);return!(null!==t&&t!==Object.prototype&&null!==Object.getPrototypeOf(t)||U in e||D in e)},X=L("Date"),G=L("File"),$=L("Blob"),Q=L("FileList");var Y="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},Z=void 0!==Y.FormData?Y.FormData:void 0,ee=L("URLSearchParams"),te=E(["ReadableStream","Request","Response","Headers"].map(L),4),ne=te[0],re=te[1],oe=te[2],ie=te[3];function ae(e,t){var n,r,o=(arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).allOwnKeys,i=void 0!==o&&o;if(null!=e)if("object"!==_(e)&&(e=[e]),I(e))for(n=0,r=e.length;n0;)if(t===(n=r[o]).toLowerCase())return n;return null}var se="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:global,ce=function(e){return!q(e)&&e!==se};var fe,le=(fe="undefined"!=typeof Uint8Array&&N(Uint8Array),function(e){return fe&&e instanceof fe}),de=L("HTMLFormElement"),pe=function(){var e=Object.prototype.hasOwnProperty;return function(t,n){return e.call(t,n)}}(),he=L("RegExp"),ye=function(e,t){var n=Object.getOwnPropertyDescriptors(e),r={};ae(n,function(n,o){var i;!1!==(i=t(n,o,e))&&(r[o]=i||n)}),Object.defineProperties(e,r)};var ve,be,me,ge,we=L("AsyncFunction"),Oe=(ve="function"==typeof setImmediate,be=J(se.postMessage),ve?setImmediate:be?(me="axios@".concat(Math.random()),ge=[],se.addEventListener("message",function(e){var t=e.source,n=e.data;t===se&&n===me&&ge.length&&ge.shift()()},!1),function(e){ge.push(e),se.postMessage(me,"*")}):function(e){return setTimeout(e)}),Ee="undefined"!=typeof queueMicrotask?queueMicrotask.bind(se):"undefined"!=typeof process&&process.nextTick||Oe,Re={isArray:I,isArrayBuffer:z,isBuffer:M,isFormData:function(e){if(!e)return!1;if(Z&&e instanceof Z)return!0;var t=N(e);if(!t||t===Object.prototype)return!1;if(!J(e.append))return!1;var n=F(e);return"formdata"===n||"object"===n&&J(e.toString)&&"[object FormData]"===e.toString()},isArrayBufferView:function(e){return"undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&z(e.buffer)},isString:H,isNumber:W,isBoolean:function(e){return!0===e||!1===e},isObject:K,isPlainObject:V,isEmptyObject:function(e){if(!K(e)||M(e))return!1;try{return 0===Object.keys(e).length&&Object.getPrototypeOf(e)===Object.prototype}catch(e){return!1}},isReadableStream:ne,isRequest:re,isResponse:oe,isHeaders:ie,isUndefined:q,isDate:X,isFile:G,isReactNativeBlob:function(e){return!(!e||void 0===e.uri)},isReactNative:function(e){return e&&void 0!==e.getParts},isBlob:$,isRegExp:he,isFunction:J,isStream:function(e){return K(e)&&J(e.pipe)},isURLSearchParams:ee,isTypedArray:le,isFileList:Q,forEach:ae,merge:function e(){for(var t=ce(this)&&this||{},n=t.caseless,r=t.skipUndefined,o={},i=function(t,i){if("__proto__"!==i&&"constructor"!==i&&"prototype"!==i){var a=n&&ue(o,i)||i,u=pe(o,a)?o[a]:void 0;V(u)&&V(t)?o[a]=e(u,t):V(t)?o[a]=e({},t):I(t)?o[a]=t.slice():r&&q(t)||(o[a]=t)}},a=arguments.length,u=new Array(a),s=0;s3&&void 0!==arguments[3]?arguments[3]:{}).allOwnKeys}),e},trim:function(e){return e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")},stripBOM:function(e){return 65279===e.charCodeAt(0)&&(e=e.slice(1)),e},inherits:function(e,t,n,r){e.prototype=Object.create(t.prototype,r),Object.defineProperty(e.prototype,"constructor",{__proto__:null,value:e,writable:!0,enumerable:!1,configurable:!0}),Object.defineProperty(e,"super",{__proto__:null,value:t.prototype}),n&&Object.assign(e.prototype,n)},toFlatObject:function(e,t,n,r){var o,i,a,u={};if(t=t||{},null==e)return t;do{for(i=(o=Object.getOwnPropertyNames(e)).length;i-- >0;)a=o[i],r&&!r(a,e,t)||u[a]||(t[a]=e[a],u[a]=!0);e=!1!==n&&N(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},kindOf:F,kindOfTest:L,endsWith:function(e,t,n){e=String(e),(void 0===n||n>e.length)&&(n=e.length),n-=t.length;var r=e.indexOf(t,n);return-1!==r&&r===n},toArray:function(e){if(!e)return null;if(I(e))return e;var t=e.length;if(!W(t))return null;for(var n=new Array(t);t-- >0;)n[t]=e[t];return n},forEachEntry:function(e,t){for(var n,r=(e&&e[D]).call(e);(n=r.next())&&!n.done;){var o=n.value;t.call(e,o[0],o[1])}},matchAll:function(e,t){for(var n,r=[];null!==(n=e.exec(t));)r.push(n);return r},isHTMLForm:de,hasOwnProperty:pe,hasOwnProp:pe,reduceDescriptors:ye,freezeMethods:function(e){ye(e,function(t,n){if(J(e)&&["arguments","caller","callee"].includes(n))return!1;var r=e[n];J(r)&&(t.enumerable=!1,"writable"in t?t.writable=!1:t.set||(t.set=function(){throw Error("Can not rewrite read-only method '"+n+"'")}))})},toObjectSet:function(e,t){var n={},r=function(e){e.forEach(function(e){n[e]=!0})};return I(e)?r(e):r(String(e).split(t)),n},toCamelCase:function(e){return e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(e,t,n){return t.toUpperCase()+n})},noop:function(){},toFiniteNumber:function(e,t){return null!=e&&Number.isFinite(e=+e)?e:t},findKey:ue,global:se,isContextDefined:ce,isSpecCompliantForm:function(e){return!!(e&&J(e.append)&&"FormData"===e[U]&&e[D])},toJSONObject:function(e){var t=new Array(10),n=function(e,r){if(K(e)){if(t.indexOf(e)>=0)return;if(M(e))return e;if(!("toJSON"in e)){t[r]=e;var o=I(e)?[]:{};return ae(e,function(e,t){var i=n(e,r+1);!q(i)&&(o[t]=i)}),t[r]=void 0,o}}return e};return n(e,0)},isAsyncFn:we,isThenable:function(e){return e&&(K(e)||J(e))&&J(e.then)&&J(e.catch)},setImmediate:Oe,asap:Ee,isIterable:function(e){return null!=e&&J(e[D])}},Se=Re.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),_e=Symbol("internals"),Ae=/[^\x09\x20-\x7E\x80-\xFF]/g;function Te(e){return e&&String(e).trim().toLowerCase()}function je(e){return!1===e||null==e?e:Re.isArray(e)?e.map(je):function(e){for(var t=0,n=e.length;tt;){var o=e.charCodeAt(n-1);if(9!==o&&32!==o)break;n-=1}return 0===t&&n===e.length?e:e.slice(t,n)}(String(e).replace(Ae,""))}function Pe(e,t,n,r,o){return Re.isFunction(r)?r.call(this,t,n):(o&&(t=n),Re.isString(t)?Re.isString(r)?-1!==t.indexOf(r):Re.isRegExp(r)?r.test(t):void 0:void 0)}var ke=function(){return l(function e(t){c(this,e),t&&this.set(t)},[{key:"set",value:function(e,t,n){var r=this;function o(e,t,n){var o=Te(t);if(!o)throw new Error("header name must be a non-empty string");var i=Re.findKey(r,o);(!i||void 0===r[i]||!0===n||void 0===n&&!1!==r[i])&&(r[i||t]=je(e))}var i=function(e,t){return Re.forEach(e,function(e,n){return o(e,n,t)})};if(Re.isPlainObject(e)||e instanceof this.constructor)i(e,t);else if(Re.isString(e)&&(e=e.trim())&&!/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim()))i(function(e){var t,n,r,o={};return e&&e.split("\n").forEach(function(e){r=e.indexOf(":"),t=e.substring(0,r).trim().toLowerCase(),n=e.substring(r+1).trim(),!t||o[t]&&Se[t]||("set-cookie"===t?o[t]?o[t].push(n):o[t]=[n]:o[t]=o[t]?o[t]+", "+n:n)}),o}(e),t);else if(Re.isObject(e)&&Re.isIterable(e)){var a,u,s,c={},f=function(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=A(e))||t){n&&(e=n);var r=0,o=function(){};return{s:o,n:function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,u=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){u=!0,i=e},f:function(){try{a||null==n.return||n.return()}finally{if(u)throw i}}}}(e);try{for(f.s();!(s=f.n()).done;){var l=s.value;if(!Re.isArray(l))throw TypeError("Object iterator must return a key-value pair");c[u=l[0]]=(a=c[u])?Re.isArray(a)?[].concat(R(a),[l[1]]):[a,l[1]]:l[1]}}catch(e){f.e(e)}finally{f.f()}i(c,t)}else null!=e&&o(t,e,n);return this}},{key:"get",value:function(e,t){if(e=Te(e)){var n=Re.findKey(this,e);if(n){var r=this[n];if(!t)return r;if(!0===t)return function(e){for(var t,n=Object.create(null),r=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;t=r.exec(e);)n[t[1]]=t[2];return n}(r);if(Re.isFunction(t))return t.call(this,r,n);if(Re.isRegExp(t))return t.exec(r);throw new TypeError("parser must be boolean|regexp|function")}}}},{key:"has",value:function(e,t){if(e=Te(e)){var n=Re.findKey(this,e);return!(!n||void 0===this[n]||t&&!Pe(0,this[n],n,t))}return!1}},{key:"delete",value:function(e,t){var n=this,r=!1;function o(e){if(e=Te(e)){var o=Re.findKey(n,e);!o||t&&!Pe(0,n[o],o,t)||(delete n[o],r=!0)}}return Re.isArray(e)?e.forEach(o):o(e),r}},{key:"clear",value:function(e){for(var t=Object.keys(this),n=t.length,r=!1;n--;){var o=t[n];e&&!Pe(0,this[o],o,e,!0)||(delete this[o],r=!0)}return r}},{key:"normalize",value:function(e){var t=this,n={};return Re.forEach(this,function(r,o){var i=Re.findKey(n,o);if(i)return t[i]=je(r),void delete t[o];var a=e?function(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,function(e,t,n){return t.toUpperCase()+n})}(o):String(o).trim();a!==o&&delete t[o],t[a]=je(r),n[a]=!0}),this}},{key:"concat",value:function(){for(var e,t=arguments.length,n=new Array(t),r=0;r1?n-1:0),o=1;o0?xe(e,t):Re.toJSONObject(e);return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:n,code:this.code,status:this.status}}}],[{key:"from",value:function(e,n,r,o,i,a){var u=new t(e.message,n||e.code,r,o,i);return u.cause=e,u.name=e.name,null!=e.status&&null==u.status&&(u.status=e.status),a&&Object.assign(u,a),u}}])}(P(Error));Ce.ERR_BAD_OPTION_VALUE="ERR_BAD_OPTION_VALUE",Ce.ERR_BAD_OPTION="ERR_BAD_OPTION",Ce.ECONNABORTED="ECONNABORTED",Ce.ETIMEDOUT="ETIMEDOUT",Ce.ECONNREFUSED="ECONNREFUSED",Ce.ERR_NETWORK="ERR_NETWORK",Ce.ERR_FR_TOO_MANY_REDIRECTS="ERR_FR_TOO_MANY_REDIRECTS",Ce.ERR_DEPRECATED="ERR_DEPRECATED",Ce.ERR_BAD_RESPONSE="ERR_BAD_RESPONSE",Ce.ERR_BAD_REQUEST="ERR_BAD_REQUEST",Ce.ERR_CANCELED="ERR_CANCELED",Ce.ERR_NOT_SUPPORT="ERR_NOT_SUPPORT",Ce.ERR_INVALID_URL="ERR_INVALID_URL",Ce.ERR_FORM_DATA_DEPTH_EXCEEDED="ERR_FORM_DATA_DEPTH_EXCEEDED";function Ne(e){return Re.isPlainObject(e)||Re.isArray(e)}function De(e){return Re.endsWith(e,"[]")?e.slice(0,-2):e}function Ue(e,t,n){return e?e.concat(t).map(function(e,t){return e=De(e),!n&&t?"["+e+"]":e}).join(n?".":""):t}var Fe=Re.toFlatObject(Re,{},null,function(e){return/^is[A-Z]/.test(e)});function Le(e,t,n){if(!Re.isObject(e))throw new TypeError("target must be an object");t=t||new FormData;var r=(n=Re.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(e,t){return!Re.isUndefined(t[e])})).metaTokens,o=n.visitor||l,i=n.dots,a=n.indexes,u=n.Blob||"undefined"!=typeof Blob&&Blob,s=void 0===n.maxDepth?100:n.maxDepth,c=u&&Re.isSpecCompliantForm(t);if(!Re.isFunction(o))throw new TypeError("visitor must be a function");function f(e){if(null===e)return"";if(Re.isDate(e))return e.toISOString();if(Re.isBoolean(e))return e.toString();if(!c&&Re.isBlob(e))throw new Ce("Blob is not supported. Use a Buffer instead.");return Re.isArrayBuffer(e)||Re.isTypedArray(e)?c&&"function"==typeof Blob?new Blob([e]):Buffer.from(e):e}function l(e,n,o){var u=e;if(Re.isReactNative(t)&&Re.isReactNativeBlob(e))return t.append(Ue(o,n,i),f(e)),!1;if(e&&!o&&"object"===_(e))if(Re.endsWith(n,"{}"))n=r?n:n.slice(0,-2),e=JSON.stringify(e);else if(Re.isArray(e)&&function(e){return Re.isArray(e)&&!e.some(Ne)}(e)||(Re.isFileList(e)||Re.endsWith(n,"[]"))&&(u=Re.toArray(e)))return n=De(n),u.forEach(function(e,r){!Re.isUndefined(e)&&null!==e&&t.append(!0===a?Ue([n],r,i):null===a?n:n+"[]",f(e))}),!1;return!!Ne(e)||(t.append(Ue(o,n,i),f(e)),!1)}var d=[],p=Object.assign(Fe,{defaultVisitor:l,convertValue:f,isVisitable:Ne});if(!Re.isObject(e))throw new TypeError("data must be an object");return function e(n,r){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;if(!Re.isUndefined(n)){if(i>s)throw new Ce("Object is too deeply nested ("+i+" levels). Max depth: "+s,Ce.ERR_FORM_DATA_DEPTH_EXCEEDED);if(-1!==d.indexOf(n))throw Error("Circular reference detected in "+r.join("."));d.push(n),Re.forEach(n,function(n,a){!0===(!(Re.isUndefined(n)||null===n)&&o.call(t,n,Re.isString(a)?a.trim():a,r,p))&&e(n,r?r.concat(a):[a],i+1)}),d.pop()}}(e),t}function Be(e){var t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+"};return encodeURIComponent(e).replace(/[!'()~]|%20/g,function(e){return t[e]})}function Ie(e,t){this._pairs=[],e&&Le(e,this,t)}var qe=Ie.prototype;function Me(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function ze(e,t,n){if(!t)return e;var r,o=n&&n.encode||Me,i=Re.isFunction(n)?{serialize:n}:n,a=i&&i.serialize;if(r=a?a(t,i):Re.isURLSearchParams(t)?t.toString():new Ie(t,i).toString(o)){var u=e.indexOf("#");-1!==u&&(e=e.slice(0,u)),e+=(-1===e.indexOf("?")?"?":"&")+r}return e}qe.append=function(e,t){this._pairs.push([e,t])},qe.toString=function(e){var t=e?function(t){return e.call(this,t,Be)}:Be;return this._pairs.map(function(e){return t(e[0])+"="+t(e[1])},"").join("&")};var He=function(){return l(function e(){c(this,e),this.handlers=[]},[{key:"use",value:function(e,t,n){return this.handlers.push({fulfilled:e,rejected:t,synchronous:!!n&&n.synchronous,runWhen:n?n.runWhen:null}),this.handlers.length-1}},{key:"eject",value:function(e){this.handlers[e]&&(this.handlers[e]=null)}},{key:"clear",value:function(){this.handlers&&(this.handlers=[])}},{key:"forEach",value:function(e){Re.forEach(this.handlers,function(t){null!==t&&e(t)})}}])}(),Je={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1,legacyInterceptorReqResOrdering:!0},We={isBrowser:!0,classes:{URLSearchParams:"undefined"!=typeof URLSearchParams?URLSearchParams:Ie,FormData:"undefined"!=typeof FormData?FormData:null,Blob:"undefined"!=typeof Blob?Blob:null},protocols:["http","https","file","blob","url","data"]},Ke="undefined"!=typeof window&&"undefined"!=typeof document,Ve="object"===("undefined"==typeof navigator?"undefined":_(navigator))&&navigator||void 0,Xe=Ke&&(!Ve||["ReactNative","NativeScript","NS"].indexOf(Ve.product)<0),Ge="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope&&"function"==typeof self.importScripts,$e=Ke&&window.location.href||"http://localhost",Qe=b(b({},Object.freeze({__proto__:null,hasBrowserEnv:Ke,hasStandardBrowserEnv:Xe,hasStandardBrowserWebWorkerEnv:Ge,navigator:Ve,origin:$e})),We);function Ye(e){function t(e,n,r,o){var i=e[o++];if("__proto__"===i)return!0;var a=Number.isFinite(+i),u=o>=e.length;return i=!i&&Re.isArray(r)?r.length:i,u?(Re.hasOwnProp(r,i)?r[i]=Re.isArray(r[i])?r[i].concat(n):[r[i],n]:r[i]=n,!a):(r[i]&&Re.isObject(r[i])||(r[i]=[]),t(e,n,r[i],o)&&Re.isArray(r[i])&&(r[i]=function(e){var t,n,r={},o=Object.keys(e),i=o.length;for(t=0;t-1,i=Re.isObject(e);if(i&&Re.isHTMLForm(e)&&(e=new FormData(e)),Re.isFormData(e))return o?JSON.stringify(Ye(e)):e;if(Re.isArrayBuffer(e)||Re.isBuffer(e)||Re.isStream(e)||Re.isFile(e)||Re.isBlob(e)||Re.isReadableStream(e))return e;if(Re.isArrayBufferView(e))return e.buffer;if(Re.isURLSearchParams(e))return t.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();if(i){var a=Ze(this,"formSerializer");if(r.indexOf("application/x-www-form-urlencoded")>-1)return function(e,t){return Le(e,new Qe.classes.URLSearchParams,b({visitor:function(e,t,n,r){return Qe.isNode&&Re.isBuffer(e)?(this.append(t,e.toString("base64")),!1):r.defaultVisitor.apply(this,arguments)}},t))}(e,a).toString();if((n=Re.isFileList(e))||r.indexOf("multipart/form-data")>-1){var u=Ze(this,"env"),s=u&&u.FormData;return Le(n?{"files[]":e}:e,s&&new s,a)}}return i||o?(t.setContentType("application/json",!1),function(e,t,n){if(Re.isString(e))try{return(t||JSON.parse)(e),Re.trim(e)}catch(e){if("SyntaxError"!==e.name)throw e}return(n||JSON.stringify)(e)}(e)):e}],transformResponse:[function(e){var t=Ze(this,"transitional")||et.transitional,n=t&&t.forcedJSONParsing,r=Ze(this,"responseType"),o="json"===r;if(Re.isResponse(e)||Re.isReadableStream(e))return e;if(e&&Re.isString(e)&&(n&&!r||o)){var i=!(t&&t.silentJSONParsing)&&o;try{return JSON.parse(e,Ze(this,"parseReviver"))}catch(e){if(i){if("SyntaxError"===e.name)throw Ce.from(e,Ce.ERR_BAD_RESPONSE,this,null,Ze(this,"response"));throw e}}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Qe.classes.FormData,Blob:Qe.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};function tt(e,t){var n=this||et,r=t||n,o=ke.from(r.headers),i=r.data;return Re.forEach(e,function(e){i=e.call(n,i,o.normalize(),t?t.status:void 0)}),o.normalize(),i}function nt(e){return!(!e||!e.__CANCEL__)}Re.forEach(["delete","get","head","post","put","patch","query"],function(e){et.headers[e]={}});var rt=function(e){function t(e,n,r){var o;return c(this,t),(o=s(this,t,[null==e?"canceled":e,Ce.ERR_CANCELED,n,r])).name="CanceledError",o.__CANCEL__=!0,o}return h(t,e),l(t)}(Ce);function ot(e,t,n){var r=n.config.validateStatus;n.status&&r&&!r(n.status)?t(new Ce("Request failed with status code "+n.status,n.status>=400&&n.status<500?Ce.ERR_BAD_REQUEST:Ce.ERR_BAD_RESPONSE,n.config,n.request,n)):e(n)}var it=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:3,r=0,o=function(e,t){e=e||10;var n,r=new Array(e),o=new Array(e),i=0,a=0;return t=void 0!==t?t:1e3,function(u){var s=Date.now(),c=o[a];n||(n=s),r[i]=u,o[i]=s;for(var f=a,l=0;f!==i;)l+=r[f++],f%=e;if((i=(i+1)%e)===a&&(a=(a+1)%e),!(s-n1&&void 0!==arguments[1]?arguments[1]:Date.now();o=i,n=null,r&&(clearTimeout(r),r=null),e.apply(void 0,R(t))};return[function(){for(var e=Date.now(),t=e-o,u=arguments.length,s=new Array(u),c=0;c=i?a(s,e):(n=s,r||(r=setTimeout(function(){r=null,a(n)},i-t)))},function(){return n&&a(n)}]}(function(n){var i=n.loaded,a=n.lengthComputable?n.total:void 0,u=null!=a?Math.min(i,a):i,s=Math.max(0,u-r),c=o(s);r=Math.max(r,u);var f=d({loaded:u,total:a,progress:a?u/a:void 0,bytes:s,rate:c||void 0,estimated:c&&a?(a-u)/c:void 0,event:n,lengthComputable:null!=a},t?"download":"upload",!0);e(f)},n)},at=function(e,t){var n=null!=e;return[function(r){return t[0]({lengthComputable:n,total:e,loaded:r})},t[1]]},ut=function(e){return function(){for(var t=arguments.length,n=new Array(t),r=0;r=48&&u<=57||u>=65&&u<=70||u>=97&&u<=102)&&(s>=48&&s<=57||s>=65&&s<=70||s>=97&&s<=102)&&(o-=2,a+=2)}var c=0,f=i-1,l=function(e){return e>=2&&37===r.charCodeAt(e-2)&&51===r.charCodeAt(e-1)&&(68===r.charCodeAt(e)||100===r.charCodeAt(e))};f>=0&&(61===r.charCodeAt(f)?(c++,f--):l(f)&&(c++,f-=3)),1===c&&f>=0&&(61===r.charCodeAt(f)||l(f))&&c++;var d=3*Math.floor(o/4)-(c||0);return d>0?d:0}if("undefined"!=typeof Buffer&&"function"==typeof Buffer.byteLength)return Buffer.byteLength(r,"utf8");for(var p=0,h=0,y=r.length;h=55296&&v<=56319&&h+1=56320&&b<=57343?(p+=4,h++):p+=3}else p+=3}return p}var Et="1.16.0",Rt=Re.isFunction,St=function(e){try{for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r-1,x=Re.isNumber(P)&&P>-1,C=u||fetch,O=O?(O+"").toLowerCase():"text",N=vt([f,d&&d.toAbortSignal()],p),D=null,U=N&&N.unsubscribe&&function(){N.unsubscribe()},e.p=1,!k||"string"!=typeof r||!r.startsWith("data:")){e.n=2;break}if(!(Ot(r)>j)){e.n=2;break}throw new Ce("maxContentLength size of "+j+" exceeded",Ce.ERR_BAD_RESPONSE,t,D);case 2:if(!x||"get"===i||"head"===i){e.n=4;break}return e.n=3,R(S,a);case 3:if(!("number"==typeof(L=e.v)&&isFinite(L)&&L>P)){e.n=4;break}throw new Ce("Request body larger than maxBodyLength limit",Ce.ERR_BAD_REQUEST,t,D);case 4:if(!(ae=y&&v&&"get"!==i&&"head"!==i)){e.n=6;break}return e.n=5,R(S,a);case 5:ue=F=e.v,ae=0!==ue;case 6:if(!ae){e.n=7;break}B=new s(r,{method:"POST",body:a,duplex:"half"}),Re.isFormData(a)&&(I=B.headers.get("content-type"))&&S.setContentType(I),B.body&&(q=at(F,it(ut(y))),M=E(q,2),z=M[0],H=M[1],a=wt(B.body,65536,z,H));case 7:return Re.isString(A)||(A=A?"include":"omit"),J=l&&"credentials"in s.prototype,Re.isFormData(a)&&(W=S.getContentType())&&/^multipart\/form-data/i.test(W)&&!/boundary=/i.test(W)&&S.delete("content-type"),S.set("User-Agent","axios/"+Et,!1),K=b(b({},T),{},{signal:N,method:i.toUpperCase(),headers:S.normalize().toJSON(),body:a,duplex:"half",credentials:J?A:void 0}),D=l&&new s(r,K),e.n=8,l?C(D,T):C(r,K);case 8:if(V=e.v,!k){e.n=9;break}if(!(null!=(X=Re.toFiniteNumber(V.headers.get("content-length")))&&X>j)){e.n=9;break}throw new Ce("maxContentLength size of "+j+" exceeded",Ce.ERR_BAD_RESPONSE,t,D);case 9:return G=g&&("stream"===O||"response"===O),g&&V.body&&(h||k||G&&U)&&($={},["status","statusText","headers"].forEach(function(e){$[e]=V[e]}),Q=Re.toFiniteNumber(V.headers.get("content-length")),Y=h&&at(Q,it(ut(h),!0))||[],Z=E(Y,2),ee=Z[0],te=Z[1],ne=function(e){if(k&&e>j)throw new Ce("maxContentLength size of "+j+" exceeded",Ce.ERR_BAD_RESPONSE,t,D);ee&&ee(e)},V=new c(wt(V.body,65536,ne,function(){te&&te(),U&&U()}),$)),O=O||"text",e.n=10,w[Re.findKey(w,O)||"text"](V,t);case 10:if(re=e.v,!k||g||G){e.n=11;break}if(null!=re&&("number"==typeof re.byteLength?oe=re.byteLength:"number"==typeof re.size?oe=re.size:"string"==typeof re&&(oe="function"==typeof o?(new o).encode(re).byteLength:re.length)),!("number"==typeof oe&&oe>j)){e.n=11;break}throw new Ce("maxContentLength size of "+j+" exceeded",Ce.ERR_BAD_RESPONSE,t,D);case 11:return!G&&U&&U(),e.n=12,new Promise(function(e,n){ot(e,n,{data:re,headers:ke.from(V.headers),status:V.status,statusText:V.statusText,config:t,request:D})});case 12:return e.a(2,e.v);case 13:if(e.p=13,se=e.v,U&&U(),!(N&&N.aborted&&N.reason instanceof Ce)){e.n=14;break}throw(ie=N.reason).config=t,D&&(ie.request=D),se!==ie&&(ie.cause=se),ie;case 14:if(!se||"TypeError"!==se.name||!/Load failed|fetch/i.test(se.message)){e.n=15;break}throw Object.assign(new Ce("Network Error",Ce.ERR_NETWORK,t,D,se&&se.response),{cause:se.cause||se});case 15:throw Ce.from(se,se&&se.code,t,D,se&&se.response);case 16:return e.a(2)}},e,null,[[1,13]])}));return function(t){return e.apply(this,arguments)}}()},At=new Map,Tt=function(e){for(var t,n,r=e&&e.env||{},o=r.fetch,i=[r.Request,r.Response,o],a=i.length,u=At;a--;)t=i[a],void 0===(n=u.get(t))&&u.set(t,n=a?new Map:_t(r)),u=n;return n};Tt();var jt={http:null,xhr:yt,fetch:{get:Tt}};Re.forEach(jt,function(e,t){if(e){try{Object.defineProperty(e,"name",{__proto__:null,value:t})}catch(e){}Object.defineProperty(e,"adapterName",{__proto__:null,value:t})}});var Pt=function(e){return"- ".concat(e)},kt=function(e){return Re.isFunction(e)||null===e||!1===e};var xt={getAdapter:function(e,t){for(var n,r,o=(e=Re.isArray(e)?e:[e]).length,i={},a=0;a1?"since :\n"+s.map(Pt).join("\n"):" "+Pt(s[0]):"as no adapter specified";throw new Ce("There is no suitable adapter to dispatch the request "+c,"ERR_NOT_SUPPORT")}return r},adapters:jt};function Ct(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new rt(null,e)}function Nt(e){return Ct(e),e.headers=ke.from(e.headers),e.data=tt.call(e,e.transformRequest),-1!==["post","put","patch"].indexOf(e.method)&&e.headers.setContentType("application/x-www-form-urlencoded",!1),xt.getAdapter(e.adapter||et.adapter,e)(e).then(function(t){Ct(e),e.response=t;try{t.data=tt.call(e,e.transformResponse,t)}finally{delete e.response}return t.headers=ke.from(t.headers),t},function(t){if(!nt(t)&&(Ct(e),t&&t.response)){e.response=t.response;try{t.response.data=tt.call(e,e.transformResponse,t.response)}finally{delete e.response}t.response.headers=ke.from(t.response.headers)}return Promise.reject(t)})}var Dt={};["object","boolean","number","function","string","symbol"].forEach(function(e,t){Dt[e]=function(n){return _(n)===e||"a"+(t<1?"n ":" ")+e}});var Ut={};Dt.transitional=function(e,t,n){function r(e,t){return"[Axios v"+Et+"] Transitional option '"+e+"'"+t+(n?". "+n:"")}return function(n,o,i){if(!1===e)throw new Ce(r(o," has been removed"+(t?" in "+t:"")),Ce.ERR_DEPRECATED);return t&&!Ut[o]&&(Ut[o]=!0,console.warn(r(o," has been deprecated since v"+t+" and will be removed in the near future"))),!e||e(n,o,i)}},Dt.spelling=function(e){return function(t,n){return console.warn("".concat(n," is likely a misspelling of ").concat(e)),!0}};var Ft={assertOptions:function(e,t,n){if("object"!==_(e))throw new Ce("options must be an object",Ce.ERR_BAD_OPTION_VALUE);for(var r=Object.keys(e),o=r.length;o-- >0;){var i=r[o],a=Object.prototype.hasOwnProperty.call(t,i)?t[i]:void 0;if(a){var u=e[i],s=void 0===u||a(u,i,e);if(!0!==s)throw new Ce("option "+i+" must be "+s,Ce.ERR_BAD_OPTION_VALUE)}else if(!0!==n)throw new Ce("Unknown option "+i,Ce.ERR_BAD_OPTION)}},validators:Dt},Lt=Ft.validators,Bt=function(){return l(function e(t){c(this,e),this.defaults=t||{},this.interceptors={request:new He,response:new He}},[{key:"request",value:(e=a(m().m(function e(t,n){var r,o,i,a,u,s;return m().w(function(e){for(;;)switch(e.p=e.n){case 0:return e.p=0,e.n=1,this._request(t,n);case 1:return e.a(2,e.v);case 2:if(e.p=2,(s=e.v)instanceof Error){r={},Error.captureStackTrace?Error.captureStackTrace(r):r=new Error,o=function(){if(!r.stack)return"";var e=r.stack.indexOf("\n");return-1===e?"":r.stack.slice(e+1)}();try{s.stack?o&&(i=o.indexOf("\n"),a=-1===i?-1:o.indexOf("\n",i+1),u=-1===a?"":o.slice(a+1),String(s.stack).endsWith(u)||(s.stack+="\n"+o)):s.stack=o}catch(e){}}throw s;case 3:return e.a(2)}},e,this,[[0,2]])})),function(t,n){return e.apply(this,arguments)})},{key:"_request",value:function(e,t){"string"==typeof e?(t=t||{}).url=e:t=e||{};var n=t=dt(this.defaults,t),r=n.transitional,o=n.paramsSerializer,i=n.headers;void 0!==r&&Ft.assertOptions(r,{silentJSONParsing:Lt.transitional(Lt.boolean),forcedJSONParsing:Lt.transitional(Lt.boolean),clarifyTimeoutError:Lt.transitional(Lt.boolean),legacyInterceptorReqResOrdering:Lt.transitional(Lt.boolean)},!1),null!=o&&(Re.isFunction(o)?t.paramsSerializer={serialize:o}:Ft.assertOptions(o,{encode:Lt.function,serialize:Lt.function},!0)),void 0!==t.allowAbsoluteUrls||(void 0!==this.defaults.allowAbsoluteUrls?t.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:t.allowAbsoluteUrls=!0),Ft.assertOptions(t,{baseUrl:Lt.spelling("baseURL"),withXsrfToken:Lt.spelling("withXSRFToken")},!0),t.method=(t.method||this.defaults.method||"get").toLowerCase();var a=i&&Re.merge(i.common,i[t.method]);i&&Re.forEach(["delete","get","head","post","put","patch","query","common"],function(e){delete i[e]}),t.headers=ke.concat(a,i);var u=[],s=!0;this.interceptors.request.forEach(function(e){if("function"!=typeof e.runWhen||!1!==e.runWhen(t)){s=s&&e.synchronous;var n=t.transitional||Je;n&&n.legacyInterceptorReqResOrdering?u.unshift(e.fulfilled,e.rejected):u.push(e.fulfilled,e.rejected)}});var c,f=[];this.interceptors.response.forEach(function(e){f.push(e.fulfilled,e.rejected)});var l,d=0;if(!s){var p=[Nt.bind(this),void 0];for(p.unshift.apply(p,u),p.push.apply(p,f),l=p.length,c=Promise.resolve(t);d0;)r._listeners[t](e);r._listeners=null}}),this.promise.then=function(e){var t,n=new Promise(function(e){r.subscribe(e),t=e}).then(e);return n.cancel=function(){r.unsubscribe(t)},n},t(function(e,t,o){r.reason||(r.reason=new rt(e,t,o),n(r.reason))})}return l(e,[{key:"throwIfRequested",value:function(){if(this.reason)throw this.reason}},{key:"subscribe",value:function(e){this.reason?e(this.reason):this._listeners?this._listeners.push(e):this._listeners=[e]}},{key:"unsubscribe",value:function(e){if(this._listeners){var t=this._listeners.indexOf(e);-1!==t&&this._listeners.splice(t,1)}}},{key:"toAbortSignal",value:function(){var e=this,t=new AbortController,n=function(e){t.abort(e)};return this.subscribe(n),t.signal.unsubscribe=function(){return e.unsubscribe(n)},t.signal}}],[{key:"source",value:function(){var t;return{token:new e(function(e){t=e}),cancel:t}}}])}();var qt={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(qt).forEach(function(e){var t=E(e,2),n=t[0],r=t[1];qt[r]=n});var Mt=function e(t){var n=new Bt(t),r=k(Bt.prototype.request,n);return Re.extend(r,Bt.prototype,n,{allOwnKeys:!0}),Re.extend(r,n,null,{allOwnKeys:!0}),r.create=function(n){return e(dt(t,n))},r}(et);return Mt.Axios=Bt,Mt.CanceledError=rt,Mt.CancelToken=It,Mt.isCancel=nt,Mt.VERSION=Et,Mt.toFormData=Le,Mt.AxiosError=Ce,Mt.Cancel=Mt.CanceledError,Mt.all=function(e){return Promise.all(e)},Mt.spread=function(e){return function(t){return e.apply(null,t)}},Mt.isAxiosError=function(e){return Re.isObject(e)&&!0===e.isAxiosError},Mt.mergeConfig=dt,Mt.AxiosHeaders=ke,Mt.formToJSON=function(e){return Ye(Re.isHTMLForm(e)?new FormData(e):e)},Mt.getAdapter=xt.getAdapter,Mt.HttpStatusCode=qt,Mt.default=Mt,Mt}); //# sourceMappingURL=axios.min.js.map diff --git a/enferno/static/js/common/config.js b/enferno/static/js/common/config.js index 5255be7f3..4ea99a2e2 100644 --- a/enferno/static/js/common/config.js +++ b/enferno/static/js/common/config.js @@ -118,6 +118,18 @@ function isValidLength(value, limit, type) { return type === "max" ? length <= limit : length >= limit; } +function escapeHtml(value) { + const entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + + return String(value ?? '').replace(/[&<>"']/g, char => entityMap[char]); +} + function scrollToFirstError() { const wrapper = document.querySelector(".v-input--error"); if (!wrapper) return; @@ -365,8 +377,8 @@ function getInfraMessage(status) { return Object.entries(errors).map(([field, message]) => { const fieldName = field.startsWith('item.') ? field.slice(5) : field; const label = fieldName.includes('__root__') ? 'Validation Error' : fieldName; - return `[${label}]: ${message}`; - }).join('
') || 'An error occurred.'; + return `[${label}]: ${message}`; + }).join('\n') || 'An error occurred.'; } if (response?.data?.message) { @@ -803,4 +815,4 @@ function loadScript(src) { loadedScripts.set(src, promise); return promise; -} \ No newline at end of file +} diff --git a/enferno/static/js/components/ActorCard.js b/enferno/static/js/components/ActorCard.js index f0f820eba..8bd69d2a4 100644 --- a/enferno/static/js/components/ActorCard.js +++ b/enferno/static/js/components/ActorCard.js @@ -50,6 +50,11 @@ const ActorCard = Vue.defineComponent({ } return false; }, + removeRedaction(redaction) { + if (typeof this.$root.removeRedaction === 'function') { + this.$root.removeRedaction(redaction); + } + }, loadRevisions() { this.hloading = true; axios @@ -428,7 +433,7 @@ const ActorCard = Vue.defineComponent({ > - +

diff --git a/enferno/static/js/components/BulletinCard.js b/enferno/static/js/components/BulletinCard.js index 6ed1e3f54..5ae32f9ac 100644 --- a/enferno/static/js/components/BulletinCard.js +++ b/enferno/static/js/components/BulletinCard.js @@ -57,6 +57,11 @@ const BulletinCard = Vue.defineComponent({ return false; }, + removeRedaction(redaction) { + if (typeof this.$root.removeRedaction === 'function') { + this.$root.removeRedaction(redaction); + } + }, loadRevisions() { this.hloading = true; @@ -261,6 +266,7 @@ const BulletinCard = Vue.defineComponent({ :renderer-id="mediaRendererId" :media="$root.expandedByRenderer?.[mediaRendererId]?.media" :media-type="$root.expandedByRenderer?.[mediaRendererId]?.mediaType" + :initial-orientation="$root.expandedByRenderer?.[mediaRendererId]?.media?.orientation || 0" @ready="$root.onMediaRendererReady" @fullscreen="$root.handleFullscreen(mediaRendererId)" @close="$root.closeExpandedMedia(mediaRendererId)" @@ -268,7 +274,7 @@ const BulletinCard = Vue.defineComponent({ - + @@ -286,6 +292,17 @@ const BulletinCard = Vue.defineComponent({ +
+ + + {{ translations.publicDescription_ }} + + +
+
+
+
+
diff --git a/enferno/static/js/components/EventsSection.js b/enferno/static/js/components/EventsSection.js index 5ab1865ba..e0e9f9776 100644 --- a/enferno/static/js/components/EventsSection.js +++ b/enferno/static/js/components/EventsSection.js @@ -49,7 +49,7 @@ const EventsSection = Vue.defineComponent({ if (!hasTitleOrType) missing.push(this.translations.titleOrTypeRequired_); if (missing.length > 0) { - this.$root.showSnack(missing.join('
')); + this.$root.showSnack(missing.join('\n')); return false; } diff --git a/enferno/static/js/components/GeoLocations.js b/enferno/static/js/components/GeoLocations.js index dabfd7e93..1fe4704b8 100644 --- a/enferno/static/js/components/GeoLocations.js +++ b/enferno/static/js/components/GeoLocations.js @@ -129,7 +129,7 @@ const GeoLocations = Vue.defineComponent({ {{ loc.lat.toFixed(4) }} , {{ loc.lng.toFixed(4) }}
-
+
diff --git a/enferno/static/js/components/GlobalMap.js b/enferno/static/js/components/GlobalMap.js index 536d6f09e..47811b50f 100644 --- a/enferno/static/js/components/GlobalMap.js +++ b/enferno/static/js/components/GlobalMap.js @@ -75,38 +75,38 @@ const GlobalMap = Vue.defineComponent({ // Dates const dates = []; - if (loc.from_date) dates.push(`${this.$root.formatDate(loc.from_date, loc.from_date.includes('T00:00') ? this.$root.dateFormats.standardDate : this.$root.dateFormats.standardDatetime)}`); + if (loc.from_date) dates.push(`${escapeHtml(this.$root.formatDate(loc.from_date, loc.from_date.includes('T00:00') ? this.$root.dateFormats.standardDate : this.$root.dateFormats.standardDatetime))}`); if (loc.from_date && loc.to_date) dates.push(``); - if (loc.to_date) dates.push(`${this.$root.formatDate(loc.to_date, loc.to_date.includes('T00:00') ? this.$root.dateFormats.standardDate : this.$root.dateFormats.standardDatetime)}`); + if (loc.to_date) dates.push(`${escapeHtml(this.$root.formatDate(loc.to_date, loc.to_date.includes('T00:00') ? this.$root.dateFormats.standardDate : this.$root.dateFormats.standardDatetime))}`); const estimated = loc.estimated - ? `` + ? `` : ''; const dateRange = dates.length ? `
${estimated} ${dates.join(' ')}
` : ''; - const parentId = loc?.parentId && loc?.show_parent_id ? `
${loc.parentId}
` : ''; + const parentId = loc?.parentId && loc?.show_parent_id ? `
${escapeHtml(loc.parentId)}
` : ''; // Copy button using data-copy instead of onclick const copyCoordsBtn = (Number.isFinite(loc.lat) && Number.isFinite(loc.lng)) - ? `` : ''; - const eventType = loc.eventtype ? `
${loc.eventtype || ''}
` : ''; + const eventType = loc.eventtype ? `
${escapeHtml(loc.eventtype)}
` : ''; - const locNumber = loc.number ? `
#${loc.number || ''}
` : ''; + const locNumber = loc.number ? `
#${escapeHtml(loc.number)}
` : ''; const html = ``; diff --git a/enferno/static/js/components/InlineMediaRenderer.js b/enferno/static/js/components/InlineMediaRenderer.js index 20ee93e64..8ac9d3db2 100644 --- a/enferno/static/js/components/InlineMediaRenderer.js +++ b/enferno/static/js/components/InlineMediaRenderer.js @@ -24,6 +24,10 @@ const InlineMediaRenderer = Vue.defineComponent({ type: Number, default: 0, }, + usePdfCanvasRenderer: { + type: Boolean, + default: false, + }, }, emits: ['ready', 'fullscreen', 'close', 'orientation-changed'], data: () => ({ @@ -159,12 +163,13 @@ const InlineMediaRenderer = Vue.defineComponent({ ref="playerContainer" class="h-100" >
- - - + + + + -
+
mdi-file-download
{{ translations.previewNotAvailable_ }}
{{ media.filename }}
diff --git a/enferno/static/js/components/MediaCard.js b/enferno/static/js/components/MediaCard.js index 49e188e59..80b46500b 100644 --- a/enferno/static/js/components/MediaCard.js +++ b/enferno/static/js/components/MediaCard.js @@ -42,6 +42,18 @@ const toolbarContent = ` {{ ocrButtonState.text }} + + + +
+ Redact document + Draw black boxes to censor sensitive areas,
then save a new redacted copy
+
+
@@ -113,9 +125,13 @@ const MediaCard = Vue.defineComponent({ miniMode: { type: Boolean, default: false, - } + }, + redactions: { + type: Array, + default: () => [], + }, }, - emits: ['media-click'], + emits: ['media-click', 'remove-redaction'], data() { return { s3url: '', @@ -134,6 +150,7 @@ const MediaCard = Vue.defineComponent({ ocrDetails: null, ocrLoading: false, expansionPanel: null, + showRedactions: false, }; }, computed: { @@ -165,6 +182,17 @@ const MediaCard = Vue.defineComponent({ visible, disabled }; + }, + redactButtonState() { + // Only on pages that mounted the redactor, for redactable types, Admin/DA + if (typeof this.$root?.openRedactor !== 'function') return; + const isRedactable = this.mediaType === 'pdf' || this.mediaType === 'image'; + const visible = (this.isCurrentUserAdmin || this.isCurrentUserDA) && isRedactable; + return { + text: this.translations.redact_, + visible, + disabled: !this.media?.id, + }; } }, methods: { @@ -202,6 +230,17 @@ const MediaCard = Vue.defineComponent({ .catch(() => this.$root.showSnack('Failed to load OCR text')) .finally(() => this.ocrLoading = false); }, + confirmRemoveRedaction(redaction) { + this.$root.$confirm({ + title: this.translations.youreAboutToDeleteARedactedCopy_, + message: `${this.translations.deletingTheRedactedCopyWillNotDeleteTheOriginalMedia_}\r\n\r\n${this.translations.doYouWantToContinue_}`, + acceptProps: { text: this.translations.deleteRedaction_, color: 'error' }, + dialogProps: { width: 780 }, + onAccept: () => { + this.$emit('remove-redaction', redaction); + }, + }); + }, copyToClipboard(text) { navigator.clipboard.writeText(text) .then(() => this.$root.showSnack('Copied to clipboard')) @@ -270,8 +309,8 @@ const MediaCard = Vue.defineComponent({ - {{ translations.extractedText_ || 'Extracted Text' }} - {{ media.extraction?.word_count }} {{ translations.words_ || 'words' }} + {{ translations.extractedText_ }} + {{ media.extraction?.word_count }} {{ translations.words_ }} @@ -285,6 +324,68 @@ const MediaCard = Vue.defineComponent({ + + @@ -292,4 +393,4 @@ const MediaCard = Vue.defineComponent({ ` -}); \ No newline at end of file +}); diff --git a/enferno/static/js/components/MediaGrid.js b/enferno/static/js/components/MediaGrid.js index 0e7cd9baa..0e9bb972f 100644 --- a/enferno/static/js/components/MediaGrid.js +++ b/enferno/static/js/components/MediaGrid.js @@ -6,42 +6,57 @@ const MediaGrid = Vue.defineComponent({ prioritizeVideos: Boolean, miniMode: Boolean, }, - emits: ['remove-media', 'media-click'], + emits: ['remove-media', 'remove-redaction', 'media-click'], computed: { - sortedMedia() { - if (this.prioritizeVideos) return this.sortMediaByFileType(this.medias); - - return this.medias; - } + primaryMedia() { + const list = this.prioritizeVideos ? this.sortMediaByFileType(this.medias) : (this.medias || []); + return list.filter(media => !this.isRedaction(media)); + }, + redactionsBySource() { + const map = {}; + for (const media of (this.medias || [])) { + if (!this.isRedaction(media)) continue; + const srcId = media.originalMediaId; + if (srcId == null) continue; + if (!map[srcId]) map[srcId] = []; + map[srcId].push(media); + } + return map; + }, }, methods: { + isRedaction(media) { + return media?.originalMediaId != null; + }, + mediaIndex(media) { + return this.medias.indexOf(media); + }, sortMediaByFileType(mediaList) { if (!mediaList) return []; - // Sort media list by fileType (video first) - const sortedMediaList = [...mediaList].sort((a, b) => { - if (a?.fileType?.includes('video')) return -1; // Video should come first - if (b?.fileType?.includes('video')) return 1; // Then images - return 0; // Leave unchanged if neither is a video + return [...mediaList].sort((a, b) => { + if (a?.fileType?.includes('video')) return -1; + if (b?.fileType?.includes('video')) return 1; + return 0; }); - - return sortedMediaList; }, }, template: /*html*/` -
- - - - - -
+
+ + + + + +
`, -}); \ No newline at end of file +}); diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js new file mode 100644 index 000000000..24848bf0e --- /dev/null +++ b/enferno/static/js/components/MediaRedactor.js @@ -0,0 +1,608 @@ +const REDACTOR_MAX_ZOOM = 6; + +const MediaRedactor = Vue.defineComponent({ + props: { + modelValue: { type: Boolean, default: false }, + media: { type: Object, default: null }, + }, + emits: ['update:modelValue', 'redacted'], + + data() { + return { + REDACTOR_MAX_ZOOM, + loading: false, + saving: false, + error: null, + label: '', + pages: [], + boxes: {}, + draft: null, + drag: null, + resize: null, + hoveredBox: null, + activeBox: null, + spaceDown: false, + panning: null, + zoom: 1, + baseWidth: 0, + pinchStartDistance: null, + pinchStartZoom: 1, + }; + }, + + computed: { + show: { + get() { + return this.modelValue; + }, + set(value) { + this.$emit('update:modelValue', value); + }, + }, + src() { + if (!this.media?.id) return null; + // Bust the browser cache when a copy is re-edited in place (filename stays, bytes change). + const v = this.media.etag ? `?v=${this.media.etag}` : ''; + return `/admin/api/media/${this.media.id}/proxy${v}`; + }, + isRedactedCopy() { + return !!(this.media?.isRedaction || this.media?.originalMediaId); + }, + mediaKind() { + const fileType = this.media?.fileType || ''; + if (fileType.includes('pdf')) return 'pdf'; + if (fileType.includes('image')) return 'image'; + const filename = this.media?.filename || ''; + if (filename.toLowerCase().endsWith('.pdf')) return 'pdf'; + if (/\.(jpe?g|png)$/i.test(filename)) return 'image'; + return 'unsupported'; + }, + canSubmit() { + return Object.values(this.boxes).some(pageBoxes => pageBoxes.length); + }, + orientation() { + return this.media?.orientation || 0; + }, + imageStyle() { + return (page) => { + const o = this.orientation; + if ((o === 90 || o === 270) && page.width && page.height) { + // Container: width=CW, height=CW*(W/H) (swapped aspect-ratio H/W). + // Image pre-rotation must be W×H to visually fill CW×(CW*W/H) after rotating. + // width as % of CW: W/H * 100% + // height as % of containerHeight (CW*W/H): need CW → CW/(CW*W/H) = H/W → (H/W)*100% + return { + position: 'absolute', + top: '50%', + left: '50%', + width: `${(page.width / page.height) * 100}%`, + height: `${(page.height / page.width) * 100}%`, + objectFit: 'fill', + transform: `translate(-50%, -50%) rotate(${o}deg)`, + }; + } + return { transform: `rotate(${o}deg)`, width: '100%' }; + }; + }, + }, + + watch: { + async show(value) { + if (value) { + this.load(); + await this.$nextTick(); + const el = this.$refs.scrollPane?.$el ?? this.$refs.scrollPane; + if (el) { + this._scrollPane = el; + el.addEventListener('touchmove', this._touchMoveHandler, { passive: false }); + el.addEventListener('wheel', this._wheelHandler, { passive: false }); + } + window.addEventListener('keydown', this._keydownHandler); + window.addEventListener('keyup', this._keyupHandler); + } else { + this._scrollPane?.removeEventListener('touchmove', this._touchMoveHandler); + this._scrollPane?.removeEventListener('wheel', this._wheelHandler); + this._scrollPane = null; + window.removeEventListener('keydown', this._keydownHandler); + window.removeEventListener('keyup', this._keyupHandler); + this.reset(); + } + }, + media() { + if (this.show) this.load(); + }, + }, + + created() { + this._pdf = null; + this._loadId = 0; + this._touchMoveHandler = (e) => this.onTouchMove(e); + this._wheelHandler = (e) => this.onWheel(e); + this._keydownHandler = (e) => this.onKeydown(e); + this._keyupHandler = (e) => this.onKeyup(e); + }, + + beforeUnmount() { + this._scrollPane?.removeEventListener('touchmove', this._touchMoveHandler); + this._scrollPane?.removeEventListener('wheel', this._wheelHandler); + window.removeEventListener('keydown', this._keydownHandler); + window.removeEventListener('keyup', this._keyupHandler); + this._pdf?.destroy?.(); + }, + + methods: { + reset() { + this._loadId++; + this.loading = false; + this.saving = false; + this.error = null; + this.label = ''; + this.pages = []; + this.boxes = {}; + this.draft = null; + this.drag = null; + this.resize = null; + this.hoveredBox = null; + this.activeBox = null; + this.spaceDown = false; + this.panning = null; + this.zoom = 1; + this.baseWidth = 0; + this.pinchStartDistance = null; + this.pinchStartZoom = 1; + this._pdf?.destroy?.(); + this._pdf = null; + }, + async load() { + this.reset(); + if (!this.src) return; + if (this.mediaKind === 'pdf') return this.loadPdf(); + if (this.mediaKind === 'image') { + this.pages = [{ index: 0, width: 1, height: 1, rendered: true }]; + this.boxes = { 0: [] }; + return; + } + this.error = 'Unsupported file type'; + }, + async loadPdf() { + const loadId = this._loadId; + this.loading = true; + try { + await loadScript('/static/js/pdf.js/pdf.min.mjs'); + pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.js/pdf.worker.min.mjs'; + const pdf = await pdfjsLib.getDocument({ url: this.src, disableStream: true, disableRange: true }).promise; + if (loadId !== this._loadId) { pdf.destroy(); return; } + this._pdf = pdf; + const pages = []; + for (let index = 0; index < pdf.numPages; index++) { + const page = await pdf.getPage(index + 1); + if (loadId !== this._loadId) return; + const viewport = page.getViewport({ scale: 1 }); + pages.push({ index, width: viewport.width, height: viewport.height }); + this.boxes[index] = []; + await page.cleanup(); + } + if (loadId !== this._loadId) return; + this.pages = pages; + this.loading = false; + await this.$nextTick(); + for (const page of pages) { + if (loadId !== this._loadId) return; + await this.renderPdfPage(page.index); + } + } catch (e) { + if (loadId !== this._loadId) return; + console.error('PDF redactor load failed:', e); + this.error = 'Failed to load document'; + this.loading = false; + } + }, + canvasRef(index) { + const ref = this.$refs[`redactCanvas-${index}`]; + return Array.isArray(ref) ? ref[0] : ref; + }, + async renderPdfPage(index) { + const canvas = this.canvasRef(index); + if (!canvas || !this._pdf) return; + const pdfPage = await this._pdf.getPage(index + 1); + const viewport = pdfPage.getViewport({ scale: 1.5 }); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(viewport.width * dpr); + canvas.height = Math.floor(viewport.height * dpr); + canvas.style.width = '100%'; + canvas.style.height = 'auto'; + const ctx = canvas.getContext('2d', { alpha: false }); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, viewport.width, viewport.height); + await pdfPage.render({ canvasContext: ctx, viewport }).promise; + await pdfPage.cleanup(); + this.pages = this.pages.map(page => + page.index === index ? { ...page, width: viewport.width, height: viewport.height } : page + ); + }, + pageRect(index) { + return this.$refs[`page-${index}`]?.[0]?.getBoundingClientRect?.() + || this.$refs[`page-${index}`]?.getBoundingClientRect?.(); + }, + normalizedPoint(index, event) { + const rect = this.pageRect(index); + const x = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1); + const y = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1); + return { x, y }; + }, + startBox(index, event) { + if (event.button !== 0 || this.spaceDown) return; + const point = this.normalizedPoint(index, event); + this.draft = { page: index, startX: point.x, startY: point.y, x: point.x, y: point.y, w: 0, h: 0 }; + }, + startDrag(pageIndex, boxIndex, event) { + if (event.button !== 0) return; + event.stopPropagation(); + this.activeBox = { page: pageIndex, box: boxIndex }; + const point = this.normalizedPoint(pageIndex, event); + const box = this.boxes[pageIndex][boxIndex]; + this.drag = { page: pageIndex, boxIndex, startMouseX: point.x, startMouseY: point.y, origX: box.x, origY: box.y }; + }, + // corner: 'tl' | 'tr' | 'bl' | 'br' — the corner being dragged; opposite corner is the anchor + startResize(pageIndex, boxIndex, corner, event) { + if (event.button !== 0) return; + event.stopPropagation(); + this.activeBox = { page: pageIndex, box: boxIndex }; + const box = this.boxes[pageIndex][boxIndex]; + const anchorX = corner.includes('l') ? box.x + box.w : box.x; + const anchorY = corner.includes('t') ? box.y + box.h : box.y; + this.resize = { page: pageIndex, boxIndex, anchorX, anchorY }; + }, + moveBox(index, event) { + if (this.resize && this.resize.page === index) { + const point = this.normalizedPoint(index, event); + const { anchorX, anchorY, boxIndex } = this.resize; + const x = Math.min(Math.max(Math.min(point.x, anchorX), 0), 1); + const y = Math.min(Math.max(Math.min(point.y, anchorY), 0), 1); + const w = Math.min(Math.abs(point.x - anchorX), 1 - x); + const h = Math.min(Math.abs(point.y - anchorY), 1 - y); + const pageBoxes = [...(this.boxes[index] || [])]; + pageBoxes[boxIndex] = { x, y, w, h }; + this.boxes = { ...this.boxes, [index]: pageBoxes }; + return; + } + if (this.drag && this.drag.page === index) { + const point = this.normalizedPoint(index, event); + const dx = point.x - this.drag.startMouseX; + const dy = point.y - this.drag.startMouseY; + const pageBoxes = [...(this.boxes[index] || [])]; + const box = pageBoxes[this.drag.boxIndex]; + pageBoxes[this.drag.boxIndex] = { + ...box, + x: Math.min(Math.max(this.drag.origX + dx, 0), 1 - box.w), + y: Math.min(Math.max(this.drag.origY + dy, 0), 1 - box.h), + }; + this.boxes = { ...this.boxes, [index]: pageBoxes }; + return; + } + if (!this.draft || this.draft.page !== index) return; + const point = this.normalizedPoint(index, event); + const x0 = Math.min(this.draft.startX, point.x); + const y0 = Math.min(this.draft.startY, point.y); + const x1 = Math.max(this.draft.startX, point.x); + const y1 = Math.max(this.draft.startY, point.y); + this.draft = { ...this.draft, x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + }, + finishBox() { + if (this.resize) { this.resize = null; return; } + if (this.drag) { this.drag = null; return; } + if (!this.draft) return; + if (this.draft.w > 0.005 && this.draft.h > 0.005) { + const pageBoxes = this.boxes[this.draft.page] || []; + pageBoxes.push({ x: this.draft.x, y: this.draft.y, w: this.draft.w, h: this.draft.h }); + this.boxes = { ...this.boxes, [this.draft.page]: pageBoxes }; + } + this.draft = null; + }, + deleteBox(pageIndex, boxIndex) { + const pageBoxes = [...(this.boxes[pageIndex] || [])]; + pageBoxes.splice(boxIndex, 1); + this.boxes = { ...this.boxes, [pageIndex]: pageBoxes }; + if (this.hoveredBox?.page === pageIndex && this.hoveredBox?.box === boxIndex) this.hoveredBox = null; + if (this.activeBox?.page === pageIndex && this.activeBox?.box === boxIndex) this.activeBox = null; + }, + onKeydown(event) { + if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; + if (event.code === 'Space' && !this.spaceDown) { + event.preventDefault(); + this.spaceDown = true; + return; + } + if (event.key !== 'Backspace' && event.key !== 'Delete') return; + const target = this.activeBox || this.hoveredBox; + if (!target) return; + event.preventDefault(); + this.deleteBox(target.page, target.box); + }, + onKeyup(event) { + if (event.code === 'Space') { + this.spaceDown = false; + this.panning = null; + } + }, + onPanStart(event) { + if (!this.spaceDown || event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const pane = this._scrollPane; + if (!pane) return; + this.panning = { startX: event.clientX, startY: event.clientY, scrollLeft: pane.scrollLeft, scrollTop: pane.scrollTop }; + }, + onPanMove(event) { + if (!this.panning) return; + event.preventDefault(); + const pane = this._scrollPane; + if (!pane) return; + pane.scrollLeft = this.panning.scrollLeft - (event.clientX - this.panning.startX); + pane.scrollTop = this.panning.scrollTop - (event.clientY - this.panning.startY); + }, + onPanEnd() { + this.panning = null; + }, + visibleBoxes(pageIndex) { + const boxes = [...(this.boxes[pageIndex] || [])]; + if (this.draft?.page === pageIndex) boxes.push(this.draft); + return boxes; + }, + zoomIn() { + this.zoom = Math.min(+(this.zoom + 0.25).toFixed(2), REDACTOR_MAX_ZOOM); + }, + zoomOut() { + this.zoom = Math.max(+(this.zoom - 0.25).toFixed(2), 1); + }, + zoomFit() { + this.zoom = 1; + }, + pinchDistance(touches) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + }, + onTouchStart(event) { + if (event.touches.length === 2) { + this.pinchStartDistance = this.pinchDistance(event.touches); + this.pinchStartZoom = this.zoom; + } + }, + onTouchMove(event) { + if (event.touches.length === 2 && this.pinchStartDistance) { + event.preventDefault(); + const scale = this.pinchDistance(event.touches) / this.pinchStartDistance; + const next = Math.min(Math.max(this.pinchStartZoom * scale, 1), REDACTOR_MAX_ZOOM); + const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2; + const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2; + this.applyZoom(next, midX, midY); + } + }, + onTouchEnd() { + this.pinchStartDistance = null; + }, + applyZoom(nextZoom, anchorX, anchorY) { + const pane = this._scrollPane; + if (!pane) { this.zoom = nextZoom; return; } + if (!this.baseWidth) { + const wrapper = pane.querySelector('.redactor-pages'); + this.baseWidth = wrapper ? wrapper.getBoundingClientRect().width : pane.clientWidth; + } + const rect = pane.getBoundingClientRect(); + const localX = anchorX !== undefined ? anchorX - rect.left : pane.clientWidth / 2; + const localY = anchorY !== undefined ? anchorY - rect.top : pane.clientHeight / 2; + const worldX = pane.scrollLeft + localX; + const worldY = pane.scrollTop + localY; + const ratio = nextZoom / this.zoom; + this.zoom = nextZoom; + this.$nextTick(() => { + pane.scrollLeft = worldX * ratio - localX; + pane.scrollTop = worldY * ratio - localY; + }); + }, + onWheel(event) { + if (!event.ctrlKey) return; + event.preventDefault(); + const factor = Math.exp(-event.deltaY * 0.005); + const next = Math.min(Math.max(this.zoom * factor, 1), REDACTOR_MAX_ZOOM); + this.applyZoom(next, event.clientX, event.clientY); + }, + async submit(overwrite = false) { + if (!this.canSubmit) return; + this.saving = true; + const pages = Object.entries(this.boxes) + .filter(([, rects]) => rects.length) + .map(([page, rects]) => ({ page: Number(page), rects })); + try { + const response = await api.post(`/admin/api/media/${this.media.id}/redact`, { pages, title: this.label.trim(), overwrite }); + this.$emit('redacted', response.data.data); + this.show = false; + } catch (e) { + this.error = e.response?.data?.message || 'Failed to save redaction'; + } finally { + this.saving = false; + } + }, + }, + + template: /*html*/` + + + + + + Redaction Tool + — {{ media?.title || media?.filename || 'document' }} + + + + + + Draw at least one black box on the document to save + + + + + + Click & drag to draw a black box + Drag box to reposition + Click box then Delete to remove + Hold Space + drag to pan + + + {{ Math.round(zoom * 100) }}% + + Fit + + + {{ error }} + +
+ +
+
+
+ + + + +
+
+
+
+
+ `, +}); diff --git a/enferno/static/js/components/MediaThumbnail.js b/enferno/static/js/components/MediaThumbnail.js index 5ac7ba626..e1ec3eecf 100644 --- a/enferno/static/js/components/MediaThumbnail.js +++ b/enferno/static/js/components/MediaThumbnail.js @@ -62,15 +62,24 @@ const MediaThumbnail = Vue.defineComponent({ immediate: true, handler(newUrl) { if (!newUrl) return; - + const hasExistingThumbnail = this.thumbnailUrl || this.imageLoaded; const isGenerating = this.isGeneratingThumbnail; - + if (!hasExistingThumbnail && !isGenerating) { this.initThumbnail(); } } - } + }, + media(newMedia, oldMedia) { + if (newMedia?.etag && newMedia.etag !== oldMedia?.etag) { + this.s3url = ''; + this.imageLoaded = false; + this.thumbnailUrl = null; + this.hasError = false; + this.init().catch(() => {}); + } + }, }, methods: { setupIntersectionObserver() { @@ -99,8 +108,9 @@ const MediaThumbnail = Vue.defineComponent({ try { const response = await api.get(`/admin/api/media/${this.media.filename}`); - this.s3url = response.data.url; - this.media.s3url = response.data.url; + const etag = this.media.etag; + this.s3url = etag ? `${response.data.url}?v=${etag}` : response.data.url; + this.media.s3url = this.s3url; this.initThumbnail(); } catch (error) { console.error('Error fetching media:', error); @@ -263,28 +273,23 @@ const MediaThumbnail = Vue.defineComponent({ return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }, getImageStyle(orientation) { - const baseStyle = { + const base = { objectFit: 'cover', - transform: `rotate(${orientation}deg)`, - maxWidth: '100%', - maxHeight: '100%', - transition: 'opacity 0.4s ease-in-out' + transition: 'opacity 0.4s ease-in-out', + position: 'absolute', + top: '50%', + left: '50%', + transform: `translate(-50%, -50%) rotate(${orientation}deg)`, }; - - // For 90 or 270 degree rotations, we need to swap dimensions if (orientation === 90 || orientation === 270) { - return { - ...baseStyle, - width: 'auto', - height: '100%' - }; + // Pre-rotation width becomes visual height after 90°/270° rotation, and vice versa. + // To fill a W×H container: pre-rotation box must be H×W (swapped). + // CSS can express this with container query units: 100cqw = containerW, 100cqh = containerH. + // So: pre-rotation width = containerH (100cqh), pre-rotation height = containerW (100cqw). + // The parent needs container-type set; the root div already has overflow:hidden. + return { ...base, width: '100cqh', height: '100cqw' }; } - - return { - ...baseStyle, - width: '100%', - height: 'auto' - }; + return { ...base, width: '100%', height: '100%' }; }, getRandomGradient() { if (this.randomGradient) return this.randomGradient; @@ -334,7 +339,7 @@ const MediaThumbnail = Vue.defineComponent({ }, }, template: /*html*/` -
+
mdi-magnify-plus @@ -360,12 +365,11 @@ const MediaThumbnail = Vue.defineComponent({ style="z-index: 2;"> -
'); - - return output ? `
${output}
` : ''; + reporters() { + if (this.type !== 2 || !Array.isArray(this.field)) return []; + return this.field.filter(rep => rep && Object.keys(rep).length); }, }, template: `
{{ title }}
- + +
`, -}); \ No newline at end of file +}); diff --git a/enferno/static/js/components/NativePdfViewer.js b/enferno/static/js/components/NativePdfViewer.js new file mode 100644 index 000000000..4a5340b84 --- /dev/null +++ b/enferno/static/js/components/NativePdfViewer.js @@ -0,0 +1,39 @@ +const NativePdfViewer = Vue.defineComponent({ + props: ['media', 'mediaType'], + data: () => { + return { + translations: window.translations, + fullscreen: false, + }; + }, + methods: { + requestFullscreen() { + this.fullscreen = true; + }, + }, + template: ` +
+ + + + {{ translations.preview_ }} + + + + + + + + + + + + +
+ `, +}); diff --git a/enferno/static/js/components/NotificationsList.js b/enferno/static/js/components/NotificationsList.js index d9524160e..6b78552cf 100644 --- a/enferno/static/js/components/NotificationsList.js +++ b/enferno/static/js/components/NotificationsList.js @@ -128,13 +128,13 @@ const NotificationsList = Vue.defineComponent({ :class="{ 'font-weight-bold': (!notification?.read_status || notification?.is_urgent) }" class="text-body-1" :style="getLineClampStyles(config.maxTitleLines)" - v-html="notification?.title" + v-text="notification?.title" />
{{ getDateFromNotification(notification) }} diff --git a/enferno/static/js/components/Toast.js b/enferno/static/js/components/Toast.js index e88a4a8d5..8e663f946 100644 --- a/enferno/static/js/components/Toast.js +++ b/enferno/static/js/components/Toast.js @@ -56,7 +56,7 @@ const Toast = Vue.defineComponent({ template: ` - + diff --git a/enferno/static/js/components/UpdateBanner.js b/enferno/static/js/components/UpdateBanner.js index 983979de0..12baa15a0 100644 --- a/enferno/static/js/components/UpdateBanner.js +++ b/enferno/static/js/components/UpdateBanner.js @@ -5,7 +5,6 @@ const UpdateBanner = Vue.defineComponent({ latest: null, releaseNotesUrl: null, dialog: false, - starting: false, }; }, computed: { @@ -32,23 +31,6 @@ const UpdateBanner = Vue.defineComponent({ // silent: background poll should never spam the UI } }, - async startUpdate() { - this.starting = true; - try { - await axios.post('/admin/api/updates/start'); - this.dialog = false; - this.$emit('update-started'); - } catch (e) { - const msg = (e?.response?.data?.message) || 'Failed to start update'; - if (this.$root && typeof this.$root.showSnack === 'function') { - this.$root.showSnack(msg, 'error'); - } else { - console.error(msg); - } - } finally { - this.starting = false; - } - }, }, template: ` Release notes

- The update will take about 60 seconds. The app will be briefly - unavailable. A pre-update database snapshot will be taken - automatically. + To update, run this on the server as an administrator: +
sudo bayanat update {{ latest }}
- Cancel - - Update now - + Close diff --git a/enferno/static/js/mixins/global-mixin.js b/enferno/static/js/mixins/global-mixin.js index d203bd4e8..84408732a 100644 --- a/enferno/static/js/mixins/global-mixin.js +++ b/enferno/static/js/mixins/global-mixin.js @@ -115,15 +115,15 @@ const globalMixin = { }, parseValidationError(response){ if (response && response.errors){ - let message = ''; + const messages = []; for(const field in response.errors){ let fieldName = field; if (fieldName.startsWith('item.')){ fieldName = fieldName.substring(5); } - message += `[${!fieldName.includes("__root__") ? fieldName : 'Validation Error'}]: ${response.errors[field]}
`; + messages.push(`[${!fieldName.includes("__root__") ? fieldName : 'Validation Error'}]: ${response.errors[field]}`); } - return message; + return messages.join('\n'); } return response; }, diff --git a/enferno/static/js/mixins/media-mixin.js b/enferno/static/js/mixins/media-mixin.js index 2e1e0f191..4f2274b84 100644 --- a/enferno/static/js/mixins/media-mixin.js +++ b/enferno/static/js/mixins/media-mixin.js @@ -319,7 +319,6 @@ const mediaMixin = { this.editedItem.medias.splice(index, 1); } }, - closeMediaDialog() { this.destroyCrop(); this.editedMedia.files = []; diff --git a/enferno/static/js/tinymce/CHANGELOG.md b/enferno/static/js/tinymce/CHANGELOG.md deleted file mode 100644 index d7c9d5211..000000000 --- a/enferno/static/js/tinymce/CHANGELOG.md +++ /dev/null @@ -1,3622 +0,0 @@ -# Changelog -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), -and is generated by [Changie](https://github.com/miniscruff/changie). - -## 7.3.0 - 2024-08-07 - -### Added -- Colorpicker number input fields now show an error tooltip and error icon when invalid text has been entered. #TINY-10799 -- New `format-code` icon. #TINY-11018 - -### Improved -- When a full document was loaded as editor content the head elements were added to the body. #TINY-11053 - -### Fixed -- Unnecessary nbsp entities were inserted when typing at the edges of inline elements. #TINY-10854 -- Fixed JavaScript error when inserting a table using the context menu by adjusting the event order in `renderInsertTableMenuItem`. #TINY-6887 -- Notifications didn't position and resize properly when resizing the editor or toggling views. #TINY-10894 -- The pattern commands would execute even if the command was not enabled. #TINY-10994 -- Split button popups were incorrectly positioned when switching to fullscreen mode if the editor was inside a scrollable container. #TINY-10973 -- Sequential html comments would in some cases generate unwanted elements. #TINY-10955 -- The listbox component had a fixed width and was not a responsive ui element. #TINY-10884 -- Prevent default mousedown on toolbar buttons was causing misplaced focus bugs. #TINY-10638 -- Attempting to use focus commands on an editor where the cursor had last been in certain contentEditable="true" elements would fail. #TINY-11085 -- Colorpicker's hex-based input field showed the wrong validation error message. #TINY-11115 - -## 7.2.1 - 2024-07-03 - -### Fixed -- Text content could move unexpectedly when deleting a paragraph. #TINY-10590 -- Cursor would shift to the start of the editor body when focus was shifted to a noneditable cell of a table. #TINY-10127 -- Long translations of the bottom help text would cause minor graphical issues. #TINY-10961 -- Open Link button was disabled when selection partially covered a link or when multiple links were selected. #TINY-11009 - -## 7.2.0 - 2024-06-19 - -### Added -- Added `options.debug` API that logs the initial raw editor options to console. #TINY-10605 -- Added `referrerpolicy` as a valid attribute for an iframe element. #TINY-10374 -- New `onInit` and `stretched` properties to the `HtmlPanel` dialog component. #TINY-10900 -- Added support for querying the state of the `mceTogglePlainTextPaste` command. #TINY-10938 -- Added `for` option to dialog label components to improve accessibility. The value must be another component on the same dialog. #TINY-10971 - -### Improved -- Dialog slider components now emit an onChange event when using arrow keys. #TINY-10428 -- Accessibility for element path buttons, added tooltip to describe the button and removed incorrect `aria-level` attribute. #TINY-10891 -- Improve merging of inserted inline elements by removing nodes with redundant inheritable styles. #TINY-10869 -- Improved Find & Replace dialog accessibility by changing placeholders to labels. #TINY-10871 - -### Changed -- Replaced tiny branding logo with `Build with TinyMCE` text and logo. #TINY-11001 - -### Fixed -- Deleting in a `div` with preceeding `br` elements would sometimes throw errors. #TINY-10840 -- `autoresize_bottom_margin` was not reliably applied in some situations. #TINY-10793 -- Fixed cases where adding a newline around a br, table or img would not move the cursor to a new line. #TINY-10384 -- Focusing on `contenteditable="true"` element when using `editable_root: false` and inline mode causing selection to be shifted. #TINY-10820 -- Corrected the `role` attribute on listbox dialog components to `combobox` when there are no nested menu items. #TINY-10807 -- HTML entities that were double decoded in `noscript` elements caused an XSS vulnerability. #TINY-11019 -- It was possible to inject XSS HTML that was not matching the regexp when using the `noneditable_regexp` option. #TINY-11022 - -## 7.1.2 - 2024-06-05 - -### Fixed -- CSS color values set to `transparent` were incorrectly converted to '#000000`. #TINY-10916 - -## 7.1.1 - 2024-05-22 - -### Fixed -- Insert/Edit image dialog lost focus after the image upload completed. #TINY-10885 -- Deleting into a list from a paragraph that has an `img` tag could cause extra inline styles to be added. #TINY-10892 -- Resolved an issue where emojis configured with the `emojiimages` database were not loading correctly due to a broken CDN. #TINY-10878 -- Iframes in dialogs were not rendering rounded borders correctly. #TINY-10901 -- Autocompleter possible values are no longer capped at a length of 10. #TINY-10942 - -## 7.1.0 - 2024-05-08 - -### Added -- Parser support for math elements. #TINY-10809 -- New `math-equation` icon. #TINY-10804 - -### Improved -- Included `itemprop`, `itemscope` and `itemtype` as valid HTML5 attributes in the core schema. #TINY-9932 -- Notification accessibility improvements: added tooltips, keyboard navigation and shortcut to focus on notifications. #TINY-6925 -- Removed `aria-pressed` from the `More` button in sliding toolbar mode and replaced it with `aria-expanded`. #TINY-10795 -- The editor UI now renders correctly in Windows High Contrast Mode. #TINY-10781 - -### Fixed -- Backspacing in certain html setups resulted in data moving around unexpectedly. #TINY-10590 -- Dialog title markup changed to use an `h1` element instead of `div`. #TINY-10800 -- Dialog title was not announced in macOS VoiceOver, dialogs now use `aria-label` instead of `aria-labelledby` on macOS. #TINY-10808 -- Theme loader did not respect the suffix when it was loading skin CSS files. #TINY-10602 -- Custom block elements with colon characters would throw errors. #TINY-10813 -- Tab navigation in views didn't work. #TINY-10780 -- Video and audio elements could not be played on Safari. #TINY-10774 -- `ToggleToolbarDrawer` command did not toggle the toolbar in `sliding` mode when `{skipFocus: true}` parameter was passed. #TINY-10726 -- The buttons in the custom view header were clipped on when overflowing. #TINY-10741 -- In the custom view, the scrollbar of the container was not visible if its height was greater than the editor. #TINY-10741 -- Fixed accessibility issue by removing duplicate `role="menu"` attribute from color swatches. #TINY-10806 -- Fullscreen mode now prevents focus from leaving the editor. #TINY-10597 -- Open link context menu action did not work with selection surrounding a link. #TINY-10391 -- Styles were not retained when toggling a list on and off. #TINY-10837 -- Caret and placeholder text were invisible in Windows High Contrast Mode. #TINY-9811 -- Firefox did not announce the iframe title when `iframe_aria_text` was set. #TINY-10718 -- Notification width was not constrained to the width of the editor. #TINY-10886 -- Open link context menu action was not enabled for links on images. #TINY-10391 - -## 7.0.1 - 2024-04-10 - -### Fixed -- Toggle list behavior generated wrong html when the `forced_root_block` option was set to `div`. #TINY-10488 -- Tapping inside a composed text on Firefox Android would not close the autocompleter. #TINY-10715 -- An inline editor toolbar now behaves correctly in horizontally scrolled containers. #TINY-10684 -- Tooltips unintended shrinking and incorrectly positioned when shown in horizontally scrollable container. #TINY-10797 -- The status bar was invisible when the editor's height is short. #TINY-10705 - -## 7.0.0 - 2024-03-20 - -### Added -- New `license_key` option that must be set to `gpl` or a valid license key. #TINY-10681 -- New custom tooltip functionality, tooltip will be shown when hovering with a mouse or with keyboard focus. #TINY-9275 -- New `sandbox_iframes_exclusions` option that holds a list of URL host names to be excluded from iframe sandboxing when `sandbox_iframes` is set to `true`. #TINY-10350 -- Added 'getAllEmojis' api function to the emoticons plugin. #TINY-10572 -- Element preset support for the `valid_children` option and Schema.addValidChildren API. #TINY-9979 -- A new `trigger` property for block text pattern configurations, allowing pattern activation with either Space or Enter keys. #TINY-10324 -- onFocus callback for CustomEditor dialog component. #TINY-10596 -- icons for the import from Word, export to Word and export to PDF premium plugins. #TINY-10612 -- `data` is now a valid element in the Schema. #TINY-10611 -- More advanced schema config for custom elements. #TINY-9980 -- Custom tooltip for autocompleter, now visible on both mouse hover and keyboard focus, except single column cases. #TINY-9638 - -### Improved -- Included keyboard shortcut in custom tooltip for `ToolbarButton` and `ToolbarToggleButton`. #TINY-10487 -- Improved showing which element has focus for keyboard navigation. #TINY-9176 -- Custom tooltips will now show for items in `collection` which is rendered inside a dialog, on mouse hover and keyboard focus. #TINY-9637 -- Autocompleter will now work with IMEs. #TINY-10637 -- Make table ghost element better reflect height changes when resizing. #TINY-10658 - -### Changed -- TinyMCE is now licensed GPL Version 2 or later. #TINY-10578 -- `convert_unsafe_embeds` editor option is now defaulted to `true`. #TINY-10351 -- `sandbox_iframes` editor option is now defaulted to `true`. #TINY-10350 -- The DOMUtils.isEmpty API function has been modified to consider nodes containing only comments as empty. #TINY-10459 -- The `highlight_on_focus` option now defaults to true, adding a focus outline to every editor. #TINY-10574 -- Delay before the tooltip to show up, from 800ms to 300ms. #TINY-10475 -- Now `tox-view__pane` has `position: relative` instead of `static`. #TINY-10561 -- Update outbound link for statusbar Tiny logo #TINY-10494 -- Remove the height field from the `table` plugin cell dialog. The `table` plugin row dialog now controls the row height by setting the height on the `tr` element, not the `td` elements. #TINY-10617 -- Change table height resizing handling to remove heights from `td`/`th` elements and only apply to `tr` elements. #TINY-10589 -- Removed incorrect `aria-placeholder` attribute from editor body when `placeholder` option is set. #TINY-10452 -- The `tooltip` property for dialog's footer `togglebutton` is now optional. #TINY-10672 -- Changed the `media_url_resolver` option to use promises. #TINY-9154 -- `Styles` bespoke toolbar button fallback changed to `Formats` if `Paragraph` is not configured in `style_formats` option. #TINY-10603 -- Updated deprecation/removed console message. #TINY-10694 - -### Removed -- Deprecated `force_hex_color` option, with the default now being all colors are forced to hex format as lower case. #TINY-10436 -- Deprecated `remove_trailing_brs` option from DomParser. #TINY-10454 -- `title` attribute on buttons with visible label. #TINY-10453 -- `InsertOrderedList` and `InsertUnorderedList` commands from core, these now only exist in the `lists` plugin. #TINY-10644 -- `closeButton` from the notification API, close buttons in notifications are now required. #TINY-10646 -- The autocompleter `ch` configuration property has been removed. Use the `trigger` property instead. #TINY-8929 -- Deprecated `template` plugin. #TINY-10654 - -### Fixed -- When deleting the last row in a table, the cursor would jump to the first cell (top left), instead of moving to the next adjacent cell in some cases. #TINY-6309 -- Heading formatting would be partially applied to the content within the `summary` element when the caret was positioned between words. #TINY-10312 -- Moving focus to the outside of the editor after having clicked a menu would not fire a `blur` event as expected. #TINY-10310 -- Autocomplete would sometimes cause corrupt data when starting during text composition. #TINY-10317 -- Inline mode with persisted toolbar would show regardless of the skin being loaded, causing css issues. #TINY-10482 -- Table classes couldn't be removed via setting an empty value in `table_class_list`. Also fixed being forced to pick the first class option. #TINY-6653 -- Directly right clicking on a ol's li in FireFox didn't enable the button `List Properties...` in the context menu. #TINY-10490 -- The `link_default_target` option wasn't considered when inserting a link via `quicklink` toolbar. #TINY-10439 -- When inline editor toolbar wrapped to multiple lines the top wasn't always calculated correctly. #TINY-10580 -- Removed manually dispatching dragend event on drop in Firefox. #TINY-10389 -- Slovenian help dialog content had a dot in the wrong place. #TINY-10601 -- Pressing Backspace at the start of an empty `summary` element within a `details` element nested in a list item no longer removes the `summary` element. #TINY-10303 -- The toolbar width was miscalculated for the inline editor positioned inside a scrollable container. #TINY-10581 -- Fixed incorrect object processor for `event_root` option. #TINY-10433 -- Adding newline after using `selection.setContent` to insert a block element would throw an unhandled exception. #TINY-10560 -- Floating toolbar buttons in inline editor incorrectly wrapped into multiple rows on window resizing or zooming. #TINY-10570 -- When setting table border width and `table_style_by_css` is true, only the border attribute is set to 0 and border-width styling is no longer used. #TINY-10308 -- Clicking to the left or right of a non-editable div in Firefox would show two cursors. #TINY-10314 - -## 6.8.3 - 2024-02-08 - -### Changed -- Update outbound TinyMCE website links. #TINY-10491 - -### Fixed -- The floating toolbar would not be fully visible when the editor was placed inside a scrollable container. #TINY-10335 -- ShadowDOM skin was not loaded properly when used with js bundling feature. #TINY-10451 - -## 6.8.2 - 2023-12-11 - -### Fixed -- Bespoke select toolbar buttons including `fontfamily`, `fontsize`, `blocks`, and `styles` incorrectly used plural words in their accessible names. #TINY-10426 -- The `align` bespoke select toolbar button had an accessible name that was misleading and grammatically incorrect in certain cases. #TINY-10435 -- Accessible names of bespoke select toolbar buttons including `align`, `fontfamily`, `fontsize`, `blocks`, and `styles` were incorrectly translated. #TINY-10426 #TINY-10435 -- Clicking inside table cells with heavily nested content could cause the browser to hang. #TINY-10380 -- Toggling a list that contains an LI element having another list as its first child would remove the remaining content within that LI element. #TINY-10414 - -## 6.8.1 - 2023-11-29 - -### Improved -- Colorpicker now includes the Brightness/Saturation selector and hue slider in the keyboard navigable items. #TINY-9287 - -### Fixed -- Translation syntax for announcement text in the table grid was incorrectly formatted. #TINY-10141 -- The functions `schema.isWrapper` and `schema.isInline` did not exclude node names that started with `#` which should not be considered as elements. #TINY-10385 - -## 6.8.0 - 2023-11-22 - -### Added -- CSS files are now also generated as separate JS files to improve bundling of all resources. #TINY-10352 -- Added new `StylesheetLoader.loadRawCss` API that can be used to load CSS into a style element. #TINY-10352 -- Added new `StylesheetLoader.unloadRawCss` API that can be used to unload CSS that was loaded into a style element. #TINY-10352 -- Added `force_hex_color` editor option. Option `'always'` converts all RGB & RGBA colours to hex, `'rgb_only'` will only convert RGB and *not* RGBA colours to hex, `'off'` won't convert any colours to hex. #TINY-9819 -- Added `default_font_stack` editor option that makes it possible to define what is considered a system font stack. #TINY-10290 -- New `sandbox_iframes` option that controls whether iframe elements will be added a `sandbox=""` attribute to mitigate malicious intent. #TINY-10348 -- New `convert_unsafe_embeds` option that controls whether `` and `` elements will be converted to more restrictive alternatives, namely `` for image MIME types, `