diff --git a/sim/cluster0/DEPLOY.md b/sim/cluster0/DEPLOY.md index 4bfb5af..07e4128 100644 --- a/sim/cluster0/DEPLOY.md +++ b/sim/cluster0/DEPLOY.md @@ -84,6 +84,7 @@ Same scripts; change only `cluster.env`: | `scripts/` | Baseline, MariaDB, deploy, nginx | | `sql/` | Schema + migrations | | `nginx/apps-port80.conf` | php83-fpm TCP vhost | +| `gitea/` | Lab Gitea `app.ini.template` + README | | `bootstrap/` | Minimal `/etc/cast-cluster.env` for first boot | See `ARCHITECTURE.md` for topology. diff --git a/sim/cluster0/cluster.env b/sim/cluster0/cluster.env index 3b5d6da..210fc26 100644 --- a/sim/cluster0/cluster.env +++ b/sim/cluster0/cluster.env @@ -43,9 +43,19 @@ APP_HUB_PATH=/app/androidcast_project COMPOSE_BACKEND=/var/www/ac/composed/backend AC_PLATFORM_ROOT=/var/www/ac/platform COMPOSE_RUNTIME=1 +COMPOSE_SKIP_MONOLITH=1 POSTGRES_SEMI_ENABLE=1 -# Set after DNS + FE redirect, then run verify_global_setup.sh +# Gitea browse UI on cast01 (primary); mirrors added later +GITEA_ENABLE=1 +GITEA_HTTP_PORT=3000 +GITEA_DB_TYPE=sqlite3 +GITEA_PUBLIC_HOST=acl0.f0xx.org +GITEA_ROOT_URL=https://acl0.f0xx.org/app/androidcast_project/git/ +GITEA_ORG=ac + +# Global verify: lab FE vhost (set after ac-fe.conf on monstro); prod unchanged below +LAB_PUBLIC_ORIGIN=https://acl0.f0xx.org PUBLIC_ORIGIN=https://apps.f0xx.org SHORT_LINKS_PUBLIC_BASE=https://s.f0xx.org FE_PROXY_TARGET=cast01.intra.raptor.org:80 diff --git a/sim/cluster0/credentials.txt b/sim/cluster0/credentials.txt index 655d275..d3d987b 100644 --- a/sim/cluster0/credentials.txt +++ b/sim/cluster0/credentials.txt @@ -20,3 +20,15 @@ allowed_from=10.7.16.% [ssh] user=ai sudo=passwordless + +[gitea_admin] +user=admin +password=cast-cluster-gitea-admin + +[gitea_mirror] +# API token for mirror-gitea-ac-org.sh — stored on cast01 only: +# /var/lib/gitea/.mirror-admin-token (regenerate: see ac-docs/docs/SECRETS_AND_RECOVERY.md) + +[secrets] +# Non-git lab secrets template: secrets.lab.env.example → copy to secrets.lab.env on NFS +# Recovery: ac-docs/docs/SECRETS_AND_RECOVERY.md diff --git a/sim/cluster0/gitea/README.md b/sim/cluster0/gitea/README.md new file mode 100644 index 0000000..be4fd7f --- /dev/null +++ b/sim/cluster0/gitea/README.md @@ -0,0 +1,65 @@ +# Gitea on cluster0 (cast01 primary) + +Browse UI (after FE `ac-fe.conf` + DNS): +`https://acl0.f0xx.org/app/androidcast_project/git/` + +Direct on cast01 (debug): `http://127.0.0.1:3000/` (nginx public path strips `/app/.../git/`). + +## This step (basic config only) + +- Prod `app.ini` layout copied and adapted in `app.ini.template` +- Paths under `/var/lib/gitea` (repos, LFS, logs) +- SQLite DB at `/var/lib/gitea/gitea.db` (`GITEA_DB_TYPE=sqlite3` in `cluster.env`; optional `mysql` for prod-like) +- Nginx on cast01 proxies `/app/androidcast_project/git/` → `127.0.0.1:3000` +- **No** repo mirrors / org migration yet — run later via scripts under + `lab-seeds/backend/scripts/gitea/` (same as prod BE) + +## Deploy + +```bash +# On cast01 after populate app phase (or standalone): +sudo sh /shared/cluster/scripts/deploy-gitea.sh +sudo sh /shared/cluster/scripts/configure-nginx.sh +sudo rc-service gitea status +curl -sS -o /dev/null -w 'gitea_direct=%{http_code}\n' http://127.0.0.1:3000/ +curl -sS -o /dev/null -w 'gitea_nginx=%{http_code}\n' \ + http://127.0.0.1/app/androidcast_project/git/ +``` + +Enable in `cluster.env`: `GITEA_ENABLE=1` (set by default in tree). + +## Lab admin login + +| Field | Value | +|-------|--------| +| User | `admin` (override `GITEA_ADMIN_USER`) | +| Password | `[gitea_admin]` in `credentials.txt` (falls back to mariadb_app password) | + +Create org **AndroidCast** in UI before mirror scripts, or run `gitea_migrate_org_setup.sh` later. + +## Later: mirror `ac/*` repos + +When ready (not part of this step): + +```bash +sudo sh /shared/cluster/scripts/mirror-gitea-ac-org.sh +``` + +Uses `gitea/ac-repos.list` and `lab-seeds/backend/scripts/gitea/*` (API pull mirrors, org **ac**). + +**Lab status (cluster0):** all mirrors in `gitea/ac-repos.list` — run `sudo sh /shared/cluster/scripts/verify-gitea-mirrors.sh` on cast01. + +See `lab-seeds/backend/scripts/gitea/README.md` for full mirror/protect flows. + +## Integration map + +| Layer | Config | +|-------|--------| +| cast01 BE nginx | `nginx/apps-port80.conf` → `:3000` | +| Gentoo FE lab vhost | `ac-platform-edge/fe/ac-fe.conf` → `cast01:80` (git via BE nginx) | +| Git server (canonical) | `git://f0xx.org/ac/*` — unchanged; Gitea is browse mirror only | + +Secrets generated once in `/var/lib/gitea/.lab-secrets.env` on cast01 (not in git). +Mirror API token: `/var/lib/gitea/.mirror-admin-token` — recovery in `ac-docs/docs/SECRETS_AND_RECOVERY.md`. + +**Note:** first start after fresh DB can take **30–90s** (SQLite schema migration). `deploy-gitea.sh` waits up to 6 minutes. diff --git a/sim/cluster0/gitea/ac-repos.list b/sim/cluster0/gitea/ac-repos.list new file mode 100644 index 0000000..5ea4968 --- /dev/null +++ b/sim/cluster0/gitea/ac-repos.list @@ -0,0 +1,36 @@ +# Gitea pull mirrors — org ac (git://f0xx.org/ac/*). One repo name per line. +# Legacy monolith (read-only reference): +android_cast|git://f0xx.org/android_cast +ac-be-access +ac-be-auth +ac-be-builder +ac-be-graphs +ac-be-hub +ac-be-issues +ac-be-remote-access +ac-be-tickets +ac-deploy +ac-docs +ac-mobile-android +ac-mobile-ios +ac-ms-build +ac-ms-devices +ac-ms-graphs +ac-ms-identity +ac-ms-issues +ac-ms-media-transcode +ac-ms-notifications +ac-ms-ota +ac-ms-rbac +ac-ms-remote-access +ac-ms-sfu-signaling +ac-ms-template +ac-ms-tickets +ac-ms-url-shortener +ac-platform-db +ac-platform-edge +ac-platform-php +ac-platform-web +ac-scripts +ac-session-studio +ac-workspace diff --git a/sim/cluster0/gitea/app.ini.template b/sim/cluster0/gitea/app.ini.template new file mode 100644 index 0000000..2f6eb56 --- /dev/null +++ b/sim/cluster0/gitea/app.ini.template @@ -0,0 +1,84 @@ +# Lab Gitea — rendered by deploy-gitea.sh → /etc/gitea/app.ini +# Based on prod artc0 layout; paths and URLs adjusted for cluster0 (acl0.f0xx.org). +# Repo migration (mirrors) is a separate step — see gitea/README.md. + +RUN_USER = gitea +RUN_MODE = prod +APP_NAME = AndroidCast lab (cluster0) +WORK_PATH = @GITEA_APP_DATA@ + +[repository] +ROOT = @GITEA_APP_DATA@/data/gitea-repositories +DEFAULT_MIRROR_INTERVAL = 10m +MIRROR_UPDATE_INTERVAL = 10m + +[server] +LFS_START_SERVER = true +PROTOCOL = http +DOMAIN = @GITEA_DOMAIN@ +ROOT_URL = @GITEA_ROOT_URL@ +HTTP_ADDR = 127.0.0.1 +HTTP_PORT = @GITEA_HTTP_PORT@ +APP_DATA_PATH = @GITEA_APP_DATA@ +STATIC_ROOT_PATH = /usr/share/webapps/gitea +DISABLE_SSH = true +OFFLINE_MODE = true +LFS_JWT_SECRET = @GITEA_LFS_JWT@ + +[database] +DB_TYPE = sqlite3 +PATH = @GITEA_APP_DATA@/gitea.db +LOG_SQL = false + +[session] +PROVIDER = file + +[log] +ROOT_PATH = /var/log/gitea +MODE = file +LEVEL = info + +[lfs] +PATH = @GITEA_APP_DATA@/lfs + +[mailer] +ENABLED = false + +[service] +REGISTER_EMAIL_CONFIRM = false +ENABLE_NOTIFY_MAIL = false +DISABLE_REGISTRATION = true +ALLOW_ONLY_EXTERNAL_REGISTRATION = false +ENABLE_CAPTCHA = false +REQUIRE_SIGNIN_VIEW = false +DEFAULT_ALLOW_CREATE_ORGANIZATION = true + +[security] +INSTALL_LOCK = true +INTERNAL_TOKEN = @GITEA_INTERNAL_TOKEN@ +PASSWORD_HASH_ALGO = pbkdf2 + +[oauth2] +JWT_SECRET = @GITEA_OAUTH_JWT@ + +[migrations] +ALLOW_LOCALNETWORKS = true +ALLOWED_DOMAINS = *,github.com,*.github.com,chromium.googlesource.com,git.zx2c4.com,f0xx.org,*.f0xx.org +BLOCKED_DOMAINS = + +[mirror] +MIN_INTERVAL = 1m + +[cron.update_mirrors] +ENABLED = true +SCHEDULE = @every 10m +RUN_AT_START = true + +[actions] +ENABLED = false + +[cron.update_checker] +ENABLED = false + +[indexer] +ISSUE_INDEXER_TYPE = db diff --git a/sim/cluster0/lab-seeds/README.md b/sim/cluster0/lab-seeds/README.md new file mode 100644 index 0000000..c8bee62 --- /dev/null +++ b/sim/cluster0/lab-seeds/README.md @@ -0,0 +1,9 @@ +# Lab backend seed (no monolith git pull) + +Frozen copy of the runnable PHP tree used to **bootstrap** `/var/www/ac/composed/backend` on cluster0. + +- **Source of truth for new work:** `ac-workspace` submodules (`ac-ms-issues`, `ac-be-issues`, …). +- **This tree:** baseline for `compose-lab-backend.sh` overlay when `COMPOSE_SKIP_MONOLITH=1`. + +Synced from legacy monolith `examples/crash_reporter/backend` (read-only reference). +Do not treat this as the long-term edit target — change the matching `ac-*` repo and re-run compose on the cluster. diff --git a/sim/cluster0/lab-seeds/backend/README.md b/sim/cluster0/lab-seeds/backend/README.md new file mode 100644 index 0000000..38ae5fb --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/README.md @@ -0,0 +1,343 @@ +# Android Cast — crash reporter backend (PHP) + +Anonymous crash JSON receiver and web console for +`https://f0xx.org/app/androidcast_project/crashes/` + +## Requirements + +- PHP 8.1+ with **PDO** (`pdo_sqlite` for default, `pdo_mysql` for MariaDB) +- Extensions: **session**, **json** (usually core), **pdo**, **pdo_sqlite** or **pdo_mysql** + +### Alpine Linux (php-fpm 8.1) + +Minimal `php81-fpm` does **not** include sessions or PDO. Install modules explicitly: + +```sh +# SQLite only (dev / small installs) +apk add php81-fpm php81-session php81-pdo php81-pdo_sqlite php81-sqlite3 + +# MariaDB (recommended on BE) +apk add mariadb mariadb-client php81-pdo_mysql + +rc-service php-fpm81 restart +# package names may be php82-* on newer Alpine; adjust to your php -v +php -m | grep -E 'session|pdo_sqlite|pdo_mysql|PDO' +``` + +If `session_name()` is undefined, `php81-session` is missing or FPM was not restarted after install. + +**CLI vs FPM:** `php` on the shell may be a different major version than `php-fpm81` (e.g. `php82` CLI + `php81-fpm`). Install `phpNN-session` for **both** SAPIs you use, or run cron with the matching binary: `php81 scripts/purge_remote_access.php`. Maintenance scripts skip sessions on CLI when the extension is absent (web/FPM still require it). + +## Quick start (SQLite, development) + +```bash +cd examples/crash_reporter/backend +cp config/config.example.php config/config.php +# For `php -S`, set 'base_path' => '' in config.php + +mkdir -p data storage +sqlite3 data/crashes.sqlite < sql/schema.sqlite.sql + +php -S 127.0.0.1:8080 -t public +``` + +Open http://127.0.0.1:8080/ — login **admin** / **admin**. + +## Google Analytics 4 (optional) + +In `config/config.php`: + +```php +'analytics' => [ + 'enabled' => true, + 'measurement_id' => 'G-XXXXXXXXXX', + 'debug' => false, +], +``` + +Loads `public/assets/js/analytics.js` from `layout.php` and `login.php`. The project hub at +`/app/androidcast_project/` uses the same script; configure `examples/app_hub/assets/analytics.config.js`. + +Events include `page_view`, `console_view` (home, tickets, reports, …), and hub `hub_nav_click` / `hub_open_docs`. + +## RBAC (phase 1 + admin UI) + +Multi-tenant foundation: + +- **Global roles** on `users.role`: `root`, `platform_admin` (legacy `admin` works), `viewer` +- **Companies** + **company_memberships** (`owner` / `admin` / `member`) — **slug admin** = `owner` or `admin` on a company (graphs company scope) +- **Privilege sets** on `company_memberships.permissions_json`: `remote_access_user`, `remote_access_admin`, `short_links_viewer`, `short_links_user` (extra grants for `member` rows) +- **Devices** auto-registered on upload; reports get `company_id` + `device_id` +- Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`) + +**Admin UI:** `?view=rbac` (nav **Access**) — company owner/admin and global admins. **Root only** can change global roles. API: `GET/POST …/api/rbac.php` (`panel`, `set_global_role`, `set_company_role`, `apply_privilege_set`). Smoke: `./scripts/test_rbac_api.sh`. + +Config (`config.php`): + +```php +'rbac' => [ + 'default_company_slug' => 'default', + 'default_company_id' => 1, +], +``` + +Permission checks: `Rbac::can('edit_tags', $companyId)` (used by `Auth::canEditTags()`). +Manual SQL (MariaDB, **as root** — `androidcast` has no `ALTER`): + +```sh +mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql +mysql -u root -p androidcast_crashes < sql/migrations/002_reports_company_columns.sql +mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql +``` + +Or from `backend/`: + +```sh +chmod +x scripts/migrate-003-tickets.sh +./scripts/migrate-003-tickets.sh +``` + +The PHP app user (`androidcast`) **cannot** create `tickets` on MariaDB — auto-migrate only works for SQLite dev. If Tickets shows *table missing*, run **003** as root once. + +Re-login after deploy so session includes company memberships. + +## UI languages + +JSON catalogs under `public/assets/i18n/` (`en.json`, `ru.json`) — same key style as Android `strings.xml`. +Default: English. User choice is stored in `localStorage` (`crash_console_lang`). Language selector is in the reports toolbar next to **Theme**, and in the nav on other pages (login included). + +Upload test payload: + +```bash +curl -X POST http://127.0.0.1:8080/api/upload.php \ + -H 'Content-Type: application/json' \ + -d @../sample_crash_java.json +``` + +**Gzip ingest** (Android sends `Content-Encoding: gzip`; plain JSON still works): + +```bash +./scripts/test_gzip_upload.sh +# BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_gzip_upload.sh +``` + +On the **BE nginx** upload `location`, enable gunzip so PHP receives plain JSON (see `nginx.vm.conf`): + +```nginx +gunzip on; +gunzip_types application/json; +``` + +PHP also decodes gzip if the header reaches FPM (`read_crash_upload_body()` in `bootstrap.php`). After deploy, sync `public/` + `src/` and reload nginx. + +## Production (nginx + PHP-FPM) + +**Deploy topology (FE/BE, ports, sshfs):** [docs/INFRA.md](../../../docs/INFRA.md). +**Canonical BE vhost for FE traffic:** `nginx.apps-port80.fragment` (`listen 80`). Port **8089** is not androidcast. + +0. Ensure PHP modules above are installed for the FPM SAPI (not only CLI `php -m`). +1. Deploy the git tree so this path exists on the VM (no symlinks required): + `.../androidcast_project/android_cast/examples/crash_reporter/backend/` +2. Point nginx `SCRIPT_FILENAME` at `.../backend/public/` (see `nginx.vm.conf`). Do not mix `proxy_*`, `try_files`, and broken alias/try_files combos in one block. +3. Set `base_path` in `config.php` to match the URL prefix exactly. +3. Choose DB: + - **SQLite (default):** `sqlite3 data/crashes.sqlite < sql/schema.sqlite.sql`, `db.driver` => `sqlite` + - **MariaDB (fresh, recommended on BE):** + ```sh + rc-service mariadb start + mysql -u root -p < sql/schema.mariadb.sql + # optional: CRASH_DB_PASS='…' ./scripts/init-mariadb.sh # creates app user + ``` + In `config/config.php`: `db.driver` => `mysql`, fill `db.mysql` (host `127.0.0.1`). + PHP auto-creates missing tables on first request if schema was not applied manually. +4. `chown -R www-data:www-data data storage` (SQLite only needs writable `data/`; MariaDB uses mysqld) +5. TLS terminate at nginx; restrict `/api/upload.php` by firewall if needed + +## Android app configuration + +In app-private `files/settings.json` (see `examples/settings.json`): + +```json +"crash": { + "enabled": "true", + "upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php", + "prune_after_upload": "true", + "upload_interval": "5m" +} +``` + +Enable **Send anonymous crash logs** in the app settings drawer. + +## Report JSON schema (v1) + +See `sample_crash_java.json`. Key fields: + +| Field | Purpose | +|-------|---------| +| `schema_version` | Always `1` for now | +| `report_id` | UUID from device | +| `generated_at_epoch_ms` | Device timestamp | +| `crash_type` | `java` or `native` | +| `fingerprint` | Grouping hash (top frames) | +| `device` | Manufacturer, model, SDK, ABIs | +| `app` | Package, version | +| `java` | Exception + `stack_frames[]` | +| `native` | Signal + `backtrace[]` | + +Backend stores full JSON in `reports.payload_json` for faithful replay in the UI. + +## Tickets (tasks / QA) + +Web UI: `/?view=tickets` — list columns **Issue**, **Opened**, **App/OS**, **Rating**, **Tags**. +Detail: `/?view=ticket&id=N` — same layout family as crash reports (tags block, device/app/timing cards, collapsible raw JSON). Editable when you are **owner** or company **admin+**: caption, summary, body, owner, verified by, rating, tags. + +**Upload API** (no session; same as crashes): + +```http +POST /api/ticket_upload.php +Content-Type: application/json +``` + +```json +{ + "schema_version": 1, + "ingest_kind": "ticket", + "ticket_id": "unique-id", + "title": "Short caption", + "brief": "One-line summary for the list", + "body": "Full description", + "opened_at_epoch_ms": 1710000000000, + "rating": 3, + "owner": "admin", + "tags": [ + {"id": "ticket", "label": "ticket", "bg": "#6366f1"}, + {"id": "open", "label": "open", "bg": "#22c55e"} + ], + "device": { "model": "BL6000 Pro", "sdk_int": 29, "release": "10" }, + "app": { "package": "com.foxx.androidcast", "version_name": "0.1.0" } +} +``` + +Tags must include **`ticket`** plus at least one status tag (`open`, `in-progress`, `closed`, `rejected`, `confirmed`, `postponed`, `urgent`). + +**Ingest alpha smoke results** from repo `tmp/alpha_smoke_round1.txt`: + +```bash +php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/androidcast_project/crashes +``` + +MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql` or `./scripts/migrate-003-tickets.sh`. Fresh installs: included in `sql/schema.mariadb.sql` and `scripts/init-mariadb.sh`. + +**Workflow / assignees / comments (004):** `mysql -u root -p androidcast_crashes < sql/migrations/004_ticket_workflow.sql` — see [docs/TICKETS_ROADMAP.md](../../../docs/TICKETS_ROADMAP.md). + +**Attachments (005):** `mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql` — files + external links (Google URLs auto-labeled). + +## Graphs dashboard (`/graphs`) + +Custom Grafana-style BE dashboards (direct MariaDB reads, no ETL): + +- `https://apps.f0xx.org/app/androidcast_project/graphs/` +- Same PHP session/RBAC as crashes, tickets, and builder (`session_cookie_path` `/app/androidcast_project`). +- Hub card: **AndroidCast graphs** (quick links to crashes, tickets, builder, git). + +**MariaDB (existing server):** run once as root: + +```sh +mysql -u root -p androidcast_crashes < sql/migrations/006_graph_sessions.sql +``` + +Table auto-creates on first ingest if missing (SQLite dev); production MariaDB should use migration **006**. + +APIs: + +- `GET …/graphs/api/graphs.php?days=7` — role-scoped metrics (`user`, `slug_admin`, `platform_admin`) +- `POST …/graphs/api/graph_upload.php` — device/session ingest (`ingest_kind=graph_session`, no session cookie) + +Smoke test: + +```bash +./scripts/test_graphs_api.sh +BASE=http://localhost:8082/app/androidcast_project/crashes ./scripts/test_graphs_api.sh +``` + +Fake data: + +```bash +php scripts/generate_fake_graph_sessions.php --count=240 --days=30 +php scripts/upload_fake_graph_sessions.php --url=https://apps.f0xx.org/app/androidcast_project/graphs +``` + +Payload (`schema_version=1`): `session_id`, `company_id`, `user_id`, `device_id`, `app_version`, +`sdk_int`, timing, `completed`, `direction`, `transport`, `avg_kbps`, `recv_kbps`, +`packet_loss_pct`, `ntp_source`, `ntp_correction_s`, `install_source`, optional `meta`. + +NTP heartbeat (`POST …/api/heartbeat.php`, `type=ntp`) returns `time_source` and `ntp_correction_s` +for clients that sync clocks before upload. + +## Remote access (WireGuard v1) + +On-demand debug tunnels — **same crashes vhost**, no new nginx HTTP locations. Full notes: [docs/REMOTE_ACCESS_IMPL.md](../../../docs/REMOTE_ACCESS_IMPL.md). + +- Dashboard: `https://apps.f0xx.org/app/androidcast_project/crashes/?view=remote_access` +- Hub card: **Remote access** +- Device poll: `POST …/api/heartbeat.php` with `heartbeat.type = ra` +- Admin API: `…/api/remote_access.php` (session cookie + RBAC) + +**MariaDB (existing server):** run once as root if not already applied: + +```sh +mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql +``` + +**Production `config.php`** (not in git) — set `remote_access.wg_server_public_key` from `wg show wg0 public-key`, `require_wg_tools => true`, and `wg_endpoint` (public UDP DNAT hostname). See `config.example.php`. + +**Cron:** hourly purge — on Alpine use **php81** if default `php` is 8.x without session (see § Requirements): + +```cron +0 * * * * cd /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend && php81 scripts/purge_remote_access.php --hours=24 +# Prunes orphan wg0 peers each run; manual: php81 scripts/sync_wg_peers.php --dry-run +``` + +Smoke / verify: + +```bash +chmod +x scripts/ra_*.sh scripts/test_remote_access_api.sh scripts/verify_remote_access_prod.sh +./scripts/test_rbac_api.sh +./scripts/test_remote_access_api.sh +BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh +./scripts/verify_remote_access_prod.sh +BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh # device + operator E2E (jq) +./scripts/ra_device_sim.sh poll --until connect # after open session in UI +``` + +**CLI validation (Linux device simulator):** [docs/REMOTE_ACCESS_VALIDATION.md](../../../docs/REMOTE_ACCESS_VALIDATION.md). + +Android: Developer settings → **Remote access** → **RSSH** (alpha target; WireGuard lab-only). RSSH avoids VPN permission. Polls BE every 1–7 min while enabled. See [REMOTE_ACCESS_IMPL.md § VPN consent](../../../docs/REMOTE_ACCESS_IMPL.md#android-vpn-consent-wireguard) and [ROADMAP § RSSH](../../../docs/ROADMAP.md#remote-access-alpha--rssh). + +## Short links (URL shortener admin) + +Operator UI for `https://s.f0xx.org` — mint/revoke bearer tokens, list links, shorten from the console. SPEC §20. + +- Dashboard: `…/crashes/?view=short_links` (RBAC: `short_links_view`; company owner/admin or privilege sets `short_links_viewer` / `short_links_user`) +- Hub nav + console home card: **Short links** +- Admin API: `…/api/short_links.php` (session cookie — not on `s.f0xx.org`) +- Permanent links: mint signer key in UI, bearer with **Permanent links** cap, shorten with **Permanent** + signer +- Public shorten API stays on `https://s.f0xx.org/api/v1/shorten` (bearer token only) + +**Production `config.php`:** set `url_shortener.enabled => true` and `url_shortener.db.mysql` to the same `androidcast` user + `url_shortener` database as [backend/url-shortener/config/config.php](../../../backend/url-shortener/config/config.example.php). See `config.example.php`. + +Smoke: `./scripts/test_short_links_api.sh` (needs admin/owner session cookie). Purge expired links: platform admin only (UI button or `backend/url-shortener/scripts/purge_url_shortener.php` cron). + +## Third-party licenses (BE / landing) + +- [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) — server stack, optional WireGuard tools, link to mobile app list. +- Mobile APK list: in-app **Licenses** button → `app/src/main/assets/licenses/combined.html`. +- Suggested public URL after sync: `…/crashes/THIRD_PARTY_LICENSES.md` (or HTML excerpt on hub legal page). + +## Default accounts + +| User | Password | Role | +|------|----------|------| +| admin | admin | root (all permissions) | + +Change the admin password in production (update `users.password_hash`). diff --git a/sim/cluster0/lab-seeds/backend/THIRD_PARTY_LICENSES.md b/sim/cluster0/lab-seeds/backend/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..9628cbe --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/THIRD_PARTY_LICENSES.md @@ -0,0 +1,46 @@ +# Third-party licenses — crash reporter backend & apps.f0xx.org stack + +Prepared for landing / legal disclosure. **Verify against production deploy** before publishing. + +## Android Cast application code (PHP/JS in this tree) + +- **License:** same BSD-style terms as the Android Cast mobile app (see repo root / mobile `licenses/combined.html`). +- **Scope:** `examples/crash_reporter/backend/` PHP, views, static JS/CSS under `public/assets/`. + +## Runtime infrastructure (operator-installed) + +| Component | Typical license | Notes | +|-----------|-----------------|--------| +| Alpine Linux | Various (GPL-2.0 kernel; mixed userspace) | BE VM base | +| nginx | BSD-2-Clause | Reverse proxy / static | +| PHP 8.x | PHP License v3.01 | FPM | +| MariaDB / SQLite | GPL-2.0 (MariaDB); public domain (SQLite) | DB engine | +| OpenSSH / OpenSSL | Various | Admin access, TLS | + +These are **server-side**; not bundled in the mobile APK. + +## Optional remote-access tooling + +| Component | License | When used | +|-----------|---------|-----------| +| **wireguard-tools** (`wg`, `wg-quick`) | GPLv2 (userspace) | BE dynamic peer provisioning (`WireGuardPeerProvisioner.php`) | +| Linux **WireGuard** kernel module | GPLv2 | UDP tunnel termination on BE | + +Mobile WireGuard mode uses outbound tunnel from device; app embeds `wireguard-android` tunnel (`third-party/wireguard-android`, Apache-2.0) — see app `licenses/combined.html`. + +## Planned: reverse SSH (RSSH) + +**Alpha essential** (owner agreement). Device opt-in via existing `heartbeat.php` RA flow; outbound SSH reverse tunnel to bastion; no `VpnService`. Stack may include OpenSSH-compatible client on device and `sshd`/SFTP on bastion — licenses TBD (typically OpenSSH license + OpenSSL). + +## Frontend assets (this backend) + +- **Vanilla JavaScript/CSS** in `public/assets/` — project-authored unless noted. +- No npm bundle in production tree at time of writing; if CDN libraries are added later, list them here. + +## Publishing on landing + +Suggested public URL after deploy sync: + +`https://apps.f0xx.org/app/androidcast_project/crashes/THIRD_PARTY_LICENSES.md` + +(or render an HTML excerpt on the hub legal page linking mobile + BE lists). diff --git a/sim/cluster0/lab-seeds/backend/config/config.example.php b/sim/cluster0/lab-seeds/backend/config/config.example.php new file mode 100644 index 0000000..a994f44 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/config/config.example.php @@ -0,0 +1,124 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 23 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 fcd4a89ef4201736923d0e7e1ad88868bba7e39eadb9fcf0ffb9d1ba7efecd12 + */ +return [ + 'app_name' => 'Android Cast', + // Console (issues/tickets/…) — production path + 'project_base_path' => '/app/androidcast_project', + 'base_path' => '/app/androidcast_project/issues', + // Legacy alias kept for redirects/nginx; do not use in new links + 'legacy_base_path' => '/app/androidcast_project/crashes', + 'db' => [ + // sqlite = default file DB; mysql = MariaDB/MySQL (recommended on BE for concurrency) + 'driver' => 'sqlite', // sqlite | mysql | mariadb (alias) + 'sqlite_path' => __DIR__ . '/../data/crashes.sqlite', + 'mysql' => [ + // Alpine: if TCP fails with "Connection refused", set socket (see /etc/my.cnf.d/*.cnf) + 'host' => '127.0.0.1', + 'port' => 3306, + 'socket' => '/run/mysqld/mysqld.sock', // leave '' to use host+port only + 'database' => 'androidcast_crashes', + 'username' => 'androidcast', + 'password' => 'change-me', + 'charset' => 'utf8mb4', + ], + ], + 'session_name' => 'ac_crash_sess', + // Shared login cookie across crashes, build, and other consoles under this path + 'session_cookie_path' => '/app/androidcast_project', + // When true, upload API may include a short error hint (do not enable on public prod long-term) + 'debug' => false, + // Phase 1 RBAC: anonymous uploads and legacy rows attach to this company + 'rbac' => [ + 'default_company_slug' => 'default', + 'default_company_id' => 1, + ], + // Google Analytics 4 (hub + crash console share public/assets/js/analytics.js) + 'analytics' => [ + 'enabled' => false, + 'measurement_id' => '', // e.g. G-XXXXXXXXXX + 'debug' => false, + ], + // On-demand remote access (WireGuard v1) — see docs/REMOTE_ACCESS_IMPL.md + 'remote_access' => [ + // Public UDP endpoint (FE DNAT → BE wg0 ListenPort) + 'wg_endpoint' => 'ra.apps.f0xx.org:45340', + // wg show wg0 public-key on BE — REQUIRED for production connect + 'wg_server_public_key' => 'EV2nnwHH7xe3jA5J46Rjtl8ao+ybZinibKg2Hy83GBw=', + 'wg_interface' => 'wg0', + // Client / server addresses (must match wg0 Address subnet on BE) + 'wg_server_allowed_ips' => '172.200.2.1/32', + 'wg_client_ip_prefix' => '172.200.2.', + 'wg_client_ip_min' => 10, + 'wg_client_ip_max' => 250, + // Prefix for `wg set` when PHP-FPM runs as nobody (see examples/wireguard/alpine-be-sudoers-wg) + 'wg_set_prefix' => 'sudo -n ', + 'provision_peers' => true, + 'require_wg_tools' => true, + 'session_ttl_s' => 3600, + 'min_poll_interval_s' => 45, + // Reverse SSH (alpha): outbound -R from device; no VpnService on phone + 'rssh' => [ + 'bastion_host' => 'ra.apps.f0xx.org', + 'bastion_port' => 443, + 'username_prefix' => 'ra-', + 'local_forward_port' => 8022, + 'remote_port_min' => 18000, + 'remote_port_max' => 18999, + // Lab/prod: create ephemeral Match User ra-* on BE sshd (see scripts/rssh_bastion_user.sh) + 'provision_users' => false, + 'provision_script' => '', // default: backend/scripts/rssh_bastion_user.sh + ], + ], + // Outbound mail (registration verify, password reset) — see docs/20260607-2FA-email-mobile-auth-flow.md + 'auth' => [ + // 32+ char secret for encrypting TOTP seeds + hashing auth attempts + 'encryption_key' => '', + 'max_attempts' => 8, + 'lockout_window_minutes' => 15, + ], + // SFU / WebRTC relay (post-alpha) — hidden until owner spec; see examples/sfu/README.md + 'sfu' => [ + 'enabled' => false, + 'signaling_url' => '', // e.g. wss://apps.f0xx.org/sfu/signal + 'janus_url' => '', // lab only — port 8089 is a separate stack (INFRA.md) + ], + 'mail' => [ + 'transport' => 'smtp', // smtp | sendmail + // Alpha: use apex if apps.f0xx.org has CNAME (no MX on subdomain) — see tmp/20260608_help_request.md + 'from' => 'Android Cast Issues ', + 'reply_to' => 'info@apps.f0xx.org', + 'smtp' => [ + 'host' => '127.0.0.1', + 'port' => 587, + 'encryption' => 'tls', // tls | ssl | '' + 'username' => '', + 'password' => '', // set in prod config.php only — not in git + ], + ], + // URL shortener admin (hub Short links card) — MariaDB url_shortener on BE + 'url_shortener' => [ + 'enabled' => false, + 'public_base' => 'https://s.f0xx.org', + 'db' => [ + 'mysql' => [ + 'host' => '127.0.0.1', + 'port' => 3306, + 'socket' => '/run/mysqld/mysqld.sock', + 'database' => 'url_shortener', + 'username' => 'androidcast', + 'password' => 'change-me', + 'charset' => 'utf8mb4', + ], + ], + ], +]; diff --git a/sim/cluster0/lab-seeds/backend/config/config.php.alpine b/sim/cluster0/lab-seeds/backend/config/config.php.alpine new file mode 100644 index 0000000..fc1cbfe --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/config/config.php.alpine @@ -0,0 +1,59 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 22 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 8b7149a4cd8a0f7d13ee8a22535c3697e919e35b9fe5bb8a80764769cc6c6853 + */ +return [ + 'app_name' => 'Android Cast Crashes', + 'base_path' => '/app/androidcast_project/crashes', + 'db' => [ + 'driver' => 'mysql', // sqlite | mysql + 'sqlite_path' => __DIR__ . '/../data/crashes.sqlite', + 'mysql' => [ + 'host' => '127.0.0.1', + 'port' => 3306, + 'socket' => '/run/mysqld/mysqld.sock', + 'database' => 'androidcast_crashes', + 'username' => 'androidcast', + 'password' => 'androidcastrootowned', + 'charset' => 'utf8mb4', + ], + ], + 'session_name' => 'ac_crash_sess', + // Shared login cookie across crashes, build, and other consoles under this path + 'session_cookie_path' => '/app/androidcast_project', + // When true, upload API may include a short error hint (do not enable on public prod long-term) + 'debug' => true, + // Phase 1 RBAC: anonymous uploads and legacy rows attach to this company + 'rbac' => [ + 'default_company_slug' => 'default', + 'default_company_id' => 1, + ], + // Google Analytics 4 (hub + crash console share public/assets/js/analytics.js) + 'analytics' => [ + 'enabled' => true, + 'measurement_id' => 'G-8RKGWGQ52P', // e.g. G-XXXXXXXXXX + 'debug' => false, + ], + // On-demand remote access (WireGuard v1) — see docs/REMOTE_ACCESS_IMPL.md + 'remote_access' => [ + // Public UDP endpoint shown to devices (FE DNAT → BE wg0) + 'wg_endpoint' => 'ra.apps.f0xx.org:45340', + // Server WG public key (wg show wg0 public-key on BE) + 'wg_server_public_key' => 'EV2nnwHH7xe3jA5J46Rjtl8ao+ybZinibKg2Hy83GBw=', + 'wg_interface' => 'wg0', + // Run `wg set wg0 peer …` when device receives connect (requires wireguard-tools on BE) + 'provision_peers' => true, + // Reject connect if wg genkey/pubkey unavailable (set true on production BE) + 'require_wg_tools' => true, + 'session_ttl_s' => 3600, + 'min_poll_interval_s' => 45, + ], +]; diff --git a/sim/cluster0/lab-seeds/backend/nginx.apps-builder.frag b/sim/cluster0/lab-seeds/backend/nginx.apps-builder.frag new file mode 100644 index 0000000..0a6b532 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.apps-builder.frag @@ -0,0 +1,17 @@ +location = /app/androidcast_project/build { + return 301 /app/androidcast_project/build/; +} + +location ^~ /app/androidcast_project/build/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/assets/; +} + +location ^~ /app/androidcast_project/build/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/build/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 512m; + fastcgi_read_timeout 1800s; +} diff --git a/sim/cluster0/lab-seeds/backend/nginx.apps-port80.fragment b/sim/cluster0/lab-seeds/backend/nginx.apps-port80.fragment new file mode 100644 index 0000000..7945465 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.apps-port80.fragment @@ -0,0 +1,134 @@ +# Full androidcast vhost for Alpine BE — listen 80 only. +# FE (apps.f0xx.org) proxies here: proxy_pass http://artc0.intra.raptor.org:80; +# Port 8089 is a different service (e.g. Janus) — not this vhost. + +server { + listen 80; + + # OTA v0 static tree — devices fetch https://apps.f0xx.org/v0/ota/channel/stable.json + # Populate: builder auto_deploy, or rsync out/ota/v0/ → .../ota-artifacts/v0/ + location ^~ /v0/ota/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts/v0/ota/; + add_header Cache-Control "public, max-age=60"; + } + + location = /app/androidcast_project { + return 301 /app/androidcast_project/; + } + + location = /app/androidcast_project/index.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/index.php; + } + + location = /app/androidcast_project/ { + rewrite ^ /app/androidcast_project/index.php last; + } + + location /app/androidcast_project/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/; + index index.php index.html; + try_files $uri $uri/ =404; + } + + location = /app/androidcast_project/issues { + return 301 /app/androidcast_project/issues/; + } + + location = /app/androidcast_project/crashes { + return 301 /app/androidcast_project/issues/; + } + + location ~ ^/app/androidcast_project/crashes(/.*)?$ { + return 301 /app/androidcast_project/issues$1; + } + + location ~ ^/app/androidcast_project/(login|logout|register|two-factor|verify-email)(/|$) { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/index.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location ^~ /app/androidcast_project/issues/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/issues/api/upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/api/upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/issues/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/graphs { + return 301 /app/androidcast_project/graphs/; + } + + location ^~ /app/androidcast_project/graphs/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/graphs/api/graphs.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/graphs/api/graph_upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/graphs/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/build { + return 301 /app/androidcast_project/build/; + } + + location ^~ /app/androidcast_project/build/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/assets/; + } + + location ^~ /app/androidcast_project/build/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/build/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 512m; + fastcgi_read_timeout 1800s; + } + + location ~ \.php$ { + include /etc/nginx/fastcgi.conf; + fastcgi_pass unix:/run/php-fpm.socket; + } +} diff --git a/sim/cluster0/lab-seeds/backend/nginx.apps.conf.fragment b/sim/cluster0/lab-seeds/backend/nginx.apps.conf.fragment new file mode 100644 index 0000000..36312c7 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.apps.conf.fragment @@ -0,0 +1,117 @@ +# Paste into the listen 443 (and 80) server {} in /etc/nginx/conf.d/apps.conf +# Replaces old per-URL blocks + any backend symlink paths. + + location ^~ /v0/ota/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts/v0/ota/; + add_header Cache-Control "public, max-age=60"; + } + + location = /app/androidcast_project/crashes { + return 301 /app/androidcast_project/crashes/; + } + + location = /app/androidcast_project/graphs { + return 301 /app/androidcast_project/graphs/; + } + + location = /app/androidcast_project { + return 301 /app/androidcast_project/; + } + + # Hub — see nginx.apps-port80.fragment (index.php + shared footer). + location = /app/androidcast_project/index.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/index.php; + } + + location = /app/androidcast_project/ { + rewrite ^ /app/androidcast_project/index.php last; + } + + location /app/androidcast_project/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/; + index index.php index.html; + try_files $uri $uri/ =404; + } + + location ^~ /app/androidcast_project/crashes/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/crashes/api/upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/crashes/api/diag.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/diag.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/diag.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/reports.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/reports.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/reports.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/report_viewed.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/report_viewed.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/report_viewed.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/report_tags.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/report_tags.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/report_tags.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location ^~ /app/androidcast_project/crashes/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/graphs/api/graphs.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/graphs/api/graph_upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/graphs/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } diff --git a/sim/cluster0/lab-seeds/backend/nginx.conf b/sim/cluster0/lab-seeds/backend/nginx.conf new file mode 100644 index 0000000..2ee1ef1 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.conf @@ -0,0 +1,42 @@ +# Example nginx site for https://f0xx.org/app/androidcast_project/crashes/ +# Adjust paths and upstream socket to your host. + +server { + listen 443 ssl http2; + server_name f0xx.org; + + # ssl_certificate /etc/letsencrypt/live/f0xx.org/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/f0xx.org/privkey.pem; + + root /var/www/androidcast_crashes/public; + index index.php; + + location /app/androidcast_project/crashes/ { + alias /var/www/androidcast_crashes/public/; + try_files $uri $uri/ /app/androidcast_project/crashes/index.php?$query_string; + } + + location ~ ^/app/androidcast_project/crashes/api/upload\.php$ { + gunzip on; + gunzip_types application/json; + alias /var/www/androidcast_crashes/public/api/upload.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/androidcast_crashes/public/api/upload.php; + fastcgi_pass unix:/run/php-fpm/www.sock; + client_max_body_size 8m; + } + + location ~ ^/app/androidcast_project/crashes/.+\.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $request_filename; + fastcgi_pass unix:/run/php-fpm/www.sock; + } + + location ~ ^/app/androidcast_project/crashes/ { + try_files $uri /app/androidcast_project/crashes/index.php?$query_string; + } + + location ~ /\. { + deny all; + } +} diff --git a/sim/cluster0/lab-seeds/backend/nginx.fe-apps.conf.fragment b/sim/cluster0/lab-seeds/backend/nginx.fe-apps.conf.fragment new file mode 100644 index 0000000..7cebdb0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.fe-apps.conf.fragment @@ -0,0 +1,3 @@ +# DEPRECATED — use nginx/fe/apps.conf (full apps.f0xx.org vhost). +# nginx.conf should contain only: include /etc/nginx/apps.conf; +# See patches/fe-nginx-APPLY.md and scripts/generate-fe-nginx-patch.sh diff --git a/sim/cluster0/lab-seeds/backend/nginx.fe-apps.f0xx.org.snippet b/sim/cluster0/lab-seeds/backend/nginx.fe-apps.f0xx.org.snippet new file mode 100644 index 0000000..d9fe6cc --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.fe-apps.f0xx.org.snippet @@ -0,0 +1,30 @@ +# FE apps.f0xx.org — replace hub/crashes upstream blocks (use BE port 80, preserve URI). +# OTA at /v0/ota/ must proxy to BE :80 even when hub uses :443 — see nginx.fe-ota.snippet +# and scripts/install-fe-ota-nginx.sh (public URL 400 until this is applied on FE). +# +# File: tmp/FE_gentoo/etc/nginx/nginx.conf (inside server { listen 443; server_name apps.f0xx.org; }) +# +# WRONG (causes browser to download index.php as application/octet-stream): +# proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/; +# BE :443 serves static files; hub must hit BE :80 (PHP-FPM) like location / already does. + + location /app/androidcast_project/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /app/androidcast_project/crashes/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + +# apps.conf build block must stay: +# proxy_pass http://artc0.intra.raptor.org:80; (no trailing slash) diff --git a/sim/cluster0/lab-seeds/backend/nginx.fe-build-inline.snippet b/sim/cluster0/lab-seeds/backend/nginx.fe-build-inline.snippet new file mode 100644 index 0000000..1559ffb --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.fe-build-inline.snippet @@ -0,0 +1,13 @@ +# Paste into Gentoo FE /etc/nginx/nginx.conf inside server { listen 443; server_name apps.f0xx.org; } +# Place immediately AFTER the crashes location block (same style as hub + crashes). + + location /app/androidcast_project/build/ { + proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/build/; + } + +# Then REMOVE or comment out the build location in /etc/nginx/apps.conf (avoid duplicate/conflict). +# Reload: nginx -t && rc-service nginx reload + +# Verify from FE: +# curl -sSI -H 'Host: apps.f0xx.org' http://127.0.0.1/app/androidcast_project/build/ | head -5 +# Expect 302 + x-powered-by: PHP (after BE fragment is fixed too). diff --git a/sim/cluster0/lab-seeds/backend/nginx.fe-ota.snippet b/sim/cluster0/lab-seeds/backend/nginx.fe-ota.snippet new file mode 100644 index 0000000..12fb070 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.fe-ota.snippet @@ -0,0 +1,13 @@ +# FE apps.f0xx.org — OTA channel at site root (/v0/ota/…). +# BE serves files on listen 80; hub/crashes may use :443 upstream — OTA must hit :80. +# +# Paste inside server { listen 443 ssl; server_name apps.f0xx.org; } before androidcast blocks. + + location /v0/ota/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } diff --git a/sim/cluster0/lab-seeds/backend/nginx.vm.conf b/sim/cluster0/lab-seeds/backend/nginx.vm.conf new file mode 100644 index 0000000..c652555 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx.vm.conf @@ -0,0 +1,109 @@ +# Inner VM nginx — crash reporter (no symlinks). +# +# Canonical disk paths on Alpine BE: +# .../androidcast_project/android_cast/examples/crash_reporter/backend/public +# +# URL prefix (config.php base_path): +# /app/androidcast_project/crashes +# +# Use the same location blocks on listen 80 and 443 if the FE proxy uses :80. + +server { + listen 443 ssl; + server_name apps.f0xx.org artc0.intra.raptor.org; + + ssl_certificate "/etc/letsencrypt/archive/apps.f0xx.org/fullchain1.pem"; + ssl_certificate_key "/etc/letsencrypt/archive/apps.f0xx.org/privkey1.pem"; + + access_log /var/log/nginx/apps.intra.raptor.org.access_log main; + error_log /var/log/nginx/apps.intra.raptor.org.error_log warn; + + location ^~ /v0/ota/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts/v0/ota/; + add_header Cache-Control "public, max-age=60"; + } + + location = /app/androidcast_project/crashes { + return 301 /app/androidcast_project/crashes/; + } + + location = /app/androidcast_project/graphs { + return 301 /app/androidcast_project/graphs/; + } + + location = /app/androidcast_project { + return 301 /app/androidcast_project/; + } + + location = /app/androidcast_project/index.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/index.php; + } + + location = /app/androidcast_project/ { + rewrite ^ /app/androidcast_project/index.php last; + } + + location /app/androidcast_project/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/; + index index.php index.html; + try_files $uri $uri/ =404; + } + + location ^~ /app/androidcast_project/crashes/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/crashes/api/upload.php { + # Decompress gzip uploads from Android before PHP (needs ngx_http_gunzip_module). + gunzip on; + gunzip_types application/json; + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 8m; + } + + location ^~ /app/androidcast_project/crashes/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/graphs/api/graphs.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/graphs/api/graph_upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/graphs/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ~ /\. { + deny all; + } +} diff --git a/sim/cluster0/lab-seeds/backend/nginx/fe/apps.conf b/sim/cluster0/lab-seeds/backend/nginx/fe/apps.conf new file mode 100644 index 0000000..b2a8068 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx/fe/apps.conf @@ -0,0 +1,137 @@ +# apps.f0xx.org — Android Cast public vhost (FE Gentoo). +# Included from nginx.conf: include /etc/nginx/apps.conf; +# +# Upstream: BE nginx on artc0.intra.raptor.org:80 (hub PHP, crashes, graphs, build, OTA). +# Git browser: BE :3000 (Gitea). Do not proxy androidcast to BE :443 or :8089. +# +# Reload: nginx -t && rc-service nginx reload + +server { + listen 80; + server_name apps.f0xx.org; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name apps.f0xx.org; + + access_log /var/log/nginx/apps.intra.raptor.org.access_log main; + error_log /var/log/nginx/apps.intra.raptor.org.error_log info; + + ssl_certificate /etc/letsencrypt/live/apps.f0xx.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/apps.f0xx.org/privkey.pem; + + # --- OTA artifacts (BE static tree on listen 80) --- + location ^~ /v0/ota/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --- APK builder (long-running Docker builds) --- + location = /app/androidcast_project/build { + return 301 $scheme://$host/app/androidcast_project/build/; + } + location ^~ /app/androidcast_project/build/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 1800s; + proxy_send_timeout 1800s; + client_max_body_size 512m; + } + + # --- Crash / ticket console --- + location = /app/androidcast_project/crashes { + return 301 $scheme://$host/app/androidcast_project/crashes/; + } + location ^~ /app/androidcast_project/crashes/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 16m; + } + + # --- Graphs dashboard --- + location = /app/androidcast_project/graphs { + return 301 $scheme://$host/app/androidcast_project/graphs/; + } + location ^~ /app/androidcast_project/graphs/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --- Hub landing (PHP index) --- + location = /app/androidcast_project { + return 301 $scheme://$host/app/androidcast_project/; + } + location ^~ /app/androidcast_project/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --- Gitea web UI --- + location = /app/androidcast_project/git { + return 301 $scheme://$host/app/androidcast_project/git/; + } + location ^~ /app/androidcast_project/git/ { + proxy_pass http://artc0.intra.raptor.org:3000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 1800s; + proxy_send_timeout 1800s; + client_max_body_size 512m; + } + + # --- Default: anything else on this vhost → BE :80 --- + location / { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_read_timeout 180s; + proxy_buffers 16 16m; + proxy_buffer_size 16m; + client_body_buffer_size 16m; + client_max_body_size 16m; + proxy_buffering off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_cache_bypass $http_upgrade; + } + + location ~ /\.ht { + deny all; + } +} diff --git a/sim/cluster0/lab-seeds/backend/nginx/rssh-bastion-stream.conf.example b/sim/cluster0/lab-seeds/backend/nginx/rssh-bastion-stream.conf.example new file mode 100644 index 0000000..5e5e4ae --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/nginx/rssh-bastion-stream.conf.example @@ -0,0 +1,20 @@ +# FE nginx stream — TCP proxy to BE sshd for RSSH bastion (ra.apps.f0xx.org:443). +# Install on FE (Gentoo); BE runs openssh with Match User ra-* (see sshd_config.d/ra.conf). +# +# *** DO NOT install under BE /etc/nginx/stream.d/ *** +# BE already uses listen 443 ssl in conf.d/apps.conf (http). Stream listen 443 here +# causes: nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address in use) +# +# Include from nginx.conf: +# include /etc/nginx/stream.d/rssh-bastion.conf; + +upstream rssh_bastion_be { + server 10.7.16.128:22; # artc0 / alpine-be — adjust for cluster/lab +} + +server { + listen 443; + proxy_pass rssh_bastion_be; + proxy_connect_timeout 30s; + proxy_timeout 24h; +} diff --git a/sim/cluster0/lab-seeds/backend/patches/fe-nginx-APPLY.md b/sim/cluster0/lab-seeds/backend/patches/fe-nginx-APPLY.md new file mode 100644 index 0000000..50dd783 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/patches/fe-nginx-APPLY.md @@ -0,0 +1,42 @@ +# FE nginx consolidation — review & apply + +## What changes + +- **`/etc/nginx/apps.conf`** becomes the full `apps.f0xx.org` vhost (HTTP redirect + HTTPS server + all Android Cast locations). +- **`/etc/nginx/nginx.conf`** drops inline `apps.f0xx.org` server blocks; adds one `include /etc/nginx/apps.conf;` at `http` level. + +## New / consolidated paths in apps.conf + +| Path | Upstream | +|------|----------| +| `/v0/ota/` | BE :80 (OTA) | +| `/app/androidcast_project/build/` | BE :80 | +| `/app/androidcast_project/crashes/` | BE :80 | +| `/app/androidcast_project/graphs/` | BE :80 (explicit) | +| `/app/androidcast_project/` | BE :80 (hub) | +| `/app/androidcast_project/git/` | BE :3000 (Gitea) | +| `/` (default) | BE :80 | + +Canonical template: `examples/crash_reporter/backend/nginx/fe/apps.conf` + +## Apply (on f0xx-monstro) + +```bash +cd /etc/nginx +cp -a apps.conf "apps.conf.bak.$(date +%Y%m%d)" +cp -a nginx.conf "nginx.conf.bak.$(date +%Y%m%d)" +# copy template from repo sync, or patch: +# patch -p1 < .../fe-nginx-apps-consolidation.patch +cp /path/to/android_cast/examples/crash_reporter/backend/nginx/fe/apps.conf apps.conf +# edit nginx.conf: replace apps.f0xx.org server {…} blocks with: +# include /etc/nginx/apps.conf; +nginx -t && rc-service nginx reload +``` + +## Verify + +```bash +curl -sS -o /dev/null -w 'hub %{http_code}\n' https://apps.f0xx.org/app/androidcast_project/ +curl -sS -o /dev/null -w 'ota %{http_code}\n' https://apps.f0xx.org/v0/ota/channel/stable.json +curl -sS -o /dev/null -w 'build %{http_code}\n' https://apps.f0xx.org/app/androidcast_project/build/ +``` diff --git a/sim/cluster0/lab-seeds/backend/patches/fe-nginx-apps-consolidation.patch b/sim/cluster0/lab-seeds/backend/patches/fe-nginx-apps-consolidation.patch new file mode 100644 index 0000000..586f53c --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/patches/fe-nginx-apps-consolidation.patch @@ -0,0 +1,271 @@ +--- a/etc/nginx/apps.conf 2026-06-10 16:57:53.240415810 +0200 ++++ b/etc/nginx/apps.conf 2026-06-10 16:57:53.246704804 +0200 +@@ -1,23 +1,137 @@ +-# location /app/androidcast_project/build/ { +-# proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/build/; +-# } +- +- # Redirect without trailing slash +- location = /app/androidcast_project/build { +- return 301 $scheme://$host/app/androidcast_project/build/; +- } +- location /app/androidcast_project/build/ { +- proxy_pass http://artc0.intra.raptor.org:80; +- proxy_http_version 1.1; +- proxy_set_header Host $host; +- proxy_set_header X-Real-IP $remote_addr; +- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +- proxy_set_header X-Forwarded-Proto $scheme; +- proxy_set_header X-Forwarded-Host $host; +- proxy_set_header Upgrade $http_upgrade; +- proxy_set_header Connection $connection_upgrade; +- proxy_read_timeout 1800s; +- proxy_send_timeout 1800s; +- client_max_body_size 512m; +- } ++# apps.f0xx.org — Android Cast public vhost (FE Gentoo). ++# Included from nginx.conf: include /etc/nginx/apps.conf; ++# ++# Upstream: BE nginx on artc0.intra.raptor.org:80 (hub PHP, crashes, graphs, build, OTA). ++# Git browser: BE :3000 (Gitea). Do not proxy androidcast to BE :443 or :8089. ++# ++# Reload: nginx -t && rc-service nginx reload + ++server { ++ listen 80; ++ server_name apps.f0xx.org; ++ return 301 https://$host$request_uri; ++} ++ ++server { ++ listen 443 ssl http2; ++ server_name apps.f0xx.org; ++ ++ access_log /var/log/nginx/apps.intra.raptor.org.access_log main; ++ error_log /var/log/nginx/apps.intra.raptor.org.error_log info; ++ ++ ssl_certificate /etc/letsencrypt/live/apps.f0xx.org/fullchain.pem; ++ ssl_certificate_key /etc/letsencrypt/live/apps.f0xx.org/privkey.pem; ++ ++ # --- OTA artifacts (BE static tree on listen 80) --- ++ location ^~ /v0/ota/ { ++ proxy_pass http://artc0.intra.raptor.org:80; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ } ++ ++ # --- APK builder (long-running Docker builds) --- ++ location = /app/androidcast_project/build { ++ return 301 $scheme://$host/app/androidcast_project/build/; ++ } ++ location ^~ /app/androidcast_project/build/ { ++ proxy_pass http://artc0.intra.raptor.org:80; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ proxy_set_header X-Forwarded-Host $host; ++ proxy_set_header Upgrade $http_upgrade; ++ proxy_set_header Connection $connection_upgrade; ++ proxy_read_timeout 1800s; ++ proxy_send_timeout 1800s; ++ client_max_body_size 512m; ++ } ++ ++ # --- Crash / ticket console --- ++ location = /app/androidcast_project/crashes { ++ return 301 $scheme://$host/app/androidcast_project/crashes/; ++ } ++ location ^~ /app/androidcast_project/crashes/ { ++ proxy_pass http://artc0.intra.raptor.org:80; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ client_max_body_size 16m; ++ } ++ ++ # --- Graphs dashboard --- ++ location = /app/androidcast_project/graphs { ++ return 301 $scheme://$host/app/androidcast_project/graphs/; ++ } ++ location ^~ /app/androidcast_project/graphs/ { ++ proxy_pass http://artc0.intra.raptor.org:80; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ } ++ ++ # --- Hub landing (PHP index) --- ++ location = /app/androidcast_project { ++ return 301 $scheme://$host/app/androidcast_project/; ++ } ++ location ^~ /app/androidcast_project/ { ++ proxy_pass http://artc0.intra.raptor.org:80; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ } ++ ++ # --- Gitea web UI --- ++ location = /app/androidcast_project/git { ++ return 301 $scheme://$host/app/androidcast_project/git/; ++ } ++ location ^~ /app/androidcast_project/git/ { ++ proxy_pass http://artc0.intra.raptor.org:3000/; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ proxy_set_header X-Forwarded-Host $host; ++ proxy_set_header Upgrade $http_upgrade; ++ proxy_set_header Connection $connection_upgrade; ++ proxy_read_timeout 1800s; ++ proxy_send_timeout 1800s; ++ client_max_body_size 512m; ++ } ++ ++ # --- Default: anything else on this vhost → BE :80 --- ++ location / { ++ proxy_pass http://artc0.intra.raptor.org:80; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ proxy_connect_timeout 120s; ++ proxy_send_timeout 120s; ++ proxy_read_timeout 180s; ++ proxy_buffers 16 16m; ++ proxy_buffer_size 16m; ++ client_body_buffer_size 16m; ++ client_max_body_size 16m; ++ proxy_buffering off; ++ proxy_set_header Upgrade $http_upgrade; ++ proxy_set_header Connection $connection_upgrade; ++ proxy_cache_bypass $http_upgrade; ++ } ++ ++ location ~ /\.ht { ++ deny all; ++ } ++} +--- a/etc/nginx/nginx.conf 2026-06-10 16:57:52.277069336 +0200 ++++ b/etc/nginx/nginx.conf 2026-06-10 16:57:53.277208423 +0200 +@@ -918,104 +918,8 @@ + + + ## ------------------------------------------------------------------------------- ++## apps.f0xx.org — Android Cast (hub, crashes, graphs, build, OTA, git) ++ include /etc/nginx/apps.conf; + +- server { +- listen 80; +- server_name apps.f0xx.org; +- location / { +- rewrite ^(.*)$ https://apps.f0xx.org$1 permanent; +- } +- # redirects both www and non-www to https +- return 301 https://apps.f0xx.org$request_uri; +- } +- server { +- listen 443 ssl http2; +- server_name apps.f0xx.org; +- +- access_log /var/log/nginx/apps.intra.raptor.org.access_log main; +- error_log /var/log/nginx/apps.intra.raptor.org.error_log info; +- +- ssl_certificate /etc/letsencrypt/live/apps.f0xx.org/fullchain.pem; +- ssl_certificate_key /etc/letsencrypt/live/apps.f0xx.org/privkey.pem; +- +- location / { +- proxy_pass http://artc0.intra.raptor.org:80; +- proxy_set_header Host $host; +- proxy_set_header X-Real-IP $remote_addr; +- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +- proxy_set_header X-Forwarded-Proto $scheme; +- proxy_connect_timeout 120; +- proxy_send_timeout 120; +- proxy_read_timeout 180; +- proxy_buffers 16 16M; +- proxy_buffer_size 16M; +- client_body_buffer_size 16M; +- client_max_body_size 16M; +- proxy_buffering off; +- proxy_set_header Upgrade $http_upgrade; +- proxy_set_header Connection keep-alive; +- proxy_set_header Host $host; +- proxy_cache_bypass $http_upgrade; +- proxy_set_header Connection "upgrade"; +- } +- +- #location /app/androidcast_project/ { +- # proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/; +- #} +-# +-# location /app/androidcast_project/crashes/ { +-# proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/crashes/; +-# } +-# +- +- location /app/androidcast_project/ { +- proxy_pass http://artc0.intra.raptor.org:80; +- proxy_http_version 1.1; +- proxy_set_header Host $host; +- proxy_set_header X-Real-IP $remote_addr; +- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +- proxy_set_header X-Forwarded-Proto $scheme; +- } +- +- location /app/androidcast_project/crashes/ { +- proxy_pass http://artc0.intra.raptor.org:80; +- proxy_http_version 1.1; +- proxy_set_header Host $host; +- proxy_set_header X-Real-IP $remote_addr; +- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +- proxy_set_header X-Forwarded-Proto $scheme; +- } +- +- include /etc/nginx/apps.conf; +- +- location ~ /\.ht { +- deny all; +- } +- +- root /var/www/localhost/htdocs/android_cast/project/android_cast/examples/crash_reporter/backend/public/; +- index index.php; +- +- +-## gitea redirection +-# Redirect without trailing slash +- location = /app/androidcast_project/git { +- return 301 $scheme://$host/app/androidcast_project/git/; +- } +- location /app/androidcast_project/git/ { +- proxy_pass http://artc0.intra.raptor.org:3000/; +- proxy_http_version 1.1; +- proxy_set_header Host $host; +- proxy_set_header X-Real-IP $remote_addr; +- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +- proxy_set_header X-Forwarded-Proto $scheme; +- proxy_set_header X-Forwarded-Host $host; +- proxy_set_header Upgrade $http_upgrade; +- proxy_set_header Connection $connection_upgrade; +- proxy_read_timeout 1800s; +- proxy_send_timeout 1800s; +- client_max_body_size 512m; +- } +- +- } + + } diff --git a/sim/cluster0/lab-seeds/backend/public/api/auth_register.php b/sim/cluster0/lab-seeds/backend/public/api/auth_register.php new file mode 100644 index 0000000..b8b6e6c --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/auth_register.php @@ -0,0 +1,26 @@ + false, 'error' => 'method_not_allowed'], 405); +} + +$raw = file_get_contents('php://input'); +$data = json_decode($raw ?: '', true); +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid_json'], 400); +} + +$action = (string) ($data['action'] ?? 'register'); +if ($action === 'verify') { + $result = AuthRegistration::verifyEmailToken((string) ($data['token'] ?? '')); + json_out($result, $result['ok'] ? 200 : 400); +} + +$email = (string) ($data['email'] ?? ''); +$password = (string) ($data['password'] ?? ''); +$username = (string) ($data['username'] ?? ''); +$result = AuthRegistration::register($email, $password, $username); +json_out($result, $result['ok'] ? 200 : 400); diff --git a/sim/cluster0/lab-seeds/backend/public/api/diag.php b/sim/cluster0/lab-seeds/backend/public/api/diag.php new file mode 100644 index 0000000..ae36bdc --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/diag.php @@ -0,0 +1,77 @@ + false, 'error' => 'diag disabled'], 403); +} + +$driver = Database::driver(); +$path = cfg('db.sqlite_path'); +$out = [ + 'ok' => true, + 'db_driver' => $driver, + 'php_user' => get_current_user(), + 'storage_writable' => is_writable(dirname(__DIR__) . '/../storage'), + 'tables' => [], + 'reports_columns' => [], + 'test_insert' => null, +]; + +if ($driver === 'sqlite') { + $out['sqlite_path'] = $path; + $out['sqlite_realpath'] = is_string($path) ? realpath($path) : null; + $out['sqlite_exists'] = is_string($path) && is_file($path); + $out['sqlite_writable'] = is_string($path) && is_writable($path); + $out['data_dir_writable'] = is_string($path) && is_writable(dirname($path)); +} else { + $m = cfg('db.mysql', []); + $socket = trim((string) ($m['socket'] ?? '')); + $out['mysql'] = [ + 'host' => $m['host'] ?? '127.0.0.1', + 'port' => (int) ($m['port'] ?? 3306), + 'socket' => $socket, + 'socket_exists' => $socket !== '' && file_exists($socket), + 'database' => $m['database'] ?? 'androidcast_crashes', + 'username' => $m['username'] ?? '', + ]; +} + +try { + $pdo = Database::pdo(); + $tables = Database::listTables($pdo); + $out['tables'] = $tables; + $out['tickets_table'] = Database::ticketsTableExists($pdo); + if (Database::isMysql()) { + try { + $out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn(); + } catch (Throwable $e) { + $out['mysql_database'] = $e->getMessage(); + } + } + if (!$out['tickets_table']) { + $out['tickets_migration'] = Database::ticketsMigrationHint(); + } + if (in_array('reports', $tables, true)) { + $cols = Database::columnNames($pdo, 'reports'); + $out['reports_columns'] = array_map( + static fn (string $name) => ['name' => $name], + $cols + ); + } + $pdo->beginTransaction(); + $stmt = $pdo->prepare( + 'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, + device_model, app_version, payload_json, tags_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $rid = 'diag-' . time(); + $stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}', '[]']); + $pdo->rollBack(); + $out['test_insert'] = 'ok (rolled back)'; +} catch (Throwable $e) { + $out['ok'] = false; + $out['test_insert'] = $e->getMessage(); +} + +json_out($out); diff --git a/sim/cluster0/lab-seeds/backend/public/api/graph_upload.php b/sim/cluster0/lab-seeds/backend/public/api/graph_upload.php new file mode 100644 index 0000000..e686395 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/graph_upload.php @@ -0,0 +1,32 @@ + false, 'error' => 'empty body'], 400); +} +$data = json_decode($raw, true); +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +if ((int) ($data['schema_version'] ?? 0) !== 1) { + json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400); +} +if (($data['ingest_kind'] ?? 'graph_session') !== 'graph_session') { + json_out(['ok' => false, 'error' => 'ingest_kind must be graph_session'], 400); +} + +try { + GraphRepository::insertSession($data); + json_out(['ok' => true, 'session_id' => $data['session_id'] ?? null]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + error_log('graph upload: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'store_failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/graphs.php b/sim/cluster0/lab-seeds/backend/public/api/graphs.php new file mode 100644 index 0000000..fe34066 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/graphs.php @@ -0,0 +1,22 @@ + true, 'data' => $payload]); +} catch (Throwable $e) { + error_log('graphs api: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'graphs_load_failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php b/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php new file mode 100644 index 0000000..af2bcae --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php @@ -0,0 +1,65 @@ + 'method_not_allowed']); + exit; +} + +$raw = file_get_contents('php://input') ?: ''; +$req = json_decode($raw, true); +if (!is_array($req)) { + http_response_code(400); + echo json_encode(['error' => 'invalid_json']); + exit; +} + +$heartbeat = $req['heartbeat'] ?? []; +if (!is_array($heartbeat)) { + $heartbeat = []; +} + +$type = (string)($heartbeat['type'] ?? 'generic'); +$srvEpoch = time(); +$srvTz = date('O'); +$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); + +$resp = [ + 'heartbeat' => [ + 'type' => $type, + 'server_date' => $srvEpoch, + 'server_tz' => $srvTz, + ], +]; + +if ($type === 'ntp') { + $clientDate = (int)($heartbeat['date'] ?? 0); + $clientTz = (string)($heartbeat['tz'] ?? ''); + $timeSource = trim((string)($heartbeat['time_source'] ?? 'device')); + if ($timeSource === '') { + $timeSource = 'device'; + } + $correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null; + $resp['heartbeat']['client_date'] = $clientDate; + $resp['heartbeat']['client_tz'] = $clientTz; + $resp['heartbeat']['time_source'] = $timeSource; + $resp['heartbeat']['correction_seconds'] = $correction; + $resp['heartbeat']['ntp_correction_s'] = $correction; + $resp['heartbeat']['enabled'] = true; +} + +if ($type === 'ip') { + $resp['heartbeat']['external_ip'] = $clientIp; +} + +if ($type === 'ra') { + require_once __DIR__ . '/../../src/RemoteAccessRepository.php'; + $ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp); + $resp['heartbeat'] = array_merge($resp['heartbeat'], $ra); +} + +echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php b/sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php new file mode 100644 index 0000000..2addb56 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php @@ -0,0 +1,146 @@ + 'intro', + 'title' => 'INTRODUCTION', + 'children' => [ + ['id' => 'intro-authors', 'title' => 'Authors'], + ['id' => 'intro-revision', 'title' => 'Revision details'], + ], + ], + [ + 'id' => 'deploy', + 'title' => 'LOCAL SINGLE-STEP TEST DEPLOYMENT', + 'children' => [ + ['id' => 'deploy-prep', 'title' => 'Preparations, environment'], + ['id' => 'deploy-packages', 'title' => 'Package prerequisites by distro'], + ['id' => 'deploy-checks', 'title' => 'Local checks'], + ], + ], + ['id' => 'orchestration', 'title' => 'ORCHESTRATION MODEL', 'children' => []], + ['id' => 'routing', 'title' => 'XHR SUPERVISED VIEW MODEL', 'children' => []], + [ + 'id' => 'ops', + 'title' => 'OPERATIONS CHECKLIST', + 'children' => [], + ], + ]; +} + +function hub_docs_section_html(string $section): string +{ + switch ($section) { + case 'intro-authors': + return '

Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.

'; + case 'intro-revision': + return '

Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.

'; + case 'deploy-prep': + return '

Prerequisites: docker + docker compose plugin, git checkout of android cast on branch next.

' + . '

Run one command from repo: cd orchestration && ./deploy.sh

'; + case 'deploy-packages': + return '' + . '' + . '' + . '' + . '' + . '
DistroInstall steps
Alpineapk add docker docker-cli-compose bash git curl
rc-update add docker default && service docker start
Debian / Ubuntuapt update && apt install -y docker.io docker-compose-plugin git curl
systemctl enable --now docker
Fedoradnf install -y docker docker-compose-plugin git curl
systemctl enable --now docker
Archpacman -S --noconfirm docker docker-compose git curl
systemctl enable --now docker
'; + case 'deploy-checks': + return '
    ' + . '
  • http://localhost:8080/app/androidcast_project/ (hub)
  • ' + . '
  • http://localhost:8080/app/androidcast_project/crashes/ (crashes/tasks)
  • ' + . '
  • http://localhost:8080/app/androidcast_project/git/ (redirect to gitea FE)
  • ' + . '
'; + case 'orchestration': + return '

All infra runs in docker with shared source bind-mount where possible.

' + . '
    ' + . '
  • landing FE (nginx): serves hub and routes subpaths
  • ' + . '
  • gitea FE (nginx) + gitea app
  • ' + . '
  • crashes FE (nginx) + php-fpm + mariadb
  • ' + . '
  • single command create/update: orchestration/deploy.sh
  • ' + . '
' + . '

Base images: nginx, php-fpm, mariadb. This stays minimal and reproducible on laptop devices.

'; + case 'routing': + return '

The Compass click opens a modal. Content is requested via XMLHttpRequest/fetch from BE endpoint ' + . '/app/androidcast_project/crashes/api/hub_deploy_docs.php.

' + . '

Internal links in this modal use tree-list captions and are intercepted on frontend, then re-requested through BE. ' + . 'Browser URL bar is not changed.

' + . '

Static assets remain at original paths and are not rewritten in this view.

'; + case 'ops': + return '
    ' + . '
  1. Pull latest next
  2. ' + . '
  3. Run orchestration/deploy.sh
  4. ' + . '
  5. Verify hub/crashes/gitea endpoints
  6. ' + . '
  7. For app changes: edit source in repo, rerun deploy script
  8. ' + . '
'; + case 'intro': + return '

Select a topic in the table of contents to load section content from backend.

'; + default: + return '

Section not found.

'; + } +} + +function hub_docs_section_title(string $section): string +{ + foreach (hub_docs_toc() as $node) { + if ($node['id'] === $section) { + return (string)$node['title']; + } + foreach ($node['children'] as $child) { + if ($child['id'] === $section) { + return (string)$child['title']; + } + } + } + return 'Section'; +} + +function hub_docs_all_section_ids(): array +{ + $ids = []; + foreach (hub_docs_toc() as $node) { + if (!empty($node['children'])) { + foreach ($node['children'] as $child) { + $ids[] = (string)$child['id']; + } + } else { + $ids[] = (string)$node['id']; + } + } + return $ids; +} + +if ($action === 'toc') { + echo safe_json_encode([ + 'ok' => true, + 'sections' => hub_docs_toc(), + 'default_section' => 'intro-authors', + 'preload' => hub_docs_all_section_ids(), + ]); + exit; +} + +if ($section === '') { + $section = 'intro-authors'; +} + +$known = hub_docs_all_section_ids(); +if (!in_array($section, $known, true)) { + $section = 'intro-authors'; +} + +echo safe_json_encode([ + 'ok' => true, + 'section' => $section, + 'title' => hub_docs_section_title($section), + 'html' => hub_docs_section_html($section), +]); diff --git a/sim/cluster0/lab-seeds/backend/public/api/live_cast.php b/sim/cluster0/lab-seeds/backend/public/api/live_cast.php new file mode 100644 index 0000000..fd6fe0c --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/live_cast.php @@ -0,0 +1,114 @@ + false, 'error' => 'invalid_json'], 400); + } + $action = trim((string) ($body['action'] ?? '')); +} + +$public = ($method === 'GET' && $action === 'session') + || ($method === 'POST' && $action === 'join_event'); + +if ($method === 'POST' && in_array($action, ['create_intent', 'heartbeat'], true)) { + $platform = strtolower(trim((string) ($body['platform'] ?? ''))); + if ($action === 'create_intent' && str_starts_with($platform, 'education')) { + $public = true; + } + if ($action === 'heartbeat') { + $sid = trim((string) ($body['session_id'] ?? '')); + if ($sid !== '') { + $existing = LiveCastRepository::sessionById($sid); + $existingPlatform = strtolower((string) ($existing['platform'] ?? '')); + if ($existing !== null && str_starts_with($existingPlatform, 'education')) { + $public = true; + } + } + } +} + +if (!$public) { + Auth::check(); +} +$actor = Auth::user() ?? []; + +if ($method === 'GET') { + if ($action === 'active') { + $platform = trim((string) ($_GET['platform'] ?? '')); + $rows = LiveCastRepository::activeSessions($actor, $platform !== '' ? $platform : null); + json_out(['ok' => true, 'sessions' => $rows]); + } + if ($action === 'analytics') { + $days = (int) ($_GET['days'] ?? 14); + $payload = LiveCastRepository::analytics($days, $actor); + json_out(['ok' => true, 'data' => $payload]); + } + if ($action === 'list') { + $days = (int) ($_GET['days'] ?? 14); + $limit = (int) ($_GET['limit'] ?? 200); + $rows = LiveCastRepository::listSessions($days, $actor, $limit); + json_out(['ok' => true, 'sessions' => $rows]); + } + if ($action === 'session') { + $sessionId = trim((string) ($_GET['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $row = LiveCastRepository::publicSession($sessionId); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'session' => $row]); + } + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +if ($action === 'create_intent') { + $row = LiveCastRepository::createIntent($body, $actor); + if ($row === []) { + json_out(['ok' => false, 'error' => 'create_failed'], 500); + } + json_out(['ok' => true, 'session' => $row]); +} + +if ($action === 'heartbeat') { + $sessionId = trim((string) ($body['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $row = LiveCastRepository::heartbeat($sessionId, $body, $actor); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'session' => $row]); +} + +if ($action === 'join_event') { + $sessionId = trim((string) ($body['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $ok = LiveCastRepository::registerJoin($sessionId, $body, $actor); + if (!$ok) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true]); +} + +json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/sim/cluster0/lab-seeds/backend/public/api/live_cast_signaling.php b/sim/cluster0/lab-seeds/backend/public/api/live_cast_signaling.php new file mode 100644 index 0000000..da26ea9 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/live_cast_signaling.php @@ -0,0 +1,84 @@ + false, 'error' => 'invalid_json'], 400); + } + $sessionId = trim((string) ($body['session_id'] ?? $sessionId)); +} + +if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); +} + +$session = LiveCastRepository::sessionById($sessionId); +if ($session === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); +} + +$platform = strtolower((string) ($session['platform'] ?? '')); +$status = strtolower((string) ($session['status'] ?? '')); +$isPublic = str_starts_with($platform, 'education') + || in_array($status, ['intent', 'live'], true); + +if (!$isPublic) { + Auth::check(); +} + +if ($method === 'GET') { + $action = trim((string) ($_GET['action'] ?? 'poll')); + if ($action === 'latest') { + $latest = LiveCastSignalingRepository::latest( + $sessionId, + $msgType !== '' ? $msgType : 'offer', + $fromRole !== '' ? $fromRole : null + ); + json_out(['ok' => true, 'message' => $latest]); + } + $messages = LiveCastSignalingRepository::poll( + $sessionId, + $sinceId, + $msgType !== '' ? $msgType : null, + $fromRole !== '' ? $fromRole : null + ); + $lastId = $sinceId; + foreach ($messages as $m) { + $lastId = max($lastId, (int) ($m['id'] ?? 0)); + } + json_out(['ok' => true, 'messages' => $messages, 'since_id' => $lastId]); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +$action = trim((string) ($body['action'] ?? 'post')); +if ($action !== 'post') { + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +$peerRole = trim((string) ($body['peer_role'] ?? 'caster')); +$type = trim((string) ($body['msg_type'] ?? '')); +$payload = $body['payload'] ?? null; +if (!is_array($payload)) { + $payload = ['sdp' => (string) ($body['sdp'] ?? ''), 'type' => (string) ($body['type'] ?? '')]; +} +if ($type === '') { + json_out(['ok' => false, 'error' => 'missing_msg_type'], 400); +} + +$id = LiveCastSignalingRepository::post($sessionId, $peerRole, $type, $payload); +json_out(['ok' => true, 'id' => $id]); diff --git a/sim/cluster0/lab-seeds/backend/public/api/rbac.php b/sim/cluster0/lab-seeds/backend/public/api/rbac.php new file mode 100644 index 0000000..b2ce844 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/rbac.php @@ -0,0 +1,70 @@ + false, 'error' => 'forbidden'], 403); +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$action = trim((string) ($_GET['action'] ?? '')); + +if ($method === 'GET' && $action === 'panel') { + $panel = RbacAdminRepository::buildPanel(); + json_out($panel, empty($panel['ok']) ? 403 : 200); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +$raw = file_get_contents('php://input') ?: ''; +$body = json_decode($raw, true); +if (!is_array($body)) { + json_out(['ok' => false, 'error' => 'invalid_json'], 400); +} + +if ($action === 'set_global_role') { + $result = RbacAdminRepository::setGlobalRole( + (int) ($body['user_id'] ?? 0), + (string) ($body['role'] ?? '') + ); + json_out($result, empty($result['ok']) ? 400 : 200); +} + +if ($action === 'set_company_role') { + $result = RbacAdminRepository::setCompanyRole( + (int) ($body['user_id'] ?? 0), + (int) ($body['company_id'] ?? 0), + (string) ($body['role'] ?? '') + ); + json_out($result, empty($result['ok']) ? 400 : 200); +} + +if ($action === 'apply_privilege_set') { + $result = RbacAdminRepository::applyPrivilegeSet( + (int) ($body['user_id'] ?? 0), + (int) ($body['company_id'] ?? 0), + (string) ($body['privilege_set'] ?? '') + ); + json_out($result, empty($result['ok']) ? 400 : 200); +} + +if ($action === 'clear_auth_lockouts') { + if (!Rbac::isRoot() && !Rbac::isGlobalAdmin()) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $userId = (int) ($body['user_id'] ?? 0); + if ($userId <= 0) { + json_out(['ok' => false, 'error' => 'invalid_user'], 400); + } + $deleted = AuthAttempts::clearForUser($userId); + json_out(['ok' => true, 'cleared' => $deleted]); +} + +json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/sim/cluster0/lab-seeds/backend/public/api/remote_access.php b/sim/cluster0/lab-seeds/backend/public/api/remote_access.php new file mode 100644 index 0000000..fe96592 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/remote_access.php @@ -0,0 +1,119 @@ + false, 'error' => 'forbidden'], 403); +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$action = trim((string)($_GET['action'] ?? '')); + +if ($method === 'GET') { + if ($action === 'devices') { + json_out(['ok' => true, 'devices' => RemoteAccessRepository::listDevices()]); + } + if ($action === 'sessions') { + $filter = trim((string)($_GET['status'] ?? '')); + json_out(['ok' => true, 'sessions' => RemoteAccessRepository::listSessions($filter)]); + } + if ($action === 'events') { + json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]); + } + if ($action === 'dashboard') { + $payload = RemoteAccessRepository::buildDashboardPayload( + rtrim(Auth::basePath(), '/'), + '/app/androidcast_project' + ); + json_out([ + 'ok' => true, + 'devices' => $payload['devices'], + 'active_sessions' => $payload['active_sessions'], + 'inactive_sessions' => $payload['inactive_sessions'], + 'recent_events' => $payload['recent_events'], + 'permissions' => [ + 'can_operate' => Rbac::can('remote_access_operate'), + 'can_admin' => Rbac::can('remote_access_admin'), + ], + 'config' => [ + 'wg_endpoint' => (string) cfg('remote_access.wg_endpoint', ''), + ], + ]); + } + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +$raw = file_get_contents('php://input') ?: ''; +$body = json_decode($raw, true); +if (!is_array($body)) { + json_out(['ok' => false, 'error' => 'invalid_json'], 400); +} + +$user = Auth::user(); +$userId = (int)($user['id'] ?? 0); + +if ($action === 'whitelist') { + if (!Rbac::can('remote_access_admin')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $deviceId = trim((string)($body['device_id'] ?? '')); + if ($deviceId === '') { + json_out(['ok' => false, 'error' => 'missing_device_id'], 400); + } + if (!RemoteAccessRepository::canAccessDevice($deviceId)) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + if (!RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null)) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [ + 'whitelisted' => !empty($body['whitelisted']), + ], ''); + json_out(['ok' => true]); +} + +if ($action === 'open_session') { + if (!Rbac::can('remote_access_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $deviceId = trim((string)($body['device_id'] ?? '')); + if ($deviceId === '') { + json_out(['ok' => false, 'error' => 'missing_device_id'], 400); + } + if (!RemoteAccessRepository::canAccessDevice($deviceId)) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $result = RemoteAccessRepository::openSession($deviceId, $userId); + if (empty($result['ok'])) { + $err = $result['error'] ?? 'failed'; + json_out(['ok' => false, 'error' => $err], $err === 'forbidden' ? 403 : 400); + } + json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']); +} + +if ($action === 'close_session') { + if (!Rbac::can('remote_access_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $sessionId = trim((string)($body['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + if (!RemoteAccessRepository::canOperatorCloseSession($sessionId, $userId)) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed'); + RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, ''); + json_out(['ok' => true]); +} + +json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/sim/cluster0/lab-seeds/backend/public/api/report_tags.php b/sim/cluster0/lab-seeds/backend/public/api/report_tags.php new file mode 100644 index 0000000..db4b094 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/report_tags.php @@ -0,0 +1,73 @@ + false, 'error' => 'unauthorized'], 401); +} + +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); +$id = (int) ($_GET['id'] ?? 0); + +if ($method === 'GET') { + if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); + } + $report = ReportRepository::getById($id); + if (!$report) { + json_out(['ok' => false, 'error' => 'not found'], 404); + } + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $report['tags'] ?? [], + 'workflow' => TagCatalog::workflowPresets(), + 'presets' => TagCatalog::editorPresets(), + 'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)), + ]); +} + +if ($method !== 'PUT' && $method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +$id = (int) ($data['id'] ?? $id); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +$tagsIn = $data['tags'] ?? []; +if (!is_array($tagsIn)) { + json_out(['ok' => false, 'error' => 'tags must be array'], 400); +} + +$report = ReportRepository::getById($id); +if (!$report) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} + +if (!Auth::canEditTags((int) ($report['company_id'] ?? 0))) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +try { + ReportRepository::setCustomTags($id, $tagsIn); + $updated = ReportRepository::getById($id); + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $updated['tags'] ?? [], + ]); +} catch (Throwable $e) { + error_log('report_tags: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'save failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/report_viewed.php b/sim/cluster0/lab-seeds/backend/public/api/report_viewed.php new file mode 100644 index 0000000..fa18d8f --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/report_viewed.php @@ -0,0 +1,25 @@ + false, 'error' => 'unauthorized'], 401); +} +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_out(['ok' => false, 'error' => 'POST required'], 405); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +$id = (int) ($data['id'] ?? 0); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +try { + ReportRepository::markViewed($id, (int) Auth::user()['id']); + json_out(['ok' => true, 'id' => $id]); +} catch (Throwable $e) { + error_log('report_viewed: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'failed'], 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/reports.php b/sim/cluster0/lab-seeds/backend/public/api/reports.php new file mode 100644 index 0000000..72182fc --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/reports.php @@ -0,0 +1,87 @@ + false, 'error' => 'unauthorized'], 401); +} + +$grouped = isset($_GET['group']) && $_GET['group'] === '1'; +$page = max(1, (int) ($_GET['page'] ?? 1)); +$perPage = (int) ($_GET['per_page'] ?? 50); +$allowedPerPage = [25, 50, 75, 100, 200]; +if (!in_array($perPage, $allowedPerPage, true)) { + $perPage = 50; +} +$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority')); +$dir = (string) ($_GET['dir'] ?? 'desc'); +$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0)); +$similarTo = max(0, (int) ($_GET['similar_to'] ?? 0)); +$searchQ = trim((string) ($_GET['q'] ?? '')); +$suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1'; + +$tagIds = request_tag_filter_ids(); +$tagMode = request_tag_filter_mode(); + +$filters = []; +if ($tagIds !== []) { + $filters['tags'] = $tagIds; + $filters['tag_mode'] = $tagMode; +} +$device = trim((string) ($_GET['filter_device'] ?? '')); +if ($device !== '') { + $filters['device'] = $device; +} +$abi = trim((string) ($_GET['filter_abi'] ?? '')); +if ($abi !== '') { + $filters['abi'] = $abi; +} +$osSdk = max(0, (int) ($_GET['filter_os_sdk'] ?? 0)); +if ($osSdk > 0) { + $filters['os_sdk'] = $osSdk; +} +$osRelease = trim((string) ($_GET['filter_os_release'] ?? '')); +if ($osRelease !== '') { + $filters['os_release'] = $osRelease; +} +$appVersion = trim((string) ($_GET['filter_app_version'] ?? '')); +if ($appVersion !== '') { + $filters['app_version'] = $appVersion; +} +$build = trim((string) ($_GET['filter_build'] ?? '')); +if ($build !== '') { + $filters['build'] = $build; +} + +try { + if ($suggest) { + json_out([ + 'ok' => true, + 'suggestions' => ReportRepository::searchSuggestions($searchQ), + 'q' => $searchQ, + ]); + } + if ($searchQ !== '') { + $data = ReportRepository::search($searchQ, $page, $perPage, $sinceMs); + } elseif ($similarTo > 0) { + $data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs); + } elseif ($filters !== []) { + if ($tagIds !== []) { + $grouped = false; + } + $data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs); + } else { + $data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs); + } + json_out(['ok' => true, 'tag_filter' => $tagIds, 'tag_mode' => $tagMode] + $data); +} catch (Throwable $e) { + error_log('reports api: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'list failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + if (str_contains($e->getMessage(), '2002')) { + $out['hint'] .= ' — use db.mysql.socket (/run/mysqld/mysqld.sock on Alpine); TCP 3306 is closed'; + } + } + json_out($out, 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/sfu_health.php b/sim/cluster0/lab-seeds/backend/public/api/sfu_health.php new file mode 100644 index 0000000..39ac4eb --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/sfu_health.php @@ -0,0 +1,18 @@ + true, + 'sfu' => [ + 'enabled' => $enabled, + 'status' => $enabled ? 'preview' : 'disabled', + 'signaling_url' => $enabled ? (string) ($sfu['signaling_url'] ?? '') : '', + 'janus_url' => $enabled ? (string) ($sfu['janus_url'] ?? '') : '', + ], +], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/sim/cluster0/lab-seeds/backend/public/api/short_links.php b/sim/cluster0/lab-seeds/backend/public/api/short_links.php new file mode 100644 index 0000000..11e6a50 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/short_links.php @@ -0,0 +1,167 @@ + false, 'error' => 'forbidden'], 403); +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$action = trim((string) ($_GET['action'] ?? '')); + +$avail = ShortLinksRepository::availability(); +if (!$avail['ok']) { + json_out([ + 'ok' => false, + 'error' => 'service_unavailable', + 'reason' => $avail['error'] ?? 'unknown', + ], 503); +} + +if ($method === 'GET') { + if ($action === 'dashboard') { + json_out([ + 'ok' => true, + 'public_base' => UrlShortenerDatabase::publicBase(), + 'bearers' => ShortLinksRepository::listBearers(), + 'signers' => ShortLinksRepository::listSigners(), + 'audit' => ShortLinksRepository::listAudit(40), + 'permissions' => [ + 'can_operate' => Rbac::can('short_links_operate'), + 'can_admin' => Rbac::isGlobalAdmin(), + ], + ]); + } + if ($action === 'links') { + $bearerId = (int) ($_GET['bearer_id'] ?? 0); + if ($bearerId <= 0) { + json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400); + } + json_out([ + 'ok' => true, + 'links' => ShortLinksRepository::listLinks($bearerId), + ]); + } + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +$raw = file_get_contents('php://input') ?: ''; +$body = json_decode($raw, true); +if (!is_array($body)) { + json_out(['ok' => false, 'error' => 'invalid_json'], 400); +} + +if ($action === 'mint_bearer') { + if (!Rbac::can('short_links_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $result = ShortLinksRepository::mintBearer( + (string) ($body['label'] ?? ''), + !array_key_exists('can_temporary', $body) || !empty($body['can_temporary']), + !empty($body['can_permanent']), + (int) ($body['rate_limit_per_hour'] ?? 1000), + !empty($body['mint_signer']) + ); + if (empty($result['ok'])) { + json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400); + } + $resp = [ + 'ok' => true, + 'bearer_id' => $result['bearer_id'], + 'token' => $result['token'], + ]; + if (isset($result['signer_key'])) { + $resp['signer_key'] = $result['signer_key']; + $resp['signer_id'] = $result['signer_id']; + } + json_out($resp); +} + +if ($action === 'mint_signer') { + if (!Rbac::can('short_links_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $result = ShortLinksRepository::mintSigner((string) ($body['label'] ?? '')); + if (empty($result['ok'])) { + json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400); + } + json_out([ + 'ok' => true, + 'signer_id' => $result['id'], + 'signer_key' => $result['key'], + ]); +} + +if ($action === 'revoke_signer') { + if (!Rbac::can('short_links_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $signerId = (int) ($body['signer_id'] ?? 0); + if ($signerId <= 0) { + json_out(['ok' => false, 'error' => 'missing_signer_id'], 400); + } + if (!ShortLinksRepository::revokeSigner($signerId)) { + json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404); + } + json_out(['ok' => true]); +} + +if ($action === 'revoke_bearer') { + if (!Rbac::can('short_links_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $bearerId = (int) ($body['bearer_id'] ?? 0); + if ($bearerId <= 0) { + json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400); + } + if (!ShortLinksRepository::revokeBearer($bearerId)) { + json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404); + } + json_out(['ok' => true]); +} + +if ($action === 'create_link') { + if (!Rbac::can('short_links_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $bearerId = (int) ($body['bearer_id'] ?? 0); + $url = (string) ($body['url'] ?? ''); + $ttl = (int) ($body['ttl'] ?? 86400); + if ($bearerId <= 0 || trim($url) === '') { + json_out(['ok' => false, 'error' => 'missing_fields'], 400); + } + $result = ShortLinksRepository::createLink( + $bearerId, + $url, + $ttl, + (string) ($body['signer'] ?? '') + ); + if (empty($result['ok'])) { + $code = match ($result['error'] ?? '') { + 'rate_limited' => 429, + 'ttl_expired' => 409, + 'signer_required' => 403, + default => 400, + }; + json_out(['ok' => false, 'error' => $result['error'] ?? 'failed', 'message' => $result['message'] ?? ''], $code); + } + json_out(['ok' => true, 'link' => $result]); +} + +if ($action === 'purge_expired') { + if (!Rbac::isGlobalAdmin()) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $counts = ShortLinksRepository::purgeExpired(); + json_out(['ok' => true, 'purged' => $counts]); +} + +json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/sim/cluster0/lab-seeds/backend/public/api/tag_catalog.php b/sim/cluster0/lab-seeds/backend/public/api/tag_catalog.php new file mode 100644 index 0000000..4871c4c --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/tag_catalog.php @@ -0,0 +1,15 @@ + false, 'error' => 'unauthorized'], 401); +} + +json_out([ + 'ok' => true, + 'workflow' => TagCatalog::workflowPresets(), + 'presets' => TagCatalog::editorPresets(), + 'filter_mode_default' => 'and', + 'filter_mode_help' => 'AND = report has all listed tags; OR = any listed tag', +]); diff --git a/sim/cluster0/lab-seeds/backend/public/api/ticket_attachment.php b/sim/cluster0/lab-seeds/backend/public/api/ticket_attachment.php new file mode 100644 index 0000000..350779a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/ticket_attachment.php @@ -0,0 +1,43 @@ + false, 'error' => 'unauthorized'], 401); +} + +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + +$user = Auth::user(); +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + +if ($method === 'GET') { + $ticketId = (int) ($_GET['ticket_id'] ?? 0); + if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); + } + try { + json_out(['ok' => true, 'attachments' => TicketAttachmentRepository::listForTicket($ticketId)]); + } catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_attachments') || str_contains($e->getMessage(), 'no such table')) { + json_out(['ok' => false, 'error' => 'attachments_table_missing', 'hint' => 'Run sql/migrations/005_ticket_attachments.sql'], 503); + } + throw $e; + } +} + +if ($method === 'DELETE') { + $id = (int) ($_GET['id'] ?? 0); + if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); + } + try { + TicketAttachmentRepository::delete($id, (string) ($user['username'] ?? '')); + json_out(['ok' => true]); + } catch (RuntimeException $e) { + $code = $e->getMessage() === 'forbidden' ? 403 : 404; + json_out(['ok' => false, 'error' => $e->getMessage()], $code); + } catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_attachments')) { + json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503); + } + throw $e; + } +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$ticketId = (int) ($_POST['ticket_id'] ?? 0); +if ($ticketId <= 0) { + $raw = file_get_contents('php://input'); + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; + if (is_array($data)) { + $ticketId = (int) ($data['ticket_id'] ?? 0); + } +} + +if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); +} + +$ticket = TicketRepository::getById($ticketId); +if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +// Any logged-in user who can view the ticket may add attachments (same as comments). + +try { + if (!empty($_FILES['file']['name'])) { + $att = TicketAttachmentRepository::addFile( + $ticketId, + (string) ($user['username'] ?? ''), + $_FILES['file'], + trim((string) ($_POST['title'] ?? '')) + ); + json_out(['ok' => true, 'attachment' => $att]); + } + $raw = file_get_contents('php://input'); + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; + if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'file or url required'], 400); + } + $url = trim((string) ($data['url'] ?? $data['external_url'] ?? '')); + if ($url === '') { + json_out(['ok' => false, 'error' => 'url required'], 400); + } + $att = TicketAttachmentRepository::addLink( + $ticketId, + (string) ($user['username'] ?? ''), + $url, + trim((string) ($data['title'] ?? '')) + ); + json_out(['ok' => true, 'attachment' => $att]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_attachments')) { + json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503); + } + error_log('ticket_attachments: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'save failed'], 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/ticket_comments.php b/sim/cluster0/lab-seeds/backend/public/api/ticket_comments.php new file mode 100644 index 0000000..e735708 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/ticket_comments.php @@ -0,0 +1,75 @@ + false, 'error' => 'unauthorized'], 401); +} + +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + +if ($method === 'GET') { + $ticketId = (int) ($_GET['ticket_id'] ?? 0); + if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); + } + $ticket = TicketRepository::getById($ticketId); + if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); + } + try { + json_out(['ok' => true, 'comments' => TicketCommentRepository::listForTicket($ticketId)]); + } catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) { + json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503); + } + throw $e; + } +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$user = Auth::user(); +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} + +$ticketId = (int) ($data['ticket_id'] ?? 0); +if ($ticketId <= 0) { + json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400); +} + +$ticket = TicketRepository::getById($ticketId); +if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +if (empty($ticket['can_edit']) && !Auth::canEditTags()) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +try { + $comment = TicketCommentRepository::add( + $ticketId, + (string) ($user['username'] ?? ''), + (string) ($data['body'] ?? '') + ); + json_out(['ok' => true, 'comment' => $comment]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) { + json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503); + } + error_log('ticket_comments: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'save failed'], 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/ticket_create.php b/sim/cluster0/lab-seeds/backend/public/api/ticket_create.php new file mode 100644 index 0000000..81247b3 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/ticket_create.php @@ -0,0 +1,65 @@ + false, 'error' => 'unauthorized'], 401); +} + +$raw = file_get_contents('php://input'); +$data = json_decode($raw !== false ? $raw : '', true); +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} + +$user = Auth::user(); +$title = trim((string) ($data['title'] ?? '')); +$body = trim((string) ($data['body'] ?? '')); +$brief = trim((string) ($data['brief'] ?? '')); +if ($title === '') { + json_out(['ok' => false, 'error' => 'title required'], 400); +} +if ($body === '' && $brief !== '') { + $body = $brief; +} + +$tags = is_array($data['tags'] ?? null) ? $data['tags'] : []; +if ($tags === []) { + $tags = [['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']]; +} + +$now = (int) round(microtime(true) * 1000); +$payload = [ + 'schema_version' => 1, + 'ingest_kind' => 'ticket', + 'ticket_id' => 'web-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)), + 'title' => mb_substr($title, 0, 256), + 'brief' => $brief !== '' ? mb_substr($brief, 0, 512) : mb_substr($title, 0, 512), + 'body' => $body !== '' ? $body : $title, + 'opened_at_epoch_ms' => $now, + 'rating' => max(0, min(5, (int) ($data['rating'] ?? 3))), + 'owner' => (string) ($user['username'] ?? 'admin'), + 'tags' => $tags, + 'device' => [ + 'manufacturer' => 'web', + 'model' => 'console', + 'sdk_int' => 0, + 'release' => 'n/a', + ], + 'app' => [ + 'package' => 'com.foxx.androidcast', + 'version_name' => 'console', + 'version_code' => 0, + ], +]; + +try { + Database::requireTicketsTable(); + $id = TicketRepository::insert($payload); + json_out(['ok' => true, 'id' => $id, 'ticket_id' => $payload['ticket_id']]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + error_log('ticket_create: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'create failed'], 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/ticket_tags.php b/sim/cluster0/lab-seeds/backend/public/api/ticket_tags.php new file mode 100644 index 0000000..21e9a06 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/ticket_tags.php @@ -0,0 +1,75 @@ + false, 'error' => 'unauthorized'], 401); +} + +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); +$id = (int) ($_GET['id'] ?? 0); + +if ($method === 'GET') { + if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); + } + $ticket = TicketRepository::getById($id); + if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); + } + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $ticket['tags'] ?? [], + 'presets' => TicketRepository::listTagPresets(), + 'can_edit' => !empty($ticket['can_edit']), + ]); +} + +if ($method !== 'PUT' && $method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +$id = (int) ($data['id'] ?? $id); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +$ticket = TicketRepository::getById($id); +if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +if (empty($ticket['can_edit'])) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +$tagsIn = $data['tags'] ?? []; +if (!is_array($tagsIn)) { + json_out(['ok' => false, 'error' => 'tags must be array'], 400); +} + +try { + TicketRepository::setCustomTags($id, $tagsIn); + $updated = TicketRepository::getById($id); + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $updated['tags'] ?? [], + ]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + error_log('ticket_tags: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'save failed'], 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/ticket_update.php b/sim/cluster0/lab-seeds/backend/public/api/ticket_update.php new file mode 100644 index 0000000..fe34661 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/ticket_update.php @@ -0,0 +1,56 @@ + false, 'error' => 'unauthorized'], 401); +} + +try { + Database::requireTicketsTable(); +} catch (RuntimeException $e) { + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} + +if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'PUT' && strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +$id = (int) ($data['id'] ?? 0); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +$ticket = TicketRepository::getById($id); +if (!$ticket) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} +if (empty($ticket['can_edit'])) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +$fields = []; +foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username', 'workflow_state'] as $key) { + if (array_key_exists($key, $data)) { + $fields[$key] = $data[$key]; + } +} +if (array_key_exists('assignees', $data)) { + $fields['assignees'] = $data['assignees']; +} + +try { + TicketRepository::update($id, $fields); + $updated = TicketRepository::getById($id); + json_out(['ok' => true, 'ticket' => $updated]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (Throwable $e) { + error_log('ticket_update: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'save failed'], 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/ticket_upload.php b/sim/cluster0/lab-seeds/backend/public/api/ticket_upload.php new file mode 100644 index 0000000..b30c0b0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/ticket_upload.php @@ -0,0 +1,46 @@ + false, 'error' => 'empty body'], 400); +} +$data = json_decode($raw, true); +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +if ((int) ($data['schema_version'] ?? 0) !== 1) { + json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400); +} +if (($data['ingest_kind'] ?? 'ticket') !== 'ticket') { + json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400); +} +try { + Database::requireTicketsTable(); + TicketRepository::insert($data); + json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); +} catch (RuntimeException $e) { + error_log('ticket upload: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503); +} catch (PDOException $e) { + $msg = $e->getMessage(); + if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) { + json_out(['ok' => false, 'error' => 'duplicate ticket_id'], 409); + } + error_log('ticket upload PDO: ' . $msg); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $msg; + } + json_out($out, 500); +} catch (Throwable $e) { + error_log('ticket upload: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/tickets.php b/sim/cluster0/lab-seeds/backend/public/api/tickets.php new file mode 100644 index 0000000..48912d0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/tickets.php @@ -0,0 +1,49 @@ + false, 'error' => 'unauthorized'], 401); +} + +$page = max(1, (int) ($_GET['page'] ?? 1)); +$perPage = (int) ($_GET['per_page'] ?? 50); +if (!in_array($perPage, [25, 50, 75, 100, 200], true)) { + $perPage = 50; +} +$sort = (string) ($_GET['sort'] ?? 'opened_at_ms'); +$dir = (string) ($_GET['dir'] ?? 'desc'); +$tag = trim((string) ($_GET['tag'] ?? '')); + +try { + Database::requireTicketsTable(); + $data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag); + json_out(['ok' => true] + $data); +} catch (RuntimeException $e) { + error_log('tickets api: ' . $e->getMessage()); + $out = [ + 'ok' => false, + 'error' => 'tickets_table_missing', + 'hint' => $e->getMessage(), + ]; + if (cfg('debug')) { + try { + $pdo = Database::pdo(); + $out['db_driver'] = Database::driver(); + $out['tickets_table'] = Database::ticketsTableExists($pdo); + if (Database::isMysql()) { + $out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn(); + } + } catch (Throwable $diag) { + $out['diag_error'] = $diag->getMessage(); + } + } + json_out($out, 503); +} catch (Throwable $e) { + error_log('tickets api: ' . $e->getMessage()); + $out = ['ok' => false, 'error' => 'list failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/upload.php b/sim/cluster0/lab-seeds/backend/public/api/upload.php new file mode 100644 index 0000000..adf85b8 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/upload.php @@ -0,0 +1,53 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 25 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 edbab2563cb9d5ba1c9a6a6272071ac12efa782274c7d1626f088a951033d5a6 + */ +declare(strict_types=1); +require_once __DIR__ . '/../../src/bootstrap.php'; + +$raw = read_crash_upload_body(); +if ($raw === null) { + json_out(['ok' => false, 'error' => 'empty body'], 400); +} +if ($raw === false) { + json_out(['ok' => false, 'error' => 'invalid gzip body'], 400); +} +$data = json_decode($raw, true); +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +if ((int) ($data['schema_version'] ?? 0) !== 1) { + json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400); +} +try { + ReportRepository::insert($data); + json_out(['ok' => true, 'report_id' => $data['report_id'] ?? null]); +} catch (PDOException $e) { + $msg = $e->getMessage(); + if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) { + json_out(['ok' => false, 'error' => 'duplicate report_id'], 409); + } + error_log('crash upload PDO: ' . $msg); + log_crash_event('upload', 'PDO: ' . $msg); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $msg; + } + json_out($out, 500); +} catch (Throwable $e) { + error_log('crash upload: ' . $e->getMessage()); + log_crash_event('upload', $e->getMessage()); + $out = ['ok' => false, 'error' => 'store failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); +} diff --git a/sim/cluster0/lab-seeds/backend/public/api/users.php b/sim/cluster0/lab-seeds/backend/public/api/users.php new file mode 100644 index 0000000..9bea9e0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/users.php @@ -0,0 +1,9 @@ + false, 'error' => 'unauthorized'], 401); +} + +json_out(['ok' => true, 'users' => UserRepository::listForAssignees()]); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/css/app.css b/sim/cluster0/lab-seeds/backend/public/assets/css/app.css new file mode 100644 index 0000000..e8cda5e --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/css/app.css @@ -0,0 +1,2423 @@ +:root, +[data-theme="dark"] { + --bg: #0f1419; + --surface: #1a2332; + --surface2: #0a0e14; + --border: #2d3a4f; + --text: #e7ecf3; + --muted: #8b9cb3; + --accent: #3d8bfd; + --accent2: #5eead4; + --danger: #f87171; + --row-hover: rgba(61, 139, 253, .12); + --row-open: rgba(61, 139, 253, .08); +} + +[data-theme="light"] { + --bg: #f0f2f6; + --surface: #ffffff; + --surface2: #e8ecf2; + --border: #c5d0e0; + --text: #1a2332; + --muted: #5a6b82; + --accent: #2563eb; + --accent2: #0d9488; + --danger: #dc2626; + --row-hover: rgba(37, 99, 235, .1); + --row-open: rgba(37, 99, 235, .06); +} +* { box-sizing: border-box; } +html { + width: 100%; + max-width: 100%; + overflow-x: clip; +} +[hidden] { + display: none !important; + pointer-events: none !important; +} +dialog:not([open]) { + display: none !important; +} +dialog:not([open])::backdrop { + display: none !important; + pointer-events: none !important; +} +dialog::backdrop { + pointer-events: auto; +} +body { + margin: 0; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + min-height: 100dvh; + width: 100%; + max-width: 100%; + overflow-x: clip; +} +body:has(.shell) { + display: block; + overflow: hidden; + height: 100vh; + height: 100dvh; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.shell { + display: flex; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: var(--footer-h, 40px); + width: 100%; + max-width: 100%; + min-height: 0; + overflow: hidden; + z-index: 1; +} +.shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; } +.shell.shell--graphs-full .main-pane { + max-width: none; + width: 100%; + min-width: 0; + padding: 20px 24px 32px; + box-sizing: border-box; +} +.shell.shell--graphs-full .graphs-app, +.shell.shell--graphs-full .graphs-grid { + max-width: 100%; + min-width: 0; +} +.shell.shell--graphs-full .graph-detail-body { + max-width: none; +} +.shell.shell--graphs-full .graph-detail-table { + max-width: none; +} +.nav-pane { + width: 56px; + height: 100%; + max-height: 100%; + align-self: stretch; + background: var(--surface); + border-right: 1px solid var(--border); + transition: width .22s ease; + overflow: hidden; + position: relative; + flex-shrink: 0; + display: flex; + flex-direction: column; +} +.nav-pane.open { width: 240px; } +.nav-handle { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + width: 40px; + height: 40px; + transition: left .22s ease, transform .22s ease; + padding: 0; + border: none; + border-radius: 10px; + cursor: pointer; + background: transparent; + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + transition: background .15s ease, color .15s ease; +} +.nav-handle:hover, +.nav-handle:focus-visible { + background: rgba(61, 139, 253, .15); + color: var(--text); + outline: none; +} +.nav-pane.open .nav-handle { + left: 8px; + transform: none; +} +.nav-list { + list-style: none; + margin: 56px 0 0; + padding: 0 8px; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +.nav-list li { margin: 6px 0; } +.nav-link { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin: 0 auto; + padding: 0; + border-radius: 10px; + color: var(--muted); + text-decoration: none; + transition: background .15s ease, color .15s ease, width .22s ease, padding .22s ease; +} +.nav-pane.open .nav-link { + width: 100%; + height: auto; + min-height: 40px; + margin: 0; + padding: 8px 12px; + justify-content: flex-start; + align-items: flex-start; + gap: 10px; +} +.nav-pane.open .nav-link > .nav-icon { + flex-shrink: 0; + margin-top: 2px; +} +.nav-link.active, +.nav-link:hover { + background: rgba(61, 139, 253, .22); + color: var(--accent); + text-decoration: none; +} +.nav-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 auto; + opacity: 0; + max-width: 0; + overflow: hidden; + transition: opacity .18s ease, max-width .22s ease; +} +.nav-pane.open .nav-text { + opacity: 1; + max-width: 11rem; + transition-delay: .06s; +} +.nav-label { + flex: 0 1 auto; + min-width: 0; + font-size: 14px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.nav-desc { + display: none; + font-size: 11px; + font-weight: 400; + line-height: 1.25; + color: var(--muted); + white-space: normal; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.nav-pane.open .nav-desc { + display: -webkit-box; +} +.nav-link > .nav-label { + opacity: 0; + max-width: 0; + transition: opacity .18s ease, max-width .22s ease; +} +.nav-pane.open .nav-link > .nav-label { + opacity: 1; + max-width: 9rem; + transition-delay: .06s; +} +.nav-text .nav-label { + opacity: 1; + max-width: none; + transition: none; +} +.nav-handle.is-dragging { + cursor: grabbing; +} +@media (prefers-reduced-motion: reduce) { + .nav-pane, + .nav-link, + .nav-label, + .nav-text, + .locale-code { + transition: none; + } + .nav-pane.open .nav-link > .nav-label, + .nav-pane.open .nav-text, + .nav-pane.open .locale-code { + transition-delay: 0s; + } +} +.nav-icon { + display: block; + flex-shrink: 0; + position: relative; + box-sizing: border-box; +} +/* Hamburger (nav handle) */ +.nav-icon--menu { + width: 18px; + height: 2px; + background: currentColor; + border-radius: 1px; + box-shadow: 0 -6px 0 currentColor, 0 6px 0 currentColor; +} +/* House */ +.nav-icon--home { + width: 18px; + height: 16px; +} +.nav-icon--home::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + transform: translateX(-50%); + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-bottom: 7px solid currentColor; +} +.nav-icon--home::after { + content: ""; + position: absolute; + left: 3px; + bottom: 0; + width: 12px; + height: 9px; + border: 2px solid currentColor; + border-top: none; + border-radius: 0 0 2px 2px; + box-sizing: border-box; +} +/* Stacked reports */ +.nav-icon--reports { + width: 16px; + height: 18px; + border: 2px solid currentColor; + border-radius: 3px; +} +.nav-icon--reports::before, +.nav-icon--reports::after { + content: ""; + position: absolute; + left: 3px; + right: 3px; + height: 2px; + background: currentColor; + border-radius: 1px; +} +.nav-icon--reports::before { top: 5px; } +.nav-icon--reports::after { top: 10px; width: 70%; } +/* Task ticket — clipboard + check (distinct from stacked “reports” document) */ +.nav-icon--tickets { + width: 14px; + height: 16px; + margin-top: 2px; + border: 2px solid currentColor; + border-top: none; + border-radius: 0 0 4px 4px; + box-sizing: border-box; +} +.nav-icon--tickets::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 9px; + height: 5px; + border: 2px solid currentColor; + border-bottom: none; + border-radius: 3px 3px 0 0; + box-sizing: border-box; + background: var(--surface); +} +.nav-icon--tickets::after { + content: ""; + position: absolute; + left: 3px; + bottom: 3px; + width: 6px; + height: 4px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(-45deg); + transform-origin: left bottom; + box-sizing: border-box; +} +.nav-icon--graphs { + width: 16px; + height: 16px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; +} +.nav-icon--graphs::before, +.nav-icon--graphs::after { + content: ""; + position: absolute; + bottom: 0; + width: 3px; + background: currentColor; + border-radius: 1px 1px 0 0; +} +.nav-icon--graphs::before { + left: 4px; + height: 8px; +} +.nav-icon--graphs::after { + left: 10px; + height: 12px; +} +.ticket-col-issue { min-width: 200px; max-width: 420px; } +.ticket-issue-title { font-weight: 600; } +.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; } +.ticket-col-env { font-size: 0.9em; max-width: 220px; } +.assignee-editor { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; } +.assignee-pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; border-radius: 999px; background: var(--surface-2, #2a3344); + font-size: 0.9rem; +} +.assignee-remove { + border: none; background: transparent; color: var(--muted, #94a3b8); + cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 0 2px; +} +.assignee-add-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.ticket-attachments { margin-top: 24px; } +.ticket-attachment-list { list-style: none; padding: 0; margin: 0 0 16px; } +.ticket-attachment { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; + padding: 10px 12px; margin-bottom: 8px; border-radius: 8px; + background: var(--surface-2, #1e293b); +} +.attachment-provider { + font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--muted, #94a3b8); +} +.ticket-attachment-add { + display: grid; grid-template-columns: 1fr 1fr; gap: 16px; +} +@media (max-width: 720px) { + .ticket-attachment-add { grid-template-columns: 1fr; } +} +.btn-small { padding: 2px 8px; font-size: 0.85rem; min-height: auto; } +.ticket-comments { margin-top: 24px; } +.ticket-comment-list { list-style: none; padding: 0; margin: 0 0 16px; } +.ticket-comment { + padding: 12px 14px; margin-bottom: 10px; border-radius: 8px; + background: var(--surface-2, #1e293b); +} +.ticket-comment-meta { margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; } +.ticket-comment-body { + margin: 0; white-space: pre-wrap; font-family: inherit; font-size: 0.95rem; + background: transparent; border: none; padding: 0; +} +.ticket-edit-form { display: grid; grid-template-columns: 1fr min(320px, 100%); gap: 16px; align-items: start; } +.ticket-edit-form .card--wide { grid-column: 1 / -1; } +@media (min-width: 900px) { + .ticket-edit-form { grid-template-columns: 1fr 280px; } + .ticket-edit-form .card--wide { grid-column: 1; grid-row: 1 / span 2; } +} +.ticket-field { display: block; margin-bottom: 12px; } +.ticket-field span { display: block; font-size: 0.85em; color: var(--muted); margin-bottom: 4px; } +.ticket-field input, +.ticket-field textarea, +.ticket-field select { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} +.ticket-body { white-space: pre-wrap; word-break: break-word; } +/* User */ +.nav-icon--user { + width: 18px; + height: 18px; +} +.nav-icon--user::before { + content: ""; + position: absolute; + top: 1px; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 50%; + box-sizing: border-box; +} +.nav-icon--user::after { + content: ""; + position: absolute; + left: 2px; + right: 2px; + bottom: 0; + height: 7px; + border: 2px solid currentColor; + border-top: none; + border-radius: 8px 8px 3px 3px; + box-sizing: border-box; +} +/* Logout — door + arrow */ +.nav-icon--logout { + width: 18px; + height: 16px; +} +.nav-icon--logout::before { + content: ""; + position: absolute; + left: 0; + top: 1px; + width: 10px; + height: 14px; + border: 2px solid currentColor; + border-right: none; + border-radius: 2px 0 0 2px; + box-sizing: border-box; +} +.nav-icon--logout::after { + content: ""; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 7px solid currentColor; +} +.nav-user { + position: static; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + color: var(--muted); + padding: 8px 8px 10px; + box-sizing: border-box; +} +.nav-pane.open .nav-user { + align-items: stretch; + padding: 0 10px; +} +.nav-user-name { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--muted); + gap: 10px; +} +.nav-pane.open .nav-user-name { + width: 100%; + padding: 0 12px; + justify-content: flex-start; + box-sizing: border-box; +} +.nav-pane.open .nav-link--logout { + width: 100%; + margin: 0; + padding: 0 12px; + justify-content: flex-start; + gap: 10px; +} +.nav-link--logout { color: var(--muted); } +.nav-link--logout:hover { color: var(--danger); background: rgba(248, 113, 113, .12); } +.main-pane { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + max-width: 100%; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + padding: 24px 28px; + box-sizing: border-box; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; +} +.main-pane::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} +body { --footer-h: 40px; } +.bottom-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + flex-shrink: 0; + height: var(--footer-h); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + font-size: 12px; + color: var(--muted); + background: var(--surface); + z-index: 40; + pointer-events: none; +} +.bottom-bar a, +.bottom-bar button, +.bottom-bar input, +.bottom-bar select, +.bottom-bar textarea { + pointer-events: auto; +} +.bottom-bar.platform-footer { + height: var(--footer-h); + min-height: var(--footer-h); + max-height: var(--footer-h); + overflow: hidden; +} +.platform-footer { + flex-direction: column; + align-items: stretch; + justify-content: center; + min-height: var(--footer-h); + padding: 6px 16px; + gap: 4px; +} +.platform-footer__top, +.platform-footer__bottom { + width: 100%; + text-align: center; + line-height: 1.35; +} +.platform-footer__main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + min-height: 24px; +} +.platform-footer--single-row .platform-footer__main { + justify-content: flex-start; +} +.platform-footer__left { + flex: 1 1 auto; + min-width: 0; +} +.platform-footer__right { + flex: 0 0 auto; + text-align: right; + max-width: 50%; +} +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + padding: 3rem 16px calc(var(--footer-h, 40px) + 1.5rem); + box-sizing: border-box; + position: relative; + isolation: isolate; + z-index: 0; +} +.login-page .login-card { + position: relative; + z-index: 100; + pointer-events: auto; +} +.login-page .login-locale { + z-index: 101; + pointer-events: auto; +} +.login-page .bottom-bar { + z-index: 50; +} +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px; + width: min(400px, 92vw); + display: flex; + flex-direction: column; + gap: 12px; +} +.login-card input { + width: 100%; + padding: 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.login-card button { + padding: 12px; + border: 1px solid var(--accent); + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .25), + 0 4px 14px rgba(0, 0, 0, .3), + 0 0 20px rgba(61, 139, 253, .35); + transition: box-shadow .18s ease, filter .18s ease, transform .12s ease; +} +.login-card button:hover, +.login-card button:focus-visible { + filter: brightness(1.08); + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .55), + 0 6px 18px rgba(0, 0, 0, .34), + 0 0 28px rgba(61, 139, 253, .48); + outline: none; +} +.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; } +.btn { + display: inline-block; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid var(--border); + margin-left: 8px; + cursor: pointer; + background: var(--surface); + color: var(--text); + text-decoration: none; + font: inherit; + line-height: 1.25; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .12), + 0 2px 10px rgba(0, 0, 0, .28), + 0 0 18px rgba(61, 139, 253, .08); + transition: + border-color .18s ease, + box-shadow .18s ease, + transform .12s ease, + filter .18s ease, + background .18s ease; +} +[data-theme="light"] .btn { + box-shadow: + 0 0 0 1px rgba(37, 99, 235, .14), + 0 2px 8px rgba(15, 23, 42, .12), + 0 0 16px rgba(37, 99, 235, .1); +} +.btn:hover, +.btn:focus-visible { + border-color: var(--accent); + text-decoration: none; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .45), + 0 4px 16px rgba(0, 0, 0, .32), + 0 0 22px rgba(61, 139, 253, .38); + outline: none; +} +[data-theme="light"] .btn:hover, +[data-theme="light"] .btn:focus-visible { + box-shadow: + 0 0 0 1px rgba(37, 99, 235, .5), + 0 4px 14px rgba(15, 23, 42, .16), + 0 0 20px rgba(37, 99, 235, .28); +} +.btn:active { + transform: translateY(1px); +} +.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn:disabled, +.btn[aria-disabled="true"] { + opacity: .55; + cursor: not-allowed; + box-shadow: 0 1px 4px rgba(0, 0, 0, .15); +} +.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } +.data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: 10px 8px; text-align: left; } +.reports-tree .report-tree-head { width: 2.5rem; padding: 0; } +.reports-tree .report-tree-cell { + width: 2.5rem; + padding: 6px 4px; + vertical-align: middle; +} +.report-tree-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background .12s ease, color .12s ease; +} +.report-tree-toggle:hover, +.report-tree-toggle:focus-visible { + background: rgba(61, 139, 253, .18); + color: var(--accent); + outline: none; +} +.report-tree-arrow { + display: block; + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid currentColor; + transition: transform .18s ease; +} +.report-tree-toggle[aria-expanded="true"] .report-tree-arrow { + transform: rotate(90deg); +} +.report-row { + transition: background .12s ease; +} +.report-row--nav { cursor: pointer; } +.report-row:hover, +.report-row:focus-within, +.report-brief-panel--nav:hover, +.report-brief-panel--nav:focus-within { + background: var(--row-hover); +} +.report-row.report-row--open { + background: var(--row-open); +} +.report-brief-panel--nav { + cursor: pointer; + border-radius: 0 8px 8px 0; + margin: 0; + padding: 0; +} +.report-brief-panel--nav:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.report-brief-row[hidden] { display: none; } +.report-brief-cell { + padding: 0 12px 12px 2.75rem !important; + border-bottom: 1px solid var(--border); + background: var(--surface2); +} +.report-brief { + margin: 0; + padding: 10px 14px; + border-left: 3px solid var(--accent); + border-radius: 0 8px 8px 0; + font-size: 13px; + line-height: 1.45; +} +.report-brief p { margin: 0 0 6px; } +.report-brief p:last-child { margin-bottom: 0; } +.report-brief-hint { font-size: 12px; margin-top: 8px !important; } +.badge { background: var(--accent2); color: #042; padding: 2px 8px; border-radius: 999px; font-weight: 700; } +.stack { background: var(--surface2); padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; } +.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); } +.nav-locale { + flex-shrink: 0; + margin-top: auto; + padding: 8px 10px 6px; + border-top: 1px solid var(--border); +} +.nav-locale .locale-toolbar-select { + width: 100%; + justify-content: center; + margin: 0; +} +.nav-pane.open .nav-locale .locale-toolbar-select { + justify-content: flex-start; +} +.locale-toolbar-select { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted); + cursor: pointer; +} +.locale-toolbar-select .locale-flag { + flex-shrink: 0; + font-size: 1rem; + line-height: 1; + width: 1.25rem; + text-align: center; + user-select: none; + pointer-events: none; +} +.locale-toolbar-select select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 13px; + font-weight: 600; + cursor: pointer; + min-width: 4.5rem; +} +.nav-locale .locale-toolbar-select select { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; +} +.login-locale { + position: fixed; + top: 12px; + right: 12px; + z-index: 5; +} +.login-page .login-card { margin-top: 2.5rem; } +.login-card input, +.login-card button, +.login-card textarea, +.login-card select { + pointer-events: auto; +} +.toolbar-select select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.reports-mode-btn { margin-left: 0; } +.reports-mode-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.reports-status { margin: 8px 0 0; min-height: 1.2em; } +.reports-table-wrap { overflow-x: auto; } +.reports-table--cols { + table-layout: fixed; + width: 100%; +} +.reports-table--cols th, +.reports-table--cols td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + vertical-align: middle; +} +.reports-table--cols .col-tags, +.reports-table--cols .report-tags { + white-space: normal; +} +.th-inner { + display: flex; + align-items: center; + gap: 4px; + padding-right: 8px; + min-height: 1.5rem; +} +.data-table th.th-draggable { + cursor: grab; + user-select: none; +} +.data-table th.th-draggable:active { + cursor: grabbing; +} +.th-drag { + color: var(--muted); + font-size: 11px; + letter-spacing: -2px; + user-select: none; + flex-shrink: 0; +} +.th-label { flex: 1; min-width: 0; } +.th-draggable.th-dragging { opacity: .45; } +.th-draggable.th-dragging .th-label { + color: var(--accent); + font-weight: 600; +} +.data-table th.th-drop-target { + box-shadow: inset 0 -3px 0 var(--accent); + background: var(--row-hover); +} +.col-drag-ghost { + position: fixed; + top: -1000px; + left: -1000px; + z-index: 9999; + padding: 6px 12px; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-size: 13px; + font-weight: 600; + box-shadow: 0 6px 18px rgba(0, 0, 0, .35); + pointer-events: none; + white-space: nowrap; +} +.col-resizer { + position: absolute; + top: 0; + right: 0; + width: 7px; + height: 100%; + cursor: col-resize; + user-select: none; + z-index: 2; +} +.col-resizer:hover, +.col-resizer:active { + background: rgba(61, 139, 253, .35); +} +.report-tree-head .col-resizer { display: none; } +.reports-pagination { margin-top: 16px; } +.pagination-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.page-btn:disabled { opacity: .45; cursor: not-allowed; } +.page-info { font-size: 13px; color: var(--muted); } +.data-table th.sortable { + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.data-table th.sortable:hover { color: var(--accent); } +.data-table th.sortable--active { color: var(--accent); } +.sort-ind { display: inline-block; min-width: 1em; font-size: 11px; opacity: .85; } +.sort-idle { opacity: .35; } +.col-tags { + min-width: 6rem; + max-width: 11rem; + text-align: right; + vertical-align: middle; +} +.col-tags .report-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; +} +.data-table th.col-tags { text-align: right; } +.report-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + color: #fff; + background: var(--tag-bg, #5c6b82); +} +button.report-tag--filter { + border: none; + cursor: pointer; + font: inherit; + color: inherit; + line-height: 1.3; +} +button.report-tag--filter:hover { + filter: brightness(1.12); +} +.reports-filter-banner { + margin: 8px 0 0; + padding: 10px 12px; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border); +} +.reports-filter-banner a { font-weight: 600; } + +.reports-search-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 10px 12px; + width: 100%; + margin: 12px 0 8px; + box-sizing: border-box; +} +.reports-search-label { + font-size: 13px; + color: var(--muted); + flex: 0 0 auto; + margin: 0; + line-height: 1.2; + align-self: center; +} +.reports-search-field { + position: relative; + flex: 0 0 40%; + width: 40%; + max-width: 40%; + align-self: center; +} +.reports-search-row .btn { + flex: 0 0 auto; + align-self: center; + margin-left: 0; +} +.reports-search-input { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 14px; +} +.reports-search-input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.reports-search-suggest { + position: absolute; + z-index: 20; + left: 0; + right: 0; + top: calc(100% + 4px); + margin: 0; + padding: 4px 0; + list-style: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .25); + max-height: 240px; + overflow-y: auto; +} +.reports-search-suggest[hidden] { display: none; } +.suggest-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.suggest-item:hover, +.suggest-item:focus-visible { + background: var(--row-hover); + outline: none; +} + +.stack-block-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.stack-block-head h2 { margin: 0; } +.btn-similar { + margin-left: 0; + white-space: nowrap; + font-size: 13px; +} + +.detail-filter-link { + color: var(--text); + text-decoration: none; + border-radius: 6px; + padding: 2px 4px; + margin: -2px -4px; + transition: background .12s ease, color .12s ease; +} +.detail-filter-link:hover, +.detail-filter-link:focus-visible { + background: var(--row-hover); + color: var(--accent); + outline: none; +} +.detail-filter-link--tag { + display: inline-block; + padding: 2px 8px; + margin: 0; + border-radius: 999px; + border: none; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + color: #fff; + background: var(--tag-bg, #5c6b82); +} +.detail-filter-link--tag:hover, +.detail-filter-link--tag:focus-visible { + color: #fff; + background: var(--tag-bg, #5c6b82); + filter: brightness(1.12); + outline: none; +} +.detail-abi-row, +.detail-meta-row { + line-height: 1.8; +} +.detail-meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.exc { color: var(--danger); font-weight: 600; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; } +.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; } +.card canvas { + width: 100%; + max-width: 100%; + height: 180px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface2); +} +.cards--lift .card--lift { + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; +} +.cards--lift .card--lift:hover { + transform: translateY(-4px); + box-shadow: 0 10px 24px rgba(0, 0, 0, .22); + border-color: var(--accent); +} +[data-theme="light"] .cards--lift .card--lift:hover { + box-shadow: 0 10px 22px rgba(26, 35, 50, .14); +} +.detail-header { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 8px; +} +.back-link { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 10px; + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; + flex-shrink: 0; + transition: background .15s ease, border-color .15s ease, color .15s ease; +} +.back-link:hover { + background: var(--row-hover); + border-color: var(--accent); + color: var(--accent); + text-decoration: none; +} +.back-icon { + display: block; + width: 10px; + height: 10px; + margin-left: 3px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); +} +.detail-tree { + margin-top: 20px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.detail-tree-item + .detail-tree-item { + border-top: 1px solid var(--border); +} +.detail-tree-row { + display: flex; + align-items: stretch; + width: 100%; + cursor: pointer; + transition: background .12s ease; +} +.detail-tree-row:hover, +.detail-tree-row:focus-within, +.detail-tree-row.report-row--open { + background: var(--row-hover); +} +.detail-tree-caption { + flex: 1; + display: flex; + align-items: center; + padding: 10px 12px; + font-weight: 600; +} +.detail-tree-body { + padding: 0 14px 14px 2.75rem; + background: var(--surface2); + border-top: 1px solid var(--border); +} +.detail-tree-body[hidden] { display: none; } +.star-rating { display: inline-flex; gap: 2px; line-height: 1; } +.star { + width: 14px; + height: 14px; + clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); + background: var(--border); +} +.star--on { background: #f59e0b; } +[data-theme="light"] .star--on { background: #d97706; } +.muted { color: var(--muted); } +.alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; } +.alert--ok { background: rgba(52,211,153,.15); color: var(--ok, #34d399); } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .35), + 0 3px 12px rgba(0, 0, 0, .3), + 0 0 22px rgba(61, 139, 253, .32); +} +.btn-primary:hover, +.btn-primary:focus-visible { + filter: brightness(1.08); + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .6), + 0 5px 18px rgba(0, 0, 0, .34), + 0 0 30px rgba(61, 139, 253, .5); +} +.btn-danger { + background: rgba(248, 113, 113, .14); + border-color: var(--danger); + color: var(--danger); + box-shadow: + 0 0 0 1px rgba(248, 113, 113, .28), + 0 3px 12px rgba(0, 0, 0, .28), + 0 0 18px rgba(248, 113, 113, .22); +} +[data-theme="light"] .btn-danger { + background: rgba(220, 38, 38, .08); + box-shadow: + 0 0 0 1px rgba(220, 38, 38, .3), + 0 3px 10px rgba(15, 23, 42, .12), + 0 0 16px rgba(220, 38, 38, .18); +} +.btn-danger:hover, +.btn-danger:focus-visible { + background: rgba(248, 113, 113, .22); + box-shadow: + 0 0 0 1px rgba(248, 113, 113, .55), + 0 5px 16px rgba(0, 0, 0, .32), + 0 0 26px rgba(248, 113, 113, .42); +} +.build-actions .toolbar-actions .btn, +.build-actions .toolbar-actions a.btn, +.build-log-actions .btn, +.build-log-actions a.btn { + margin-left: 0; +} +.build-ssh-panel { + margin-top: 12px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid var(--accent); + background: rgba(61, 139, 253, .08); + box-shadow: 0 0 18px rgba(61, 139, 253, .15); +} +.build-ssh-panel pre { + margin: 8px 0 0; + padding: 10px 12px; + background: var(--surface2); + border-radius: 6px; + overflow-x: auto; + font-size: 13px; +} + +/* Builder status glyphs (list + detail) */ +.build-status-line, +.build-status-cell-inner { + display: inline-flex; + align-items: center; + gap: 8px; + vertical-align: middle; +} +.build-status-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.build-status-spinner { + display: block; + width: 15px; + height: 15px; + border: 2px solid rgba(61, 139, 253, .22); + border-top-color: var(--accent); + border-radius: 50%; + animation: build-status-spin .85s linear infinite; + box-shadow: 0 0 10px rgba(61, 139, 253, .35); +} +[data-theme="light"] .build-status-spinner { + border-color: rgba(37, 99, 235, .18); + border-top-color: var(--accent); + box-shadow: 0 0 8px rgba(37, 99, 235, .28); +} +@keyframes build-status-spin { + to { transform: rotate(360deg); } +} +.build-status-icon--passed { + width: 17px; + height: 17px; + border-radius: 50%; + background: linear-gradient(145deg, #34d399, #16a34a); + box-shadow: + 0 0 0 1px rgba(34, 197, 94, .45), + 0 0 12px rgba(34, 197, 94, .4); + position: relative; +} +.build-status-icon--passed::after { + content: ''; + position: absolute; + left: 5px; + top: 3px; + width: 4px; + height: 7px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.build-status-icon--failed { + width: 18px; + height: 16px; + position: relative; + clip-path: polygon(50% 0%, 6% 92%, 94% 92%); + background: linear-gradient(180deg, #fb7185, var(--danger)); + box-shadow: 0 0 12px rgba(248, 113, 113, .45); +} +.build-status-fail-mark { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding-top: 5px; + font-size: 11px; + font-weight: 800; + line-height: 1; + color: #fff; + text-shadow: 0 1px 0 rgba(0, 0, 0, .25); + pointer-events: none; +} + +.build-col-controls { width: 4.5rem; } +.build-row-controls { vertical-align: middle; } +.build-transport-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} +.build-transport-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .12), + 0 2px 8px rgba(0, 0, 0, .25), + 0 0 14px rgba(61, 139, 253, .1); + transition: box-shadow .18s ease, border-color .18s ease, transform .12s ease; +} +.build-transport-btn:hover:not(:disabled), +.build-transport-btn:focus-visible:not(:disabled) { + border-color: var(--accent); + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .45), + 0 0 18px rgba(61, 139, 253, .35); + outline: none; +} +.build-transport-btn:active:not(:disabled) { transform: translateY(1px); } +.build-transport-btn:disabled { + opacity: .35; + cursor: not-allowed; + box-shadow: none; +} +.build-transport-play::before { + content: ''; + display: block; + width: 0; + height: 0; + margin-left: 3px; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 10px solid var(--accent); + filter: drop-shadow(0 0 4px rgba(61, 139, 253, .45)); +} +.build-transport-stop { + border-color: rgba(248, 113, 113, .55); + box-shadow: + 0 0 0 1px rgba(248, 113, 113, .25), + 0 2px 8px rgba(0, 0, 0, .25), + 0 0 12px rgba(248, 113, 113, .18); +} +.build-transport-stop::before { + content: ''; + display: block; + width: 9px; + height: 9px; + border-radius: 2px; + background: var(--danger); + box-shadow: 0 0 8px rgba(248, 113, 113, .55); +} + +.tag-edit-btn { + flex-shrink: 0; + margin-left: 2px; + padding: 0 5px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 13px; + line-height: 1.2; + cursor: pointer; + vertical-align: middle; +} +.tag-edit-btn:hover, +.tag-edit-btn:focus-visible { + background: rgba(61, 139, 253, .2); + color: var(--accent); + outline: none; +} + +.tag-editor { + margin: 20px 0 24px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; +} +.tag-editor-head h2 { margin: 0 0 4px; font-size: 1.1rem; } +.tag-editor-hint { margin: 0 0 12px; font-size: 13px; } +.tag-editor-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 1.5rem; + margin-bottom: 12px; +} +.tag-editor-list { + list-style: none; + margin: 0 0 12px; + padding: 0; +} +.tag-editor-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.tag-editor-item:last-child { border-bottom: none; } +.tag-color-edit { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); +} +.tag-color-edit input[type="color"] { + width: 2rem; + height: 1.6rem; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} +.tag-editor-add { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 14px; + margin-bottom: 12px; +} +.tag-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} +.tag-field input[type="text"] { + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + min-width: 10rem; +} +.tag-editor-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.tag-editor-status--err { color: var(--danger); } +.tag-preset-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; +} +.tag-preset-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-preset-chip { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--tag-bg, #5c6b82); + cursor: pointer; +} +.tag-preset-chip:hover { filter: brightness(1.12); } +.tag-preset-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.tag-preset-group:last-child { margin-bottom: 0; } +.tag-preset-group-label { font-size: 12px; min-width: 5.5rem; } + +.tag-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2, rgba(255, 255, 255, .03)); +} +.tag-filter-label { font-size: 13px; } +.tag-filter-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-filter-chip { + display: inline-block; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--tag-bg, #5c6b82); + text-decoration: none; + border: 2px solid transparent; + opacity: 0.72; +} +.tag-filter-chip:hover { filter: brightness(1.1); opacity: 1; } +.tag-filter-chip--active { + opacity: 1; + border-color: var(--accent, #3d8bfd); + box-shadow: 0 0 0 1px var(--accent, #3d8bfd); +} +.tag-mode-select-wrap { margin-left: auto; } + +.report-tag { + position: relative; + padding-right: 8px; +} +.report-tag .tag-remove { + margin-left: 4px; + padding: 0 3px; + border: none; + border-radius: 4px; + background: rgba(0, 0, 0, .25); + color: #fff; + font-size: 14px; + line-height: 1; + cursor: pointer; + vertical-align: middle; +} +.report-tag .tag-remove:hover { background: rgba(0, 0, 0, .45); } + +.tag-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.tag-modal[hidden] { + display: none !important; + pointer-events: none !important; +} +.tag-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .55); + pointer-events: auto; +} +.tag-modal[hidden] .tag-modal-backdrop { + pointer-events: none; +} +.tag-modal-panel { + position: relative; + width: min(420px, 100%); + max-height: 90vh; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, .4); +} +.tag-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} +.tag-modal-header h2 { margin: 0; font-size: 1.05rem; } +.tag-modal-close { + border: none; + background: transparent; + color: var(--muted); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; +} +.tag-modal-close:hover { color: var(--text); background: var(--row-hover); } +.tag-editor--modal { margin: 0; border: none; border-radius: 0; } + +.console-home-intro { + margin: 0 0 20px; + color: var(--muted); + max-width: 42rem; + line-height: 1.5; +} +.console-home-cards { + margin: 0 0 28px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} +.console-home-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + text-decoration: none; + color: var(--text); + cursor: pointer; + font: inherit; + text-align: left; + width: 100%; +} +.console-home-card--btn { + border: 1px solid var(--border); + background: var(--surface); +} +.console-home-card h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} +.console-home-card p { + margin: 0; + font-size: 0.88rem; + line-height: 1.45; +} +.console-home-card .nav-icon { + opacity: 0.9; +} +.console-home-more { + margin: 8px 0 12px; + font-size: 0.95rem; + font-weight: 600; + color: var(--muted); +} +.graphs-app .graphs-quick-links, +.console-quick-links { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px; + align-items: center; +} +.console-quick-link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 8px; + text-decoration: none; + color: var(--text); + background: var(--surface-2, rgba(255, 255, 255, 0.06)); + font-size: 0.9rem; + line-height: 1.2; + border: 1px solid var(--border, rgba(255, 255, 255, 0.08)); +} +.console-quick-link:hover { + background: var(--row-hover, rgba(255, 255, 255, 0.1)); + color: var(--text); +} +.console-quick-link .nav-icon { + flex-shrink: 0; +} +.nav-icon--git { + width: 18px; + height: 18px; +} +.nav-icon--git::before { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 7px; + height: 7px; + border: 2px solid currentColor; + border-radius: 50%; + box-sizing: border-box; +} +.nav-icon--git::after { + content: ""; + position: absolute; + left: 8px; + top: 5px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-left: none; + border-bottom: none; + border-radius: 0 6px 0 0; + box-sizing: border-box; +} +.nav-icon--builder { + width: 18px; + height: 18px; + border: 2px solid currentColor; + border-radius: 3px; + box-sizing: border-box; + position: relative; +} +.nav-icon--builder::before { + content: ""; + position: absolute; + left: 3px; + top: 7px; + width: 10px; + height: 2px; + background: currentColor; + box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor; +} +.nav-icon--remote { + width: 18px; + height: 18px; +} +.nav-icon--remote::before { + content: ""; + position: absolute; + left: 2px; + top: 8px; + width: 14px; + height: 2px; + background: currentColor; + border-radius: 1px; +} +.nav-icon--remote::after { + content: ""; + position: absolute; + left: 5px; + top: 3px; + width: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 2px; + box-sizing: border-box; +} +.nav-icon--link { + width: 18px; + height: 18px; +} +.nav-icon--link::before { + content: ""; + position: absolute; + left: 1px; + top: 9px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-radius: 3px; + transform: rotate(-45deg); + box-sizing: border-box; +} +.nav-icon--link::after { + content: ""; + position: absolute; + left: 8px; + top: 2px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-radius: 3px; + transform: rotate(-45deg); + box-sizing: border-box; +} +.tag-field--wide { + flex: 1 1 280px; + min-width: 200px; +} +.tag-field--wide input { + width: 100%; +} +.tag-field--check span { + display: flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} +.tag-field--check input[type="checkbox"] { + width: auto; + margin: 0; +} +.graph-quick-link { font-size: 0.9rem; } +.graphs-scope { margin-top: 20px; } +.graphs-scope-title { + margin: 0 0 12px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); +} +.graphs-app .graphs-grid { + display: grid; + grid-template-columns: repeat(var(--graphs-columns, 2), minmax(0, 1fr)); + gap: 16px; + width: 100%; +} +.graphs-grid .card.card--lift.graph-brick--clickable { + cursor: pointer; + min-height: 12rem; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +.graphs-grid .card.card--lift.graph-brick--clickable:hover, +.graphs-grid .card.card--lift.graph-brick--clickable:focus-visible { + box-shadow: 0 0 0 1px var(--accent, #3d8bfd); + outline: none; +} +.graphs-grid .card.card--lift.graph-brick--clickable h3::after { + content: " ↗"; + font-size: 0.75em; + color: var(--muted, #94a3b8); + font-weight: 400; +} +.graphs-grid .card.card--lift.graph-brick--empty { + display: none; +} +.graphs-grid .card.card--lift.graph-brick--dragging { + opacity: 0.55; + outline: 2px dashed var(--accent); +} +.graphs-grid .card.card--lift h3.graph-brick-head { + display: flex; + align-items: center; + gap: 8px; +} +.graphs-session-notice { + margin: 0 0 12px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(245, 158, 11, 0.45); + background: rgba(245, 158, 11, 0.12); + color: var(--text); + font-size: 0.9rem; + line-height: 1.45; +} +.graphs-session-notice[hidden] { + display: none; +} +.graph-brick-drag-handle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: -2px 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface2); + color: var(--text); + font: inherit; + line-height: 1; + cursor: grab; + user-select: none; + touch-action: none; +} +.graph-brick-drag-handle:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--row-hover); +} +.graph-brick-drag-handle:active { + cursor: grabbing; +} +.graph-brick-drag-grip { + font-size: 12px; + letter-spacing: -2px; + pointer-events: none; +} +.graph-brick-tip { + position: absolute; + z-index: 2; + margin: 0; + padding: 4px 8px; + border-radius: 6px; + background: var(--surface2); + border: 1px solid var(--border); + font-size: 0.8rem; + pointer-events: none; + white-space: nowrap; +} +.ticket-create-dialog { + border: 1px solid var(--border); + border-radius: 12px; + padding: 0; + margin: auto; + max-width: 36rem; + width: calc(100% - 32px); + background: var(--surface); + color: var(--text); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); +} +.ticket-create-dialog::backdrop { + background: rgba(8, 12, 20, 0.72); +} +.ticket-create-form { + display: flex; + flex-direction: column; + margin: 0; +} +.ticket-create-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} +.ticket-create-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} +.ticket-create-close { + border: none; + background: transparent; + color: var(--muted); + font-size: 1.4rem; + line-height: 1; + padding: 4px 8px; + border-radius: 8px; + cursor: pointer; +} +.ticket-create-close:hover { + color: var(--text); + background: var(--row-hover); +} +.ticket-create-body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; +} +.ticket-create-body label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.9rem; + color: var(--muted); +} +.ticket-create-body input, +.ticket-create-body textarea { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font: inherit; +} +.ticket-create-footer { + padding: 12px 18px 16px; + border-top: 1px solid var(--border); +} +.ticket-create-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; +} +.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; min-height: 120px; } +.graph-detail-overlay { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(8, 12, 20, 0.92); + display: flex; + flex-direction: column; + pointer-events: auto; +} +.graph-detail-overlay[hidden] { + display: none !important; + pointer-events: none !important; +} +.graph-detail-panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + max-height: 100vh; + background: var(--bg, #0f1419); +} +.graph-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.1)); + flex-shrink: 0; +} +.graph-detail-header-text { + flex: 1; + min-width: 0; +} +.graph-detail-header h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 600; +} +.graph-detail-stats { + margin: 4px 0 0; + font-size: 0.88rem; +} +.graph-detail-canvas-wrap { + position: relative; + flex: 1; + overflow: auto; + padding: 16px 20px 24px; +} +.graph-detail-canvas-wrap canvas { + display: block; + width: 100%; + max-width: 100%; + height: auto; +} +.graph-detail-body { + max-width: 960px; +} +.graph-detail-kpi { + font-size: 3rem; +} +.graph-detail-tooltip { + position: absolute; + z-index: 3; + margin: 0; + padding: 6px 10px; + border-radius: 6px; + background: var(--surface-2, #1e293b); + border: 1px solid var(--border, rgba(255, 255, 255, 0.15)); + color: var(--text); + font-size: 0.85rem; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} +.graph-detail-table { + font-size: 1rem; + max-width: 720px; +} +.graph-kpi { + font-size: 2rem; + font-weight: 700; + margin: 12px 0 0; + color: var(--accent, #3d8bfd); +} +.graph-breakdown { font-size: 0.92rem; } +.graph-breakdown-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); +} +.graph-breakdown-row a { color: var(--accent, #3d8bfd); text-decoration: none; } +.graph-breakdown-row a:hover { text-decoration: underline; } +.graphs-footnote { margin-top: 20px; font-size: 0.88rem; } + +.tag-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + background: rgba(61, 139, 253, .18); + color: var(--accent); +} +.tag-pill--warn { + background: rgba(245, 158, 11, .18); + color: #fbbf24; +} +[data-theme="light"] .tag-pill--warn { + color: #b45309; +} +.ra-device-row--needs-wl { + background: rgba(245, 158, 11, .06); +} +#ra-devices-table .report-row[data-device-id] { + cursor: pointer; +} +#ra-devices-table .col-actions, +#ra-devices-table .ra-device-actions { + white-space: nowrap; +} +.reports-table--cols .col-actions { + min-width: 160px; +} +.ra-device-actions { + white-space: nowrap; +} +.ra-detail-links { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} +.ra-whitelist-hint { + max-width: 72ch; + line-height: 1.5; +} + +/* Live cast pages */ +.live-page { + min-height: 100vh; + background: var(--bg-shell, #0f1419); +} +.live-shell { + max-width: 880px; + margin: 0 auto; + padding: 24px 16px 48px; +} +.live-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.live-brand { + font-weight: 600; + color: var(--text-primary, #e2e8f0); + text-decoration: none; +} +.live-card h1 { + margin-top: 0; +} +.live-meta-list { + list-style: none; + padding: 0; + margin: 12px 0; +} +.live-meta-list li { + padding: 4px 0; + color: var(--text-muted, #94a3b8); +} +.live-player-wrap, +.live-player-shell { + margin-top: 16px; + min-height: 200px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.55); + padding: 16px; +} +.live-player-placeholder { + font-size: 1.1rem; + margin: 0 0 8px; +} +.live-preview { + width: 100%; + max-height: 360px; + border-radius: 10px; + background: #000; + margin: 12px 0; +} +.live-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.live-timer-label { + font-variant-numeric: tabular-nums; +} + +/* Cookie consent */ +.cookie-consent { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1200; + padding: 12px; + pointer-events: none; +} +.cookie-consent-inner { + pointer-events: auto; + max-width: 720px; + margin: 0 auto; + padding: 16px 18px; + border-radius: 14px; + background: var(--card-bg, rgba(30, 41, 59, 0.96)); + border: 1px solid rgba(148, 163, 184, 0.25); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} +.cookie-consent-title { + margin: 0 0 6px; + font-weight: 600; +} +.cookie-consent-text { + margin: 0 0 12px; + font-size: 0.92rem; + line-height: 1.45; +} +.cookie-consent-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Nav icons: live + education */ +.nav-icon--live { + width: 18px; + height: 16px; +} +.nav-icon--education { + width: 18px; + height: 16px; +} +.nav-icon--live::before { + content: ''; + position: absolute; + inset: 7px 11px 11px 7px; + border: 2px solid currentColor; + border-radius: 4px; +} +.nav-icon--live::after { + content: ''; + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + background: #34d399; + top: 6px; + right: 6px; + box-shadow: 0 0 0 2px var(--nav-bg, #111827); +} +.nav-icon--education::before { + content: ''; + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + height: 10px; + border: 2px solid currentColor; + border-top: none; + border-radius: 0 0 4px 4px; +} +.nav-icon--education::after { + content: ''; + position: absolute; + left: 10px; + right: 10px; + top: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 8px 8px 0 0; +} + +.tag-pill--ok { + background: rgba(52, 211, 153, 0.15); + color: #6ee7b7; +} + +.twofa-qr-wrap { + margin: 12px 0 16px; + text-align: center; +} +.twofa-qr { + border-radius: 8px; + background: #fff; + padding: 6px; +} +.twofa-qr-wrap figcaption { + margin-top: 8px; + font-size: 0.88rem; +} + +.live-share { + margin-top: 16px; + padding: 16px; +} +.live-share h2 { + margin: 0 0 8px; + font-size: 1.05rem; +} +.live-stats-nerds { + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.72); + border: 1px solid rgba(148, 163, 184, 0.25); + font-size: 0.78rem; + line-height: 1.45; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +[data-theme="light"] .live-stats-nerds { + background: rgba(241, 245, 249, 0.95); + border-color: rgba(100, 116, 139, 0.35); +} +.live-player-wrap video.live-preview { + width: 100%; + max-height: 60vh; + background: #000; + border-radius: 8px; +} +.platform-footer__left a { + color: var(--accent, #5b8def); + text-decoration: none; +} +.platform-footer__left a:hover, +.platform-footer__left a:focus-visible { + text-decoration: underline; +} diff --git a/sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json b/sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json new file mode 100644 index 0000000..204b525 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json @@ -0,0 +1,218 @@ +{ + "app_name": "Android Cast Issues", + "nav.home": "Home", + "nav.reports": "Issues", + "nav.tickets": "Tickets", + "nav.logout": "Logout", + "nav.toggle": "Navigation", + "nav.home_desc": "Console home and workspace cards.", + "nav.reports_desc": "Browse and triage issues.", + "nav.tickets_desc": "Roadmap tasks, QA items, and tags.", + "nav.graphs": "Graphs", + "nav.graphs_desc": "Sessions, issues, and device activity.", + "nav.remote": "Remote access", + "nav.remote_desc": "WireGuard sessions and device reachability.", + "nav.access": "Access", + "nav.access_desc": "Roles, privilege sets, and operators.", + "nav.builder": "Builder", + "nav.builder_desc": "Docker CI for APK baking and OTA artifacts.", + "theme.label": "Theme", + "theme.dark": "Dark", + "theme.light": "Light", + "lang.label": "Language", + "lang.en": "English", + "lang.ru": "Russian", + "home.title": "Console", + "home.intro": "Issue triage, tickets, and session metrics — pick a workspace below.", + "home.card_issues": "Issues", + "home.card_issues_desc": "Browse and triage issues, tags, and fingerprints.", + "home.card_new_issue_desc": "Log a new issue or task in one step.", + "home.card_new_ticket_desc": "Create a roadmap or QA ticket in one step.", + "home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.", + "home.card_graphs": "Graphs", + "home.card_graphs_desc": "Sessions, issues, and device activity over time.", + "home.card_short_links": "Short links", + "home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.", + "home.more": "All services", + "reports.title": "Issues", + "reports.by_time": "By time", + "reports.grouped": "Grouped", + "reports.per_page": "Per page", + "reports.search": "Search", + "reports.search_placeholder": "Keywords (exceptions, devices, stack traces…)", + "reports.clear": "Clear", + "reports.loading": "Loading…", + "reports.empty": "No reports yet.", + "reports.status_showing": "Showing {from}–{to} of {total}", + "reports.invalid_response": "Invalid response", + "reports.load_failed": "Load failed", + "reports.network_error": "Network error", + "reports.mode_disabled_filter": "Disabled while a filter is active", + "filter.banner_similar": "Similar reports for #{id}", + "filter.banner_device": "Device: “{value}”", + "filter.banner_abi": "ABI: {value}", + "filter.banner_sdk": "SDK {value}", + "filter.banner_os": "Android {value}", + "filter.banner_app": "App {value}", + "filter.banner_build": "Build {value}", + "filter.banner_search": "Search: “{value}”", + "filter.clear": "Clear filters", + "pagination.previous": "← Previous", + "pagination.next": "Next →", + "pagination.previous_range": "← Previous ({before}/{through})", + "pagination.next_range": "Next ({start}/{end}) →", + "col.device": "Device", + "col.app": "App", + "col.os": "OS", + "col.os_ver": "OS ver", + "col.received": "Received", + "col.rating": "Rating", + "col.count": "Count", + "col.fingerprint": "Fingerprint", + "col.type": "Type", + "col.last_generated": "Last generated", + "col.last_received": "Last received", + "col.tags": "Tags", + "col.expand": "Expand", + "col.drag": "Drag column", + "col.drag_reorder": "Drag column to reorder", + "graphs.brick_drag": "Drag chart to reorder", + "graphs.session_deficit": "Session charts are empty. On the device: Developer settings → enable “Grab session stats”, cast a session, then wait for upload to graph_upload.php.", + "row.show_summary": "Show summary", + "row.open_report": "Click to open full report", + "row.edit_tags": "Edit tags", + "tag.new": "new", + "tag.java": "Java", + "tag.ndk": "NDK", + "tag.android": "Android", + "tag.edit_title": "Edit tags", + "tag.close": "Close", + "tag.label": "Label", + "tag.color": "Color", + "tag.add": "Add", + "tag.add_tag": "Add tag", + "tag.save": "Save", + "tag.save_tags": "Save tags", + "tag.suggestions": "Suggestions:", + "tag.placeholder": "e.g. regression", + "tag.hint_auto": "Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.", + "tag.hint_admin": "Sign in as admin to edit custom tags.", + "tag.none_custom": "No custom tags yet.", + "tag.none_preview": "No custom tags", + "tag.remove": "Remove tag", + "tag.saving": "Saving…", + "tag.saved": "Saved", + "tag.save_failed": "Save failed", + "tag.workflow": "Workflow", + "tag.filter_label": "Filter by tag", + "tag.filter_mode": "Match", + "tag.filter_and": "All tags (AND)", + "tag.filter_or": "Any tag (OR)", + "detail.back": "Back to list", + "detail.title": "Issue report", + "detail.report_meta": "Report {id} · {type}", + "detail.tags": "Tags", + "detail.device": "Device", + "detail.app": "App", + "detail.timing": "Timing", + "detail.abis": "ABIs:", + "detail.build": "Build:", + "detail.generated": "Generated:", + "detail.received": "Received:", + "detail.fingerprint": "Fingerprint:", + "detail.filter_device": "Show reports for similar devices", + "detail.filter_os": "Show reports on this OS", + "detail.filter_version": "Show reports for this app version", + "detail.filter_build": "Show reports from this build", + "detail.java_exception": "Java exception", + "detail.native_crash": "Native issue", + "detail.find_similar": "Find similar issues", + "detail.thread": "Thread:", + "detail.signal": "Signal:", + "detail.session_context": "Session context (issue)", + "detail.raw_json": "Raw JSON", + "login.sign_in_hint": "Sign in to view anonymous issue reports", + "login.username": "Username", + "login.password": "Password", + "login.submit": "Sign in", + "login.hint_default": "Default: admin / admin", + "login.register": "Register", + "login.register_soon": "(coming soon)", + "login.error": "Invalid credentials", + "register.title": "Create account", + "register.hint": "We will email a verification link before you can sign in.", + "register.email": "Email", + "register.username": "Username (optional)", + "register.password": "Password", + "register.submit": "Register", + "register.back_login": "Back to sign in", + "verify.title": "Email verification", + "verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.", + "verify.fail": "Invalid or expired link.", + "verify.sign_in": "Sign in", + "verify.register_again": "Register again", + "twofa.title": "Two-factor authentication", + "twofa.hint": "Enter the 6-digit code from your authenticator app.", + "twofa.code": "Authentication code", + "twofa.submit": "Continue", + "twofa.cancel": "Cancel", + "twofa.error": "Invalid code", + "security.title": "Account security", + "security.totp_intro": "Protect your account with a 6-digit authenticator app (recommended for alpha).", + "security.start_totp": "Set up authenticator", + "security.scan_qr": "Scan this QR code with your authenticator app, then enter a code to confirm.", + "security.manual_secret": "Can't scan?", + "security.confirm_totp": "Confirm enrollment", + "security.totp_enabled": "Authenticator app is enrolled.", + "security.remove_totp": "Remove authenticator", + "security.back_console": "Back to console", + "nav.security": "Security", + "footer.copyright": "© Anton Afanaasyeu, {year}", + "tickets.title": "Tickets", + "issues.new": "New issue", + "tickets.new": "New ticket", + "ticket.create": "Create", + "ticket.cancel": "Cancel", + "ticket.title": "Title", + "ticket.brief": "Brief", + "ticket.body": "Description", + "tickets.empty": "No tickets yet.", + "tickets.col_issue": "Issue", + "tickets.col_opened": "Opened", + "tickets.col_env": "App / OS", + "tickets.filter_tag": "Status tag", + "tickets.filter_all": "All", + "ticket.back": "Back to tickets", + "ticket.detail_title": "Ticket", + "ticket.id_label": "ID", + "ticket.tag_hint": "Must include ticket and a status tag (open, in progress, closed, …).", + "ticket.tag_readonly": "Only the owner or an admin can edit tags.", + "ticket.issue": "Issue", + "ticket.caption": "Caption", + "ticket.summary": "Summary", + "ticket.description": "Description", + "ticket.workflow": "Workflow", + "ticket.owner": "Owner", + "ticket.verified_by": "Verified by", + "ticket.timing": "Timing", + "ticket.opened": "Opened", + "ticket.created": "Created", + "ticket.updated": "Updated", + "ticket.save": "Save ticket", + "ticket.source": "Source / context", + "ticket.lifecycle": "Lifecycle", + "ticket.assignees": "Assignees", + "ticket.assignee_add": "Add assignee", + "ticket.comments": "Comments", + "ticket.comment_add": "Add comment", + "ticket.comment_post": "Post comment", + "ticket.attachments": "Attachments", + "ticket.attach_link": "Add link", + "ticket.attach_link_hint": "Google Docs, Sheets, Drive, Meet, or any HTTPS URL.", + "ticket.attach_link_btn": "Attach link", + "ticket.attach_file": "Upload file", + "ticket.attach_file_hint": "Max 16 MiB — images, logs, pdf, zip, …", + "ticket.attach_file_btn": "Upload", + "ticket.link_title": "Title (optional)", + "ticket.file_pick": "File" +} diff --git a/sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json b/sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json new file mode 100644 index 0000000..b72d81c --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json @@ -0,0 +1,196 @@ +{ + "app_name": "Сбои Android Cast", + "nav.home": "Главная", + "nav.reports": "Задачи (Issues)", + "nav.tickets": "Задачи", + "tickets.title": "Задачи", + "issues.new": "Новая проблема", + "tickets.new": "Новый тикет", + "ticket.create": "Создать", + "ticket.cancel": "Отмена", + "ticket.title": "Заголовок", + "ticket.brief": "Кратко", + "ticket.body": "Описание", + "tickets.empty": "Задач пока нет.", + "ticket.detail_title": "Задача", + "nav.logout": "Выход", + "nav.toggle": "Навигация", + "nav.home_desc": "Главная консоли и карточки разделов.", + "nav.reports_desc": "Просмотр и разбор issues.", + "nav.tickets_desc": "Задачи roadmap, QA и теги.", + "nav.graphs": "Графики", + "nav.graphs_desc": "Сессии, issues и активность устройств.", + "nav.remote": "Удалённый доступ", + "nav.remote_desc": "Сессии WireGuard и доступ к устройствам.", + "nav.access": "Доступ", + "nav.access_desc": "Роли, наборы привилегий и операторы.", + "nav.builder": "Сборщик", + "nav.builder_desc": "Docker CI для сборки APK и OTA.", + "theme.label": "Тема", + "theme.dark": "Тёмная", + "theme.light": "Светлая", + "lang.label": "Язык", + "lang.en": "English", + "lang.ru": "Русский", + "home.title": "Консоль", + "home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.", + "home.card_issues": "Проблемы", + "home.card_issues_desc": "Просмотр и разбор issues, тегов и отпечатков.", + "home.card_new_issue_desc": "Создать новую проблему или задачу.", + "home.card_new_ticket_desc": "Создать тикет roadmap или QA за один шаг.", + "home.card_tickets_desc": "Задачи, QA и теги workflow.", + "home.card_graphs": "Графики", + "home.card_graphs_desc": "Сессии, issues и активность устройств.", + "home.card_short_links": "Короткие ссылки", + "home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.", + "home.more": "Все сервисы", + "reports.title": "Issues", + "reports.by_time": "По времени", + "reports.grouped": "Группы", + "reports.per_page": "На странице", + "reports.search": "Поиск", + "reports.search_placeholder": "Ключевые слова (исключения, устройства, стеки…)", + "reports.clear": "Очистить", + "reports.loading": "Загрузка…", + "reports.empty": "Отчётов пока нет.", + "reports.status_showing": "Показано {from}–{to} из {total}", + "reports.invalid_response": "Некорректный ответ", + "reports.load_failed": "Ошибка загрузки", + "reports.network_error": "Сетевая ошибка", + "reports.mode_disabled_filter": "Недоступно при активном фильтре", + "filter.banner_similar": "Похожие отчёты для №{id}", + "filter.banner_device": "Устройство: «{value}»", + "filter.banner_abi": "ABI: {value}", + "filter.banner_sdk": "SDK {value}", + "filter.banner_os": "Android {value}", + "filter.banner_app": "Приложение {value}", + "filter.banner_build": "Сборка {value}", + "filter.banner_search": "Поиск: «{value}»", + "filter.clear": "Сбросить фильтры", + "pagination.previous": "← Назад", + "pagination.next": "Вперёд →", + "pagination.previous_range": "← Назад ({before}/{through})", + "pagination.next_range": "Вперёд ({start}/{end}) →", + "col.device": "Устройство", + "col.app": "Приложение", + "col.os": "ОС", + "col.os_ver": "Версия ОС", + "col.received": "Получен", + "col.rating": "Рейтинг", + "col.count": "Кол-во", + "col.fingerprint": "Отпечаток", + "col.type": "Тип", + "col.last_generated": "Создан", + "col.last_received": "Получен", + "col.tags": "Метки", + "col.expand": "Развернуть", + "col.drag": "Перетащить столбец", + "col.drag_reorder": "Перетащите для смены порядка столбцов", + "graphs.brick_drag": "Перетащите график для смены порядка", + "graphs.session_deficit": "Графики сессий пусты. На устройстве: Настройки разработчика → «Grab session stats», проведите сессию трансляции и дождитесь загрузки в graph_upload.php.", + "row.show_summary": "Показать сводку", + "row.open_report": "Нажмите, чтобы открыть полный отчёт", + "row.edit_tags": "Редактировать метки", + "tag.new": "новый", + "tag.java": "Java", + "tag.ndk": "NDK", + "tag.android": "Android", + "tag.edit_title": "Редактирование меток", + "tag.close": "Закрыть", + "tag.label": "Название", + "tag.color": "Цвет", + "tag.add": "Добавить", + "tag.add_tag": "Добавить метку", + "tag.save": "Сохранить", + "tag.save_tags": "Сохранить метки", + "tag.suggestions": "Подсказки:", + "tag.hint_auto": "Автометки (NDK / Java / Android / новый) в списке. Ниже можно добавить свои.", + "tag.hint_admin": "Войдите как администратор, чтобы редактировать метки.", + "tag.none_custom": "Пользовательских меток пока нет.", + "tag.none_preview": "Нет пользовательских меток", + "tag.remove": "Удалить метку", + "tag.saving": "Сохранение…", + "tag.saved": "Сохранено", + "tag.save_failed": "Не удалось сохранить", + "tag.workflow": "Статус", + "tag.filter_label": "Фильтр по метке", + "tag.filter_mode": "Совпадение", + "tag.filter_and": "Все метки (И)", + "tag.filter_or": "Любая метка (ИЛИ)", + "detail.back": "К списку", + "detail.title": "Issue-отчёт", + "detail.report_meta": "Отчёт {id} · {type}", + "detail.tags": "Метки", + "detail.device": "Устройство", + "detail.app": "Приложение", + "detail.timing": "Время", + "detail.abis": "ABI:", + "detail.build": "Сборка:", + "detail.generated": "Создан:", + "detail.received": "Получен:", + "detail.fingerprint": "Отпечаток:", + "detail.filter_device": "Показать отчёты для похожих устройств", + "detail.filter_os": "Показать отчёты с этой ОС", + "detail.filter_version": "Показать отчёты для этой версии приложения", + "detail.filter_build": "Показать отчёты этой сборки", + "detail.java_exception": "Исключение Java", + "detail.native_crash": "Нативный issue", + "detail.find_similar": "Найти похожие отчёты", + "detail.thread": "Поток:", + "detail.signal": "Сигнал:", + "detail.session_context": "Контекст сессии (issue)", + "detail.raw_json": "Исходный JSON", + "login.sign_in_hint": "Войдите для просмотра анонимных issue-отчётов", + "login.username": "Имя пользователя", + "login.password": "Пароль", + "login.submit": "Войти", + "login.hint_default": "По умолчанию: admin / admin", + "login.register": "Регистрация", + "login.register_soon": "(скоро)", + "login.error": "Неверные учётные данные", + "register.title": "Создать аккаунт", + "register.hint": "Мы отправим ссылку для подтверждения email перед входом.", + "register.email": "Email", + "register.username": "Имя пользователя (необязательно)", + "register.password": "Пароль", + "register.submit": "Зарегистрироваться", + "register.back_login": "Назад ко входу", + "verify.title": "Подтверждение email", + "verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.", + "verify.fail": "Недействительная или просроченная ссылка.", + "verify.sign_in": "Войти", + "verify.register_again": "Зарегистрироваться снова", + "twofa.title": "Двухфакторная аутентификация", + "twofa.hint": "Введите 6-значный код из приложения-аутентификатора.", + "twofa.code": "Код аутентификации", + "twofa.submit": "Продолжить", + "twofa.cancel": "Отмена", + "twofa.error": "Неверный код", + "security.title": "Безопасность аккаунта", + "security.totp_intro": "Защитите аккаунт приложением-аутентификатором (рекомендуется для альфы).", + "security.start_totp": "Настроить аутентификатор", + "security.scan_qr": "Отсканируйте QR-код и введите код для подтверждения.", + "security.manual_secret": "Не удаётся сканировать?", + "security.confirm_totp": "Подтвердить настройку", + "security.totp_enabled": "Приложение-аутентификатор подключено.", + "security.remove_totp": "Отключить аутентификатор", + "security.back_console": "Назад в консоль", + "nav.security": "Безопасность", + "footer.copyright": "© Anton Afanaasyeu, {year}", + "nav.tickets": "Тикеты", + "ticket.lifecycle": "Жизненный цикл", + "ticket.assignees": "Исполнители", + "ticket.assignee_add": "Добавить", + "ticket.comments": "Комментарии", + "ticket.comment_add": "Новый комментарий", + "ticket.comment_post": "Отправить", + "ticket.attachments": "Вложения", + "ticket.attach_link": "Ссылка", + "ticket.attach_link_hint": "Google Docs, Таблицы, Drive, Meet или любой HTTPS URL.", + "ticket.attach_link_btn": "Прикрепить", + "ticket.attach_file": "Файл", + "ticket.attach_file_hint": "До 16 МБ — изображения, логи, pdf, zip…", + "ticket.attach_file_btn": "Загрузить", + "ticket.link_title": "Название (необяз.)", + "ticket.file_pick": "Файл" +} diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/analytics.js b/sim/cluster0/lab-seeds/backend/public/assets/js/analytics.js new file mode 100644 index 0000000..be542e6 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/analytics.js @@ -0,0 +1,157 @@ +/** + * Google Analytics 4 for Android Cast web platform (hub + crash console). + * Set window.ANDROIDCAST_ANALYTICS before this script loads: + * { measurementId: 'G-XXXXXXXX', platform: 'hub'|'crashes', debug: false } + */ +(function (global) { + 'use strict'; + + var cfg = global.ANDROIDCAST_ANALYTICS || {}; + var measurementId = (cfg.measurementId || '').trim(); + var platform = cfg.platform || 'web'; + var debug = !!cfg.debug; + + function log() { + if (debug && global.console) { + global.console.log.apply(global.console, ['[analytics]'].concat([].slice.call(arguments))); + } + } + + function enabled() { + if (global.AndroidCastCookieConsent && !global.AndroidCastCookieConsent.allowsAnalytics()) { + var choice = global.AndroidCastCookieConsent.getChoice && global.AndroidCastCookieConsent.getChoice(); + if (choice && choice !== 'all') { + return false; + } + } + return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId); + } + + function ensureGtag(cb) { + if (!enabled()) { + return; + } + global.dataLayer = global.dataLayer || []; + if (!global.gtag) { + global.gtag = function () { + global.dataLayer.push(arguments); + }; + } + if (global.__acAnalyticsLoaded) { + if (cb) cb(); + return; + } + var s = document.createElement('script'); + s.async = true; + s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(measurementId); + s.onload = function () { + global.__acAnalyticsLoaded = true; + global.gtag('js', new Date()); + global.gtag('config', measurementId, { + send_page_view: false, + anonymize_ip: true + }); + log('loaded', measurementId); + if (cb) cb(); + }; + document.head.appendChild(s); + } + + function pagePath() { + var base = (cfg.basePath || '').replace(/\/$/, ''); + var path = global.location.pathname || '/'; + if (base && path.indexOf(base) === 0) { + path = path.slice(base.length) || '/'; + } + return path; + } + + function pageView(extra) { + if (!enabled()) return; + ensureGtag(function () { + var params = { + page_title: document.title || '', + page_location: global.location.href, + page_path: pagePath(), + platform: platform + }; + if (extra) { + for (var k in extra) { + if (Object.prototype.hasOwnProperty.call(extra, k)) params[k] = extra[k]; + } + } + global.gtag('event', 'page_view', params); + log('page_view', params); + }); + } + + function trackEvent(name, params) { + if (!enabled() || !name) return; + ensureGtag(function () { + var payload = params || {}; + payload.platform = platform; + global.gtag('event', name, payload); + log('event', name, payload); + }); + } + + function bindConsoleView() { + var body = document.body; + if (!body) return; + var view = body.getAttribute('data-view'); + if (view) { + trackEvent('console_view', { view_name: view }); + } + } + + function bindHubInteractions() { + document.addEventListener('click', function (ev) { + var t = ev.target; + if (!(t instanceof Element)) return; + var card = t.closest('.hub-card, .hub-card-link'); + if (card) { + var label = card.getAttribute('data-analytics-label') + || (card.textContent || '').trim().slice(0, 64); + trackEvent('hub_nav_click', { link_text: label }); + return; + } + if (t.closest('[data-open-docs], #hub-compass-layer, .hub-logo-layer--compass')) { + trackEvent('hub_open_docs', { source: 'compass' }); + } + if (t.closest('[data-close-docs]')) { + trackEvent('hub_close_docs', {}); + } + }); + } + + function init() { + if (!enabled()) { + log('disabled (no measurement id or cookie consent)'); + return; + } + pageView(); + if (platform === 'crashes' || String(platform).indexOf('live_') === 0) { + bindConsoleView(); + } else if (platform === 'hub') { + bindHubInteractions(); + } + } + + global.addEventListener('ac-cookie-consent', function () { + if (enabled()) { + init(); + } + }); + + global.AndroidCastAnalytics = { + enabled: enabled, + pageView: pageView, + trackEvent: trackEvent + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(typeof window !== 'undefined' ? window : this); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/app.js b/sim/cluster0/lab-seeds/backend/public/assets/js/app.js new file mode 100644 index 0000000..9ce9c3e --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/app.js @@ -0,0 +1,2067 @@ +(function () { + function t(key, params) { + return window.CrashI18n ? window.CrashI18n.t(key, params) : key; + } + + const STORAGE = { + theme: 'crash_console_theme', + perPage: 'crash_console_per_page', + lastVisit: 'crash_console_last_visit_ms', + viewed: 'crash_console_viewed_ids', + sort: 'crash_console_sort', + skipStamp: 'crash_skip_visit_stamp', + colWidths: 'crash_console_col_widths', + colOrder: 'crash_console_col_order', + }; + + const DEFAULT_COL_WIDTHS = { + expand: 40, + device_model: 128, + app_version: 80, + os_name: 88, + os_version: 120, + received_at_ms: 152, + rating: 92, + tags: 128, + }; + + function tagNew() { + return { label: t('tag.new'), bg: '#3d8bfd' }; + } + + const LIST_COLUMNS = [ + { key: 'device_model', labelKey: 'col.device' }, + { key: 'app_version', labelKey: 'col.app' }, + { key: 'os_name', labelKey: 'col.os' }, + { key: 'os_version', labelKey: 'col.os_ver' }, + { key: 'received_at_ms', labelKey: 'col.received', fmt: 'time' }, + { key: 'rating', labelKey: 'col.rating', fmt: 'stars' }, + ]; + + const SORT_API_MAP = { + priority: 'priority', + rating: 'fingerprint_cnt', + os_name: 'device_model', + os_version: 'app_version', + }; + + const GROUP_COLUMNS = [ + { key: 'cnt', labelKey: 'col.count' }, + { key: 'rating', labelKey: 'col.rating', fmt: 'stars' }, + { key: 'fingerprint', labelKey: 'col.fingerprint', fmt: 'fp' }, + { key: 'crash_type', labelKey: 'col.type' }, + { key: 'last_generated_ms', labelKey: 'col.last_generated', fmt: 'time' }, + { key: 'last_received_ms', labelKey: 'col.last_received', fmt: 'time' }, + ]; + + function colLabel(col) { + if (!col) return ''; + return t(col.labelKey || 'col.' + col.key); + } + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function lsGet(key, fallback) { + try { + const v = localStorage.getItem(key); + return v === null ? fallback : v; + } catch { + return fallback; + } + } + + function lsSet(key, value) { + try { + localStorage.setItem(key, value); + } catch { /* ignore */ } + } + + function getViewedIds() { + try { + const raw = lsGet(STORAGE.viewed, '[]'); + const arr = JSON.parse(raw); + return new Set(Array.isArray(arr) ? arr.map(Number).filter(Boolean) : []); + } catch { + return new Set(); + } + } + + function addViewedId(id) { + const set = getViewedIds(); + set.add(Number(id)); + lsSet(STORAGE.viewed, JSON.stringify([...set])); + } + + function lastVisitMs() { + return Number(lsGet(STORAGE.lastVisit, '0')) || 0; + } + + function stampLastVisit() { + lsSet(STORAGE.lastVisit, String(Date.now())); + } + + function applyTheme(theme) { + const t = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', t); + lsSet(STORAGE.theme, t); + const sel = document.getElementById('theme-select'); + if (sel) sel.value = t; + } + + function initTheme() { + applyTheme(lsGet(STORAGE.theme, 'dark')); + const sel = document.getElementById('theme-select'); + if (sel) { + sel.addEventListener('change', () => applyTheme(sel.value)); + } + } + + function initNav() { + /* nav_shell.js handles toggle + drag on #nav-pane / #nav-handle */ + } + + function markReportViewed(id) { + if (!id) return; + addViewedId(id); + const xhr = new XMLHttpRequest(); + xhr.open('POST', basePath() + '/api/report_viewed.php', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(JSON.stringify({ id: Number(id) })); + } + + function bindDetailTree(root) { + root.querySelectorAll('[data-tree-toggle]').forEach((row) => { + const panelId = row.getAttribute('data-controls'); + const panel = panelId ? document.getElementById(panelId) : null; + const btn = row.querySelector('.report-tree-toggle'); + if (!panel) return; + const toggle = (e) => { + if (e && e.target.closest('.report-tree-toggle') && e.type === 'click') { + e.stopPropagation(); + } + const open = panel.hidden; + panel.hidden = !open; + if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + row.classList.toggle('report-row--open', open); + }; + if (btn) { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggle(e); + }); + } + row.addEventListener('click', () => toggle()); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }); + }); + } + + function initReportDetail() { + const id = document.body.getAttribute('data-report-id'); + if (!id) return; + markReportViewed(id); + const tree = document.getElementById('detail-tree'); + if (tree) bindDetailTree(tree); + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function formatTime(ms) { + if (!ms) return '—'; + const d = new Date(Number(ms)); + return d.toISOString().replace('T', ' ').slice(0, 19); + } + + function formatCell(col, row) { + const v = row[col.key]; + if (col.fmt === 'time') return formatTime(v); + if (col.fmt === 'stars') return formatStars(Number(row.rating ?? 0)); + if (col.fmt === 'fp') { + const s = String(v || ''); + return '' + escapeHtml(s.slice(0, 12)) + (s.length > 12 ? '…' : '') + ''; + } + return escapeHtml(v ?? '') || ''; + } + + function formatStars(n) { + const score = Math.max(0, Math.min(5, Number(n) || 0)); + let html = + ''; + for (let i = 1; i <= 5; i++) { + html += ''; + } + return html + ''; + } + + function crashKindTag(crashType) { + const kind = String(crashType || '').toLowerCase(); + const map = { + native: { labelKey: 'tag.ndk', bg: '#b45309' }, + java: { labelKey: 'tag.java', bg: '#6d28d9' }, + android: { labelKey: 'tag.android', bg: '#047857' }, + }; + const hit = map[kind]; + if (hit) return { label: t(hit.labelKey), bg: hit.bg }; + return { label: kind || '?', bg: '#5c6b82' }; + } + + function renderTagPills(tags, filterable) { + if (!tags.length) return ''; + return tags + .map((t) => { + const label = escapeHtml(t.label || t.id || ''); + const bg = escapeHtml(t.bg || '#5c6b82'); + const id = t.id ? String(t.id).trim() : ''; + if (filterable && id) { + return ( + '' + ); + } + return '' + label + ''; + }) + .join(''); + } + + function buildTags(row, grouped) { + const tags = []; + const kind = crashKindTag(row.crash_type); + tags.push(kind); + if (!grouped && row.id) { + const viewed = row.viewed || getViewedIds().has(Number(row.id)); + const sinceVisit = Number(row.received_at_ms) > lastVisitMs(); + if (!viewed && (lastVisitMs() === 0 || sinceVisit)) { + tags.push(tagNew()); + } + } + if (Array.isArray(row.tags)) { + row.tags.forEach((t) => { + if (!t || !t.id) return; + const id = String(t.id).toLowerCase(); + if (id === 'new' || id === 'java' || id === 'ndk' || id === 'android') return; + tags.push({ + id, + label: t.label || t.id, + bg: t.bg || '#5c6b82', + }); + }); + } + return renderTagPills(tags, !grouped); + } + + function apiSortKey(key) { + return SORT_API_MAP[key] || key; + } + + function loadSavedSort(isGrouped) { + try { + const s = JSON.parse(lsGet(STORAGE.sort, '')); + if (!s || !s.sort) { + return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; + } + if (!!s.grouped !== !!isGrouped) { + return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; + } + return { sort: s.sort, dir: s.dir || 'desc' }; + } catch { + return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; + } + } + + function getColWidths() { + try { + const w = JSON.parse(lsGet(STORAGE.colWidths, '{}')); + return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) }; + } catch { + return { ...DEFAULT_COL_WIDTHS }; + } + } + + function saveColWidths(widths) { + lsSet(STORAGE.colWidths, JSON.stringify(widths)); + } + + function getOrderedColumnKeys() { + const defaults = LIST_COLUMNS.map((c) => c.key); + try { + const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]')); + if (Array.isArray(saved) && saved.length) { + const out = saved.filter((k) => defaults.includes(k)); + defaults.forEach((k) => { + if (!out.includes(k)) out.push(k); + }); + return out; + } + } catch { /* ignore */ } + return defaults; + } + + function saveColumnOrder(keys) { + lsSet(STORAGE.colOrder, JSON.stringify(keys)); + } + + function getDisplayColumns(isGrouped) { + if (isGrouped) return GROUP_COLUMNS; + return getOrderedColumnKeys() + .map((k) => LIST_COLUMNS.find((c) => c.key === k)) + .filter(Boolean); + } + + function applyColgroup(isGrouped) { + const cg = document.getElementById('reports-colgroup'); + if (!cg || isGrouped) { + if (cg) cg.innerHTML = ''; + return; + } + const widths = getColWidths(); + const order = getOrderedColumnKeys(); + let html = + ''; + order.forEach((k) => { + html += + ''; + }); + html += + ''; + cg.innerHTML = html; + } + + function columnLabelByKey(key) { + const c = LIST_COLUMNS.find((col) => col.key === key); + if (c) return colLabel(c); + const g = GROUP_COLUMNS.find((col) => col.key === key); + if (g) return colLabel(g); + if (key === 'tags') return t('col.tags'); + return key; + } + + function clearColumnDropMarkers(thead) { + thead.querySelectorAll('.th-drop-target').forEach((el) => { + el.classList.remove('th-drop-target'); + }); + thead.querySelectorAll('.th-dragging').forEach((el) => { + el.classList.remove('th-dragging'); + }); + } + + function bindReportsTableLayout(table, thead, ctx) { + if (!table || !thead || table.dataset.layoutBound === '1') return; + table.dataset.layoutBound = '1'; + + thead.addEventListener('dragstart', (e) => { + if (ctx.getGrouped()) return; + if (e.target.closest('.col-resizer')) { + e.preventDefault(); + return; + } + const th = e.target.closest('th.th-draggable[data-col-key]'); + if (!th) return; + const sourceKey = th.getAttribute('data-col-key'); + if (!sourceKey) return; + table.dataset.dragColKey = sourceKey; + th.classList.add('th-dragging'); + const label = columnLabelByKey(sourceKey); + const ghost = document.createElement('div'); + ghost.className = 'col-drag-ghost'; + ghost.textContent = label; + ghost.setAttribute('aria-hidden', 'true'); + document.body.appendChild(ghost); + table._dragGhost = ghost; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', sourceKey); + try { + e.dataTransfer.setDragImage(ghost, 16, 14); + } catch { /* ignore */ } + } + }); + + thead.addEventListener('dragend', () => { + clearColumnDropMarkers(thead); + delete table.dataset.dragColKey; + if (table._dragGhost) { + table._dragGhost.remove(); + table._dragGhost = null; + } + }); + + thead.addEventListener('dragenter', (e) => { + if (ctx.getGrouped() || !table.dataset.dragColKey) return; + e.preventDefault(); + }); + + thead.addEventListener('dragover', (e) => { + if (ctx.getGrouped() || !table.dataset.dragColKey) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + const active = thead.querySelector('.th-drop-target'); + if (active && active !== th) active.classList.remove('th-drop-target'); + th.classList.add('th-drop-target'); + }); + + thead.addEventListener('dragleave', (e) => { + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + const rel = e.relatedTarget; + if (rel && th.contains(rel)) return; + th.classList.remove('th-drop-target'); + }); + + thead.addEventListener('drop', (e) => { + if (ctx.getGrouped()) return; + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + e.preventDefault(); + e.stopPropagation(); + const sourceKey = + table.dataset.dragColKey || + (e.dataTransfer && e.dataTransfer.getData('text/plain')) || + ''; + const targetKey = th.getAttribute('data-col-key'); + clearColumnDropMarkers(thead); + delete table.dataset.dragColKey; + if (table._dragGhost) { + table._dragGhost.remove(); + table._dragGhost = null; + } + if (!sourceKey || !targetKey || sourceKey === targetKey) return; + const order = getOrderedColumnKeys(); + const from = order.indexOf(sourceKey); + const to = order.indexOf(targetKey); + if (from < 0 || to < 0) return; + order.splice(from, 1); + order.splice(to, 0, sourceKey); + saveColumnOrder(order); + ctx.refreshLayout(); + }); + + table.addEventListener('mousedown', (e) => { + if (ctx.getGrouped()) return; + const handle = e.target.closest('.col-resizer'); + if (!handle) return; + e.preventDefault(); + e.stopPropagation(); + const colKey = handle.getAttribute('data-resize-col'); + const th = handle.closest('th'); + if (!colKey || !th) return; + const widths = getColWidths(); + const startX = e.clientX; + const startW = th.getBoundingClientRect().width; + + const onMove = (ev) => { + const w = Math.max(48, Math.round(startW + (ev.clientX - startX))); + widths[colKey] = w; + th.style.width = w + 'px'; + th.style.minWidth = w + 'px'; + th.style.maxWidth = w + 'px'; + const cg = document.getElementById('reports-colgroup'); + const col = cg && cg.querySelector('col[data-col="' + colKey + '"]'); + if (col) col.style.width = w + 'px'; + else if (colKey === 'tags') { + const tagCol = cg && cg.querySelector('col.col-tags'); + if (tagCol) tagCol.style.width = w + 'px'; + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + saveColWidths(widths); + applyColgroup(false); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } + + function parseListContext() { + const p = new URLSearchParams(window.location.search); + const filterTags = []; + p.getAll('tag').forEach((t) => { + const id = String(t).trim().toLowerCase(); + if (id && !filterTags.includes(id)) filterTags.push(id); + }); + return { + similarTo: Number(p.get('similar_to')) || 0, + filterDevice: p.get('filter_device') || '', + filterAbi: p.get('filter_abi') || '', + filterOsSdk: Number(p.get('filter_os_sdk')) || 0, + filterOsRelease: p.get('filter_os_release') || '', + filterAppVersion: p.get('filter_app_version') || '', + filterBuild: p.get('filter_build') || '', + filterTags, + tagMode: p.get('tag_mode') === 'or' ? 'or' : 'and', + searchQuery: p.get('q') || '', + }; + } + + function listContextActive(ctx) { + return ( + ctx.similarTo > 0 || + !!ctx.filterDevice || + !!ctx.filterAbi || + ctx.filterOsSdk > 0 || + !!ctx.filterOsRelease || + !!ctx.filterAppVersion || + !!ctx.filterBuild || + (ctx.filterTags && ctx.filterTags.length > 0) || + !!ctx.searchQuery + ); + } + + function reportsUrlWithTagToggle(tagId, tagMode) { + const p = new URLSearchParams(window.location.search); + p.set('view', 'reports'); + const tags = p.getAll('tag'); + const idx = tags.indexOf(tagId); + if (idx >= 0) { + tags.splice(idx, 1); + } else { + tags.push(tagId); + } + p.delete('tag'); + tags.forEach((t) => p.append('tag', t)); + if (tags.length) { + p.set('tag_mode', tagMode === 'or' ? 'or' : 'and'); + } else { + p.delete('tag_mode'); + } + p.delete('group'); + p.delete('page'); + return basePath() + '/?' + p.toString(); + } + + function updateFilterBanner(ctx, bannerEl) { + if (!bannerEl) return; + const parts = []; + if (ctx.similarTo > 0) { + parts.push('Similar reports for #' + ctx.similarTo); + } + if (ctx.filterDevice) { + parts.push('Device: “' + ctx.filterDevice + '”'); + } + if (ctx.filterAbi) { + parts.push('ABI: ' + ctx.filterAbi); + } + if (ctx.filterOsSdk > 0) { + parts.push('SDK ' + ctx.filterOsSdk); + } + if (ctx.filterOsRelease) { + parts.push('Android ' + ctx.filterOsRelease); + } + if (ctx.filterAppVersion) { + parts.push('App ' + ctx.filterAppVersion); + } + if (ctx.filterBuild) { + parts.push('Build ' + ctx.filterBuild); + } + if (ctx.filterTags && ctx.filterTags.length) { + const modeLabel = ctx.tagMode === 'or' ? 'OR' : 'AND'; + parts.push('Tags (' + modeLabel + '): ' + ctx.filterTags.join(', ')); + } + if (ctx.searchQuery) { + parts.push('Search: “' + ctx.searchQuery + '”'); + } + if (!parts.length) { + bannerEl.hidden = true; + bannerEl.textContent = ''; + return; + } + bannerEl.hidden = false; + bannerEl.innerHTML = + parts.join(' · ') + + ' — Clear filters'; + } + + function navigateTo(href) { + try { + sessionStorage.setItem(STORAGE.skipStamp, '1'); + } catch { /* ignore */ } + window.location.href = href; + } + + function bindTreeInteractions(root) { + root.querySelectorAll('.report-tree-toggle').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.getAttribute('aria-controls'); + const briefRow = id ? document.getElementById(id) : null; + const row = btn.closest('.report-row'); + if (!briefRow || !row) return; + const open = briefRow.hidden; + briefRow.hidden = !open; + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + row.classList.toggle('report-row--open', open); + }); + }); + + root.querySelectorAll('[data-href]').forEach((el) => { + const href = el.getAttribute('data-href'); + if (!href) return; + const go = () => navigateTo(href); + const onActivate = (e) => { + if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return; + go(); + }; + el.addEventListener('click', onActivate); + el.addEventListener('keydown', (e) => { + if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + go(); + } + }); + }); + } + + function bindReportTagFilters(root, onTag) { + if (!root || typeof onTag !== 'function') return; + root.querySelectorAll('.report-tag--filter').forEach((btn) => { + btn.addEventListener('click', (e) => { + const tagId = btn.getAttribute('data-tag-id') || ''; + if (tagId) onTag(tagId, e); + }); + }); + } + + function initReportsApp() { + const app = document.getElementById('reports-app'); + if (!app) return; + + const thead = document.getElementById('reports-thead'); + const tbody = document.getElementById('reports-tbody'); + const pagination = document.getElementById('reports-pagination'); + const statusEl = document.getElementById('reports-status'); + const perPageSel = document.getElementById('per-page-select'); + + const table = document.getElementById('reports-tree'); + const filterBanner = document.getElementById('reports-filter-banner'); + const listContext = parseListContext(); + let grouped = + listContext.filterTags && listContext.filterTags.length > 0 + ? false + : listContextActive(listContext) + ? false + : app.getAttribute('data-grouped') === '1'; + let page = 1; + let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50; + let listTotal = 0; + const saved = loadSavedSort(grouped); + let sort = saved.sort; + let dir = saved.dir; + let loading = false; + let lastItems = []; + + function saveSortState() { + lsSet( + STORAGE.sort, + JSON.stringify({ sort, dir, grouped: !!grouped }) + ); + } + + function sortActive(colKey) { + if (sort === 'priority' && colKey === 'rating') return true; + if (sort === colKey) return true; + return apiSortKey(sort) === apiSortKey(colKey); + } + + const layoutCtx = { + getGrouped: () => grouped, + refreshLayout: () => { + applyColgroup(grouped); + renderHead(); + if (lastItems.length) renderRows(lastItems); + }, + }; + + function refreshTableChrome() { + layoutCtx.refreshLayout(); + } + + bindReportsTableLayout(table, thead, layoutCtx); + updateFilterBanner(listContext, filterBanner); + initTagFilterBar(listContext); + + if (listContextActive(listContext)) { + app.setAttribute('data-grouped', '0'); + app.querySelectorAll('.reports-mode-btn').forEach((b) => { + b.classList.toggle('active', b.getAttribute('data-grouped') === '0'); + b.disabled = true; + b.title = 'Disabled while a filter is active'; + }); + } + + if (perPageSel) { + perPageSel.value = String(perPage); + perPageSel.addEventListener('change', () => { + perPage = Number(perPageSel.value) || 50; + lsSet(STORAGE.perPage, String(perPage)); + page = 1; + load(); + }); + } + + app.querySelectorAll('.reports-mode-btn').forEach((btn) => { + btn.addEventListener('click', () => { + grouped = btn.getAttribute('data-grouped') === '1'; + app.setAttribute('data-grouped', grouped ? '1' : '0'); + app.querySelectorAll('.reports-mode-btn').forEach((b) => { + b.classList.toggle('active', b === btn); + }); + const next = loadSavedSort(grouped); + sort = next.sort; + dir = next.dir; + page = 1; + load(); + }); + }); + + const maybeStampVisit = () => { + try { + if (sessionStorage.getItem(STORAGE.skipStamp)) { + sessionStorage.removeItem(STORAGE.skipStamp); + return; + } + } catch { /* ignore */ } + stampLastVisit(); + }; + window.addEventListener('pagehide', maybeStampVisit); + + function columns() { + return getDisplayColumns(grouped); + } + + function colspan() { + return columns().length + 2; + } + + function renderHead() { + const cols = columns(); + const widths = getColWidths(); + let html = + ''; + cols.forEach((c) => { + const active = sortActive(c.key); + const arrow = + active ? (dir === 'asc' ? '▲' : '▼') : ''; + const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : ''; + let thClass = 'sortable' + (active ? ' sortable--active' : ''); + let thDragAttrs = ''; + if (!grouped) { + thClass += ' th-draggable'; + thDragAttrs = + ' draggable="true" data-col-key="' + + escapeHtml(c.key) + + '" title="' + + escapeHtml(t('col.drag_reorder')) + + '"'; + } + html += + '' + + (grouped + ? '' + : '') + + '' + + escapeHtml(colLabel(c)) + + ' ' + + (grouped ? '' : '') + + ''; + }); + html += + 'Tags'; + thead.innerHTML = html; + thead.querySelectorAll('.sortable').forEach((th) => { + th.addEventListener('click', (e) => { + if (e.target.closest('.col-resizer')) return; + const key = th.getAttribute('data-sort'); + if (!key) return; + if (sort === key) { + dir = dir === 'asc' ? 'desc' : 'asc'; + } else { + sort = key; + dir = 'desc'; + } + saveSortState(); + page = 1; + load(); + }); + }); + } + + function pageRangeEnd(targetPage, totalCount) { + return Math.min(targetPage * perPage, totalCount); + } + + function renderPagination(total) { + listTotal = total; + const pages = Math.max(1, Math.ceil(total / perPage)); + if (page > pages) page = pages; + const prev = page > 1 ? page - 1 : null; + const next = page < pages ? page + 1 : null; + let prevLabel = '← Previous'; + let nextLabel = 'Next →'; + if (prev) { + const t = page - 1; + const before = (t - 1) * perPage; + const through = pageRangeEnd(t, total); + prevLabel = '← Previous (' + before + '/' + through + ')'; + } + if (next) { + const start = page * perPage + 1; + const end = pageRangeEnd(page + 1, total); + nextLabel = 'Next (' + start + '/' + end + ') →'; + } + let html = '
'; + html += + ''; + html += + 'Page ' + + page + + ' / ' + + pages + + ' · ' + + total + + ' reports'; + html += + ''; + html += '
'; + pagination.innerHTML = html; + pagination.querySelectorAll('.page-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const p = Number(btn.getAttribute('data-page')); + if (!p) return; + page = p; + load(); + }); + }); + } + + function renderRows(items) { + const cols = columns(); + if (!items.length) { + tbody.innerHTML = + '' + + escapeHtml(t('reports.empty')) + + ''; + return; + } + let html = ''; + items.forEach((row, i) => { + const rowKey = grouped ? 'g' + i + '-' + page : 'r' + row.id; + const briefId = 'brief-' + rowKey; + const openUrl = !grouped && row.id + ? basePath() + '/?view=report&id=' + row.id + : ''; + const navClass = openUrl ? ' report-row--nav' : ''; + const hrefAttr = openUrl ? ' data-href="' + escapeHtml(openUrl) + '"' : ''; + const tabAttr = openUrl ? ' tabindex="0" role="link"' : ''; + + html += ''; + html += + ''; + cols.forEach((c) => { + if (c.key === 'cnt') { + html += '' + escapeHtml(row.cnt) + ''; + } else { + html += '' + formatCell(c, row) + ''; + } + }); + const tagEditBtn = + !grouped && row.id && canTagEdit() + ? '' + : ''; + html += + '
' + + buildTags(row, grouped) + + tagEditBtn + + '
'; + html += ''; + + const briefLines = row.brief || []; + let panelOpen = '
'; + if (openUrl) { + panelOpen = + '
'; + } + html += + '' + + panelOpen + + '
'; + briefLines.forEach((line) => { + html += '

' + escapeHtml(line) + '

'; + }); + if (openUrl) { + html += '

Click to open full report

'; + } + html += '
'; + }); + tbody.innerHTML = html; + bindTreeInteractions(tbody); + bindReportTagFilters(tbody, handleReportTagClick); + } + + function applyReportTagFilter(tagId) { + listContext.filterTags = [tagId]; + listContext.tagMode = listContext.tagMode || 'and'; + grouped = false; + app.setAttribute('data-grouped', '0'); + page = 1; + updateFilterBanner(listContext, filterBanner); + const p = new URLSearchParams(window.location.search); + p.set('view', 'reports'); + p.delete('tag'); + p.append('tag', tagId); + p.set('tag_mode', listContext.tagMode); + p.delete('group'); + p.delete('page'); + history.replaceState(null, '', basePath() + '/?' + p.toString()); + const modeWrap = document.getElementById('tag-mode-wrap'); + if (modeWrap) modeWrap.hidden = false; + const modeSel = document.getElementById('tag-mode-select'); + if (modeSel) modeSel.value = listContext.tagMode; + load(); + } + + function handleReportTagClick(tagId, ev) { + ev.stopPropagation(); + ev.preventDefault(); + if (grouped) { + applyReportTagFilter(tagId); + return; + } + const xhr = new XMLHttpRequest(); + xhr.open( + 'GET', + basePath() + + '/api/reports.php?page=1&per_page=2&tag=' + + encodeURIComponent(tagId) + + '&tag_mode=and&sort=received_at_ms&dir=desc&since_ms=0', + true + ); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + return; + } + if (!data.ok) return; + const total = Number(data.total || 0); + if (total === 1 && data.items && data.items[0] && data.items[0].id) { + navigateTo(basePath() + '/?view=report&id=' + data.items[0].id); + return; + } + applyReportTagFilter(tagId); + }; + xhr.send(); + } + + function load() { + if (loading) return; + loading = true; + statusEl.textContent = t('reports.loading'); + let qs = + 'page=' + + page + + '&per_page=' + + perPage + + '&sort=' + + encodeURIComponent(apiSortKey(sort)) + + '&dir=' + + encodeURIComponent(dir) + + '&since_ms=' + + encodeURIComponent(String(lastVisitMs())); + if (listContext.searchQuery) { + qs += '&q=' + encodeURIComponent(listContext.searchQuery); + } else if (listContext.similarTo > 0) { + qs += '&similar_to=' + encodeURIComponent(String(listContext.similarTo)); + } else { + if (grouped) qs += '&group=1'; + if (listContext.filterDevice) { + qs += '&filter_device=' + encodeURIComponent(listContext.filterDevice); + } + if (listContext.filterAbi) { + qs += '&filter_abi=' + encodeURIComponent(listContext.filterAbi); + } + if (listContext.filterOsSdk > 0) { + qs += '&filter_os_sdk=' + encodeURIComponent(String(listContext.filterOsSdk)); + } + if (listContext.filterOsRelease) { + qs += '&filter_os_release=' + encodeURIComponent(listContext.filterOsRelease); + } + if (listContext.filterAppVersion) { + qs += '&filter_app_version=' + encodeURIComponent(listContext.filterAppVersion); + } + if (listContext.filterBuild) { + qs += '&filter_build=' + encodeURIComponent(listContext.filterBuild); + } + if (listContext.filterTags && listContext.filterTags.length) { + listContext.filterTags.forEach((tagId) => { + qs += '&tag=' + encodeURIComponent(tagId); + }); + qs += '&tag_mode=' + encodeURIComponent(listContext.tagMode || 'and'); + } + } + const xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/reports.php?' + qs, true); + xhr.onload = function () { + loading = false; + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + statusEl.textContent = 'Invalid response'; + return; + } + if (!data.ok) { + statusEl.textContent = data.error || 'Load failed'; + return; + } + lastItems = data.items || []; + refreshTableChrome(); + renderPagination(Number(data.total) || 0); + statusEl.textContent = + 'Showing ' + + (data.items?.length || 0) + + ' of ' + + (data.total || 0) + + (grouped ? ' (grouped)' : ''); + }; + xhr.onerror = function () { + loading = false; + statusEl.textContent = 'Network error'; + }; + xhr.send(); + } + + initTagEditButtons(tbody, load); + + const searchInput = document.getElementById('reports-search-input'); + const searchSuggest = document.getElementById('reports-search-suggest'); + const searchClear = document.getElementById('reports-search-clear'); + let suggestTimer = null; + + function hideSuggest() { + if (searchSuggest) { + searchSuggest.hidden = true; + searchSuggest.innerHTML = ''; + } + } + + function applySearchQuery(q, pushUrl) { + listContext.similarTo = 0; + listContext.filterDevice = ''; + listContext.filterAbi = ''; + listContext.filterOsSdk = 0; + listContext.filterOsRelease = ''; + listContext.filterAppVersion = ''; + listContext.filterBuild = ''; + listContext.searchQuery = (q || '').trim(); + grouped = false; + app.setAttribute('data-grouped', '0'); + page = 1; + updateFilterBanner(listContext, filterBanner); + if (searchInput) searchInput.value = listContext.searchQuery; + if (searchClear) searchClear.hidden = !listContext.searchQuery; + if (pushUrl) { + const url = + basePath() + + '/?view=reports' + + (listContext.searchQuery ? '&q=' + encodeURIComponent(listContext.searchQuery) : ''); + history.replaceState(null, '', url); + } + load(); + } + + function fetchSuggest(q) { + if (!searchSuggest || q.length < 2) { + hideSuggest(); + return; + } + const xhr = new XMLHttpRequest(); + xhr.open( + 'GET', + basePath() + + '/api/reports.php?suggest=1&q=' + + encodeURIComponent(q) + + '&per_page=10', + true + ); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + hideSuggest(); + return; + } + if (!data.ok || !data.suggestions || !data.suggestions.length) { + hideSuggest(); + return; + } + searchSuggest.innerHTML = data.suggestions + .map( + (s) => + '
  • ' + ) + .join(''); + searchSuggest.hidden = false; + }; + xhr.send(); + } + + if (searchInput) { + if (listContext.searchQuery) { + searchInput.value = listContext.searchQuery; + if (searchClear) searchClear.hidden = false; + } + searchInput.addEventListener('input', () => { + const q = searchInput.value; + if (searchClear) searchClear.hidden = !q.trim(); + hideSuggest(); + clearTimeout(suggestTimer); + const trimmed = q.trim(); + if (trimmed.length < 2) return; + suggestTimer = setTimeout(() => fetchSuggest(trimmed), 1000); + }); + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + clearTimeout(suggestTimer); + hideSuggest(); + applySearchQuery(searchInput.value.trim(), true); + } + if (e.key === 'Escape') { + hideSuggest(); + } + }); + } + function selectSearchSuggestion(term) { + if (!searchInput) return; + const t = (term || '').trim(); + if (!t) return; + const raw = searchInput.value; + const withoutTrailing = raw.replace(/\s+$/, ''); + const lastSpace = withoutTrailing.lastIndexOf(' '); + searchInput.value = + lastSpace < 0 + ? t + : withoutTrailing.slice(0, lastSpace + 1) + t; + if (searchClear) searchClear.hidden = !searchInput.value.trim(); + searchInput.focus(); + } + if (searchSuggest) { + searchSuggest.addEventListener('click', (e) => { + const btn = e.target.closest('[data-suggest]'); + if (!btn) return; + const term = btn.getAttribute('data-suggest') || ''; + selectSearchSuggestion(term); + hideSuggest(); + }); + } + if (searchClear) { + searchClear.addEventListener('click', () => { + if (searchInput) searchInput.value = ''; + searchClear.hidden = true; + hideSuggest(); + applySearchQuery('', true); + }); + } + + applyColgroup(grouped); + load(); + } + + const TAG_QUICK_COLORS = [ + '#5c6b82', '#3d8bfd', '#6d28d9', '#b45309', '#047857', + '#dc2626', '#d97706', '#0891b2', '#be185d', '#4f46e5', + ]; + + function canTagEdit() { + return document.body.getAttribute('data-can-tag-edit') === '1'; + } + + function slugTagId(label) { + return String(label) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32); + } + + function renderTagPill(tag, removable) { + let html = + '' + + escapeHtml(tag.label || tag.id); + if (removable) { + html += + ''; + } + return html + ''; + } + + function tagsApiUrl(entityType) { + return basePath() + (entityType === 'ticket' ? '/api/ticket_tags.php' : '/api/report_tags.php'); + } + + const TICKET_META_IDS = ['ticket', 'issue']; + + function isTicketMetaId(id) { + return TICKET_META_IDS.indexOf(id) >= 0; + } + + function stripTicketMetaTags(tags) { + return (tags || []).filter((t) => !isTicketMetaId(t.id)); + } + + function tagsJson(tags) { + return JSON.stringify(tags || []); + } + + function createTagEditor(cfg) { + const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report'; + const state = { + tags: [...(cfg.initialTags || [])], + workflow: [], + presets: [], + entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0, + entityType, + }; + const els = cfg.elements; + + function renderList() { + if (!els.list) return; + if (!state.tags.length) { + els.list.innerHTML = '
  • No custom tags yet.
  • '; + return; + } + els.list.innerHTML = state.tags + .map( + (t, i) => + '
  • ' + + renderTagPill(t, true) + + '
  • ' + ) + .join(''); + els.list.querySelectorAll('.tag-remove').forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-remove-id'); + state.tags = state.tags.filter((t) => t.id !== id); + renderAll(); + }); + }); + els.list.querySelectorAll('input[data-color-idx]').forEach((inp) => { + inp.addEventListener('input', () => { + const idx = Number(inp.getAttribute('data-color-idx')); + if (state.tags[idx]) state.tags[idx].bg = inp.value; + renderPreview(); + }); + }); + } + + function renderPreview() { + if (!els.preview) return; + els.preview.innerHTML = state.tags.length + ? state.tags.map((t) => renderTagPill(t, false)).join('') + : 'No custom tags'; + } + + function presetChipHtml(p) { + return ( + '' + ); + } + + function wirePresetChips(root) { + if (!root) return; + root.querySelectorAll('.tag-preset-chip').forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-preset-id'); + const p = + state.workflow.find((x) => x.id === id) || + state.presets.find((x) => x.id === id); + if (!p || state.tags.some((t) => t.id === p.id)) return; + if (entityType === 'ticket' && isTicketMetaId(p.id)) { + state.tags = stripTicketMetaTags(state.tags); + } + state.tags.push({ ...p }); + renderAll(); + }); + }); + } + + function renderPresets() { + if (!els.presetBar || !els.presetChips) return; + const wf = state.workflow || []; + const hist = (state.presets || []).filter( + (p) => !wf.some((w) => w.id === p.id) + ); + if (!wf.length && !hist.length) { + els.presetBar.hidden = true; + return; + } + els.presetBar.hidden = false; + let html = ''; + if (wf.length) { + html += + '
    ' + + escapeHtml(t('tag.workflow')) + + '
    ' + + wf.map(presetChipHtml).join('') + + '
    '; + } + if (hist.length) { + html += + '
    ' + + escapeHtml(t('tag.suggestions')) + + '
    ' + + hist.map(presetChipHtml).join('') + + '
    '; + } + els.presetChips.innerHTML = html; + wirePresetChips(els.presetChips); + } + + function renderAll() { + renderList(); + renderPreview(); + } + + function setStatus(msg, ok) { + if (!els.status) return; + els.status.textContent = msg || ''; + els.status.classList.toggle('tag-editor-status--err', ok === false); + } + + function addFromForm(form) { + const label = (form.label?.value || '').trim(); + const bg = form.bg?.value || '#5c6b82'; + if (!label) return; + const id = slugTagId(label); + if (!id) return; + const reserved = ['new', 'java', 'ndk', 'android']; + if (reserved.includes(id)) { + setStatus('Reserved tag id: ' + id, false); + return; + } + const existing = state.tags.findIndex((t) => t.id === id); + const entry = { id, label, bg }; + if (existing >= 0) state.tags[existing] = entry; + else state.tags.push(entry); + form.label.value = ''; + renderAll(); + setStatus('', true); + } + + if (els.form) { + els.form.addEventListener('submit', (e) => { + e.preventDefault(); + addFromForm(els.form); + }); + } + + if (els.saveBtn) { + els.saveBtn.addEventListener('click', () => { + setStatus(t('tag.saving'), true); + const sentTags = state.tags.map((t) => ({ ...t })); + const xhr = new XMLHttpRequest(); + xhr.open('POST', tagsApiUrl(state.entityType), true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + setStatus('Invalid response', false); + return; + } + if (!data.ok) { + setStatus(data.hint || data.error || 'Save failed', false); + return; + } + state.tags = data.tags || []; + renderAll(); + const adjusted = tagsJson(sentTags) !== tagsJson(state.tags); + setStatus(adjusted ? 'Saved (type/status tags normalized)' : 'Saved', true); + if (cfg.onSaved) cfg.onSaved(state.tags, state.entityId); + }; + xhr.onerror = function () { + setStatus(t('reports.network_error'), false); + }; + xhr.send(JSON.stringify({ id: state.entityId, tags: state.tags })); + }); + } + + function loadFromApi() { + const xhr = new XMLHttpRequest(); + xhr.open('GET', tagsApiUrl(state.entityType) + '?id=' + state.entityId, true); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + return; + } + if (data.ok) { + state.tags = data.tags || []; + state.workflow = data.workflow || []; + state.presets = data.presets || []; + renderAll(); + renderPresets(); + } + }; + xhr.send(); + } + + renderAll(); + if (cfg.fetchPresets) loadFromApi(); + + return { + setTags(tags) { + state.tags = tags || []; + renderAll(); + }, + getTags() { + return state.tags; + }, + reload() { + loadFromApi(); + }, + }; + } + + let tagModalEditor = null; + + function openTagModal(entityId, entityType) { + const modal = document.getElementById('tag-modal'); + const editorEl = document.getElementById('tag-editor-modal'); + if (!modal || !editorEl || !canTagEdit()) return; + const et = entityType === 'ticket' ? 'ticket' : 'report'; + editorEl.setAttribute('data-report-id', et === 'report' ? String(entityId) : '0'); + editorEl.setAttribute('data-ticket-id', et === 'ticket' ? String(entityId) : '0'); + editorEl.setAttribute('data-entity-type', et); + modal.hidden = false; + modal.setAttribute('aria-hidden', 'false'); + tagModalEditor = createTagEditor({ + entityId: Number(entityId), + entityType: et, + initialTags: [], + fetchPresets: true, + elements: { + list: document.getElementById('tag-modal-list'), + preview: document.getElementById('tag-modal-preview'), + form: document.getElementById('tag-modal-add'), + presetBar: document.getElementById('tag-modal-presets'), + presetChips: document.getElementById('tag-modal-preset-chips'), + saveBtn: document.getElementById('tag-modal-save'), + status: document.getElementById('tag-modal-status'), + }, + onSaved() { + if (typeof reportsReloadRef === 'function') reportsReloadRef(); + if (typeof window.ticketsReloadRef === 'function') window.ticketsReloadRef(); + }, + }); + } + window.openTagModal = openTagModal; + + function closeTagModal() { + const modal = document.getElementById('tag-modal'); + if (!modal) return; + modal.hidden = true; + modal.setAttribute('aria-hidden', 'true'); + tagModalEditor = null; + } + + let reportsReloadRef = null; + + function initTagModal() { + document.querySelectorAll('[data-tag-modal-close]').forEach((el) => { + el.addEventListener('click', closeTagModal); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeTagModal(); + }); + } + + function initTagEditorReadOnly() { + const preview = document.getElementById('tag-editor-preview'); + const dataEl = + document.getElementById('ticket-custom-tags-data') || + document.getElementById('report-custom-tags-data'); + if (!preview || !dataEl || canTagEdit()) return; + try { + const tags = JSON.parse(dataEl.textContent || '[]'); + preview.innerHTML = tags.length + ? tags.map((t) => renderTagPill(t, false)).join('') + : 'No custom tags'; + } catch { + preview.innerHTML = ''; + } + } + + function initTagEditorDetail() { + const root = document.getElementById('tag-editor'); + if (!root) return; + const entityType = root.getAttribute('data-entity-type') === 'ticket' ? 'ticket' : 'report'; + const entityId = Number( + root.getAttribute(entityType === 'ticket' ? 'data-ticket-id' : 'data-report-id') + ); + const canEdit = entityType === 'ticket' ? !!root.querySelector('#tag-editor-list') : canTagEdit(); + if (!canEdit) { + initTagEditorReadOnly(); + return; + } + let initial = []; + const dataEl = + document.getElementById('ticket-custom-tags-data') || + document.getElementById('report-custom-tags-data'); + if (dataEl) { + try { + initial = JSON.parse(dataEl.textContent || '[]'); + } catch { /* ignore */ } + } + createTagEditor({ + entityId, + entityType, + initialTags: initial, + fetchPresets: true, + elements: { + list: document.getElementById('tag-editor-list'), + preview: document.getElementById('tag-editor-preview'), + form: document.getElementById('tag-editor-add'), + presetBar: document.getElementById('tag-preset-bar'), + presetChips: document.getElementById('tag-preset-chips'), + saveBtn: document.getElementById('tag-editor-save'), + status: document.getElementById('tag-editor-status'), + }, + }); + } + + function initTicketDetail() { + const id = document.body.getAttribute('data-ticket-id'); + if (!id) return; + const tree = document.getElementById('detail-tree'); + if (tree) bindDetailTree(tree); + + let assignees = []; + const assigneeDataEl = document.getElementById('ticket-assignees-data'); + if (assigneeDataEl) { + try { + assignees = JSON.parse(assigneeDataEl.textContent || '[]'); + if (!Array.isArray(assignees)) assignees = []; + } catch { + assignees = []; + } + } + + const assigneeEditor = document.getElementById('assignee-editor'); + const assigneePick = document.getElementById('assignee-user-pick'); + const assigneeAddBtn = document.getElementById('assignee-add-btn'); + + function renderAssignees() { + if (!assigneeEditor) return; + if (!assignees.length) { + assigneeEditor.innerHTML = ''; + return; + } + assigneeEditor.innerHTML = assignees + .map(function (a, idx) { + const role = a.role ? ' (' + escapeHtml(a.role) + ')' : ''; + return ( + '' + + escapeHtml(a.username) + + role + + ' ' + ); + }) + .join(''); + assigneeEditor.querySelectorAll('.assignee-remove').forEach(function (btn) { + btn.addEventListener('click', function () { + const i = Number(btn.getAttribute('data-idx')); + assignees.splice(i, 1); + renderAssignees(); + }); + }); + } + + function loadUsersForAssignees() { + if (!assigneePick) return; + const xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/users.php', true); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + return; + } + if (!data.ok || !Array.isArray(data.users)) return; + data.users.forEach(function (u) { + const opt = document.createElement('option'); + opt.value = u.username; + opt.textContent = u.username + (u.role ? ' (' + u.role + ')' : ''); + opt.dataset.role = u.role || ''; + assigneePick.appendChild(opt); + }); + }; + xhr.send(); + } + + if (assigneeEditor) { + renderAssignees(); + loadUsersForAssignees(); + if (assigneeAddBtn && assigneePick) { + assigneeAddBtn.addEventListener('click', function () { + const username = assigneePick.value; + if (!username) return; + const role = assigneePick.selectedOptions[0]?.dataset.role || ''; + if (assignees.some(function (a) { return a.username === username; })) return; + assignees.push({ username: username, role: role }); + renderAssignees(); + }); + } + } + + const attachmentList = document.getElementById('ticket-attachment-list'); + const linkForm = document.getElementById('ticket-link-form'); + const fileForm = document.getElementById('ticket-file-form'); + const linkStatus = document.getElementById('ticket-link-status'); + const fileStatus = document.getElementById('ticket-file-status'); + + function renderAttachmentItem(att) { + const li = document.createElement('li'); + li.className = 'ticket-attachment ticket-attachment--' + (att.kind || 'file'); + li.dataset.attachmentId = String(att.id); + let main = ''; + const label = escapeHtml(att.provider_label || 'File'); + if (att.kind === 'link' && att.external_url) { + main = + '' + + escapeHtml(att.title || att.external_url) + + ''; + } else if (att.download_url) { + const size = + att.size_bytes > 0 + ? ' (' + (att.size_bytes / 1024).toFixed(1) + ' KB)' + : ''; + main = + '' + + escapeHtml(att.title || att.original_name || 'file') + + '' + + size; + } else { + main = '' + escapeHtml(att.title || '') + ''; + } + li.innerHTML = + '' + + label + + ' ' + + main + + ' ' + + escapeHtml(att.created_by || '') + + '' + + ' '; + li.querySelector('.attachment-remove').addEventListener('click', function () { + if (!confirm('Remove attachment?')) return; + const xhr = new XMLHttpRequest(); + xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + att.id, true); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + return; + } + if (data.ok) li.remove(); + }; + xhr.send(); + }); + return li; + } + + if (linkForm) { + linkForm.addEventListener('submit', function (e) { + e.preventDefault(); + const fd = new FormData(linkForm); + const url = String(fd.get('url') || '').trim(); + if (!url) return; + if (linkStatus) linkStatus.textContent = 'Saving…'; + const xhr = new XMLHttpRequest(); + xhr.open('POST', basePath() + '/api/ticket_attachments.php', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + if (linkStatus) linkStatus.textContent = 'Invalid response'; + return; + } + if (!data.ok) { + if (linkStatus) linkStatus.textContent = data.error || 'Failed'; + return; + } + if (attachmentList && data.attachment) { + attachmentList.appendChild(renderAttachmentItem(data.attachment)); + } + linkForm.reset(); + if (linkStatus) linkStatus.textContent = 'Attached'; + }; + xhr.onerror = function () { + if (linkStatus) linkStatus.textContent = t('reports.network_error'); + }; + xhr.send( + JSON.stringify({ + ticket_id: Number(id), + title: fd.get('title'), + url: url, + }) + ); + }); + } + + if (fileForm) { + fileForm.addEventListener('submit', function (e) { + e.preventDefault(); + const fd = new FormData(fileForm); + fd.append('ticket_id', id); + if (fileStatus) fileStatus.textContent = 'Uploading…'; + const xhr = new XMLHttpRequest(); + xhr.open('POST', basePath() + '/api/ticket_attachments.php', true); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + if (fileStatus) fileStatus.textContent = 'Invalid response'; + return; + } + if (!data.ok) { + if (fileStatus) fileStatus.textContent = data.error || 'Failed'; + return; + } + if (attachmentList && data.attachment) { + attachmentList.appendChild(renderAttachmentItem(data.attachment)); + } + fileForm.reset(); + if (fileStatus) fileStatus.textContent = 'Uploaded'; + }; + xhr.onerror = function () { + if (fileStatus) fileStatus.textContent = t('reports.network_error'); + }; + xhr.send(fd); + }); + } + + if (attachmentList) { + attachmentList.querySelectorAll('.attachment-remove').forEach(function (btn) { + btn.addEventListener('click', function () { + const attId = btn.getAttribute('data-id'); + if (!confirm('Remove attachment?')) return; + const xhr = new XMLHttpRequest(); + xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + attId, true); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + return; + } + if (data.ok) btn.closest('.ticket-attachment')?.remove(); + }; + xhr.send(); + }); + }); + } + + const commentForm = document.getElementById('ticket-comment-form'); + const commentStatus = document.getElementById('ticket-comment-status'); + const commentList = document.getElementById('ticket-comment-list'); + if (commentForm) { + commentForm.addEventListener('submit', function (e) { + e.preventDefault(); + const fd = new FormData(commentForm); + const bodyText = String(fd.get('body') || '').trim(); + if (!bodyText) return; + if (commentStatus) commentStatus.textContent = 'Posting…'; + const xhr = new XMLHttpRequest(); + xhr.open('POST', basePath() + '/api/ticket_comments.php', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + if (commentStatus) commentStatus.textContent = 'Invalid response'; + return; + } + if (!data.ok) { + if (commentStatus) commentStatus.textContent = data.error || 'Failed'; + return; + } + const c = data.comment; + if (commentList && c) { + const li = document.createElement('li'); + li.className = 'ticket-comment'; + const when = c.created_at_ms + ? new Date(Number(c.created_at_ms)).toISOString().replace('T', ' ').slice(0, 19) + : ''; + li.innerHTML = + '
    ' + + escapeHtml(c.author_username) + + ' ' + + escapeHtml(when) + + '
    ';
    +            li.querySelector('.ticket-comment-body').textContent = c.body;
    +            commentList.appendChild(li);
    +          }
    +          commentForm.reset();
    +          if (commentStatus) commentStatus.textContent = 'Posted';
    +        };
    +        xhr.onerror = function () {
    +          if (commentStatus) commentStatus.textContent = t('reports.network_error');
    +        };
    +        xhr.send(JSON.stringify({ ticket_id: Number(id), body: bodyText }));
    +      });
    +    }
    +
    +    const form = document.getElementById('ticket-edit-form');
    +    if (!form) return;
    +    const statusEl = document.getElementById('ticket-save-status');
    +    const ratingSel = form.querySelector('select[name="rating"]');
    +    const starsPreview = form.querySelector('.ticket-stars-preview .star-rating');
    +    if (ratingSel && starsPreview) {
    +      ratingSel.addEventListener('change', () => {
    +        const n = Math.max(0, Math.min(5, Number(ratingSel.value) || 0));
    +        let html = '';
    +        for (let i = 1; i <= 5; i++) {
    +          html += '';
    +        }
    +        starsPreview.innerHTML = html;
    +      });
    +    }
    +    form.addEventListener('submit', (e) => {
    +      e.preventDefault();
    +      if (statusEl) statusEl.textContent = 'Saving…';
    +      const fd = new FormData(form);
    +      const body = {
    +        id: Number(form.getAttribute('data-ticket-id')),
    +        title: fd.get('title'),
    +        brief: fd.get('brief'),
    +        body: fd.get('body'),
    +        owner_username: fd.get('owner_username'),
    +        verified_by_username: fd.get('verified_by_username'),
    +        rating: Number(fd.get('rating')),
    +        workflow_state: fd.get('workflow_state'),
    +        assignees: assignees,
    +      };
    +      const xhr = new XMLHttpRequest();
    +      xhr.open('PUT', basePath() + '/api/ticket_update.php', true);
    +      xhr.setRequestHeader('Content-Type', 'application/json');
    +      xhr.onload = function () {
    +        let data;
    +        try {
    +          data = JSON.parse(xhr.responseText);
    +        } catch {
    +          if (statusEl) statusEl.textContent = 'Invalid response';
    +          return;
    +        }
    +        if (!data.ok) {
    +          if (statusEl) statusEl.textContent = data.error || 'Save failed';
    +          return;
    +        }
    +        if (statusEl) statusEl.textContent = 'Saved';
    +      };
    +      xhr.onerror = function () {
    +        if (statusEl) statusEl.textContent = t('reports.network_error');
    +      };
    +      xhr.send(JSON.stringify(body));
    +    });
    +  }
    +
    +  function initTagFilterBar(listContext) {
    +    const chipsEl = document.getElementById('tag-filter-chips');
    +    const modeWrap = document.getElementById('tag-mode-wrap');
    +    const modeSel = document.getElementById('tag-mode-select');
    +    if (!chipsEl) return;
    +
    +    function renderFilterChips(workflow) {
    +      const active = listContext.filterTags || [];
    +      chipsEl.innerHTML = (workflow || [])
    +        .map((tag) => {
    +          const on = active.includes(tag.id);
    +          return (
    +            '' +
    +            escapeHtml(tag.label) +
    +            ''
    +          );
    +        })
    +        .join('');
    +    }
    +
    +    if (modeSel) {
    +      modeSel.value = listContext.tagMode === 'or' ? 'or' : 'and';
    +      modeSel.addEventListener('change', () => {
    +        if (!(listContext.filterTags && listContext.filterTags.length)) return;
    +        const p = new URLSearchParams(window.location.search);
    +        p.set('view', 'reports');
    +        p.set('tag_mode', modeSel.value === 'or' ? 'or' : 'and');
    +        p.delete('page');
    +        window.location.href = basePath() + '/?' + p.toString();
    +      });
    +    }
    +
    +    const xhr = new XMLHttpRequest();
    +    xhr.open('GET', basePath() + '/api/tag_catalog.php', true);
    +    xhr.onload = function () {
    +      let data;
    +      try {
    +        data = JSON.parse(xhr.responseText);
    +      } catch {
    +        return;
    +      }
    +      if (!data.ok) return;
    +      renderFilterChips(data.workflow || []);
    +      if (modeWrap) {
    +        modeWrap.hidden = !(listContext.filterTags && listContext.filterTags.length);
    +      }
    +    };
    +    xhr.send();
    +  }
    +
    +  function initTagEditButtons(tbody, reloadFn) {
    +    reportsReloadRef = reloadFn;
    +    if (!tbody) return;
    +    tbody.addEventListener('click', (e) => {
    +      const btn = e.target.closest('.tag-edit-btn');
    +      if (!btn) return;
    +      e.stopPropagation();
    +      e.preventDefault();
    +      openTagModal(Number(btn.getAttribute('data-report-id')), 'report');
    +    });
    +  }
    +
    +  function boot() {
    +    initTheme();
    +    initNav();
    +    initTagModal();
    +    initReportDetail();
    +    initTicketDetail();
    +    initTagEditorDetail();
    +    initReportsApp();
    +  }
    +
    +  function onReady(fn) {
    +    const run = () => {
    +      if (window.CrashI18n && window.CrashI18n.ready) {
    +        window.CrashI18n.ready.then(fn);
    +      } else {
    +        fn();
    +      }
    +    };
    +    if (document.readyState === 'loading') {
    +      document.addEventListener('DOMContentLoaded', run);
    +    } else {
    +      run();
    +    }
    +  }
    +
    +  onReady(boot);
    +})();
    diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/cookie_consent.js b/sim/cluster0/lab-seeds/backend/public/assets/js/cookie_consent.js
    new file mode 100644
    index 0000000..dd73075
    --- /dev/null
    +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/cookie_consent.js
    @@ -0,0 +1,79 @@
    +/**
    + * Cookie consent banner — stores choice in localStorage (ac_cookie_consent).
    + * Values: all | necessary | reject
    + */
    +(function (global) {
    +  'use strict';
    +
    +  var STORAGE_KEY = 'ac_cookie_consent';
    +  var banner = null;
    +
    +  function getChoice() {
    +    try {
    +      var v = localStorage.getItem(STORAGE_KEY);
    +      if (v === 'all' || v === 'necessary' || v === 'reject') {
    +        return v;
    +      }
    +    } catch (e) { /* ignore */ }
    +    return null;
    +  }
    +
    +  function setChoice(value) {
    +    try {
    +      localStorage.setItem(STORAGE_KEY, value);
    +    } catch (e) { /* ignore */ }
    +    global.dispatchEvent(new CustomEvent('ac-cookie-consent', { detail: { choice: value } }));
    +    hideBanner();
    +  }
    +
    +  function allowsAnalytics() {
    +    return getChoice() === 'all';
    +  }
    +
    +  function allowsPreferenceCookies() {
    +    var c = getChoice();
    +    return c === 'all' || c === 'necessary';
    +  }
    +
    +  function hideBanner() {
    +    if (banner) {
    +      banner.hidden = true;
    +    }
    +  }
    +
    +  function ensureBanner() {
    +    if (banner || getChoice()) {
    +      return;
    +    }
    +    banner = document.getElementById('cookie-consent-banner');
    +    if (!banner) {
    +      return;
    +    }
    +    banner.hidden = false;
    +    var allBtn = document.getElementById('cookie-consent-all');
    +    var necBtn = document.getElementById('cookie-consent-necessary');
    +    var rejBtn = document.getElementById('cookie-consent-reject');
    +    if (allBtn) {
    +      allBtn.addEventListener('click', function () { setChoice('all'); });
    +    }
    +    if (necBtn) {
    +      necBtn.addEventListener('click', function () { setChoice('necessary'); });
    +    }
    +    if (rejBtn) {
    +      rejBtn.addEventListener('click', function () { setChoice('reject'); });
    +    }
    +  }
    +
    +  global.AndroidCastCookieConsent = {
    +    getChoice: getChoice,
    +    allowsAnalytics: allowsAnalytics,
    +    allowsPreferenceCookies: allowsPreferenceCookies,
    +    setChoice: setChoice
    +  };
    +
    +  if (document.readyState === 'loading') {
    +    document.addEventListener('DOMContentLoaded', ensureBanner);
    +  } else {
    +    ensureBanner();
    +  }
    +})(typeof window !== 'undefined' ? window : this);
    diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/graphs.js b/sim/cluster0/lab-seeds/backend/public/assets/js/graphs.js
    new file mode 100644
    index 0000000..01fcd2f
    --- /dev/null
    +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/graphs.js
    @@ -0,0 +1,1093 @@
    +(function () {
    +  const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8'];
    +
    +  const BRICK_DEFS = {
    +    sessions: { type: 'line', field: 'sessions_per_day', title: 'Sessions / day', color: COLORS[0] },
    +    devices: { type: 'line', field: 'unique_devices_per_day', title: 'Unique devices / day', color: COLORS[1] },
    +    duration: { type: 'line', field: 'avg_duration_s_per_day', title: 'Avg duration (s) / day', color: COLORS[2] },
    +    send: { type: 'line', field: 'send_sessions_per_day', title: 'Send sessions / day', color: COLORS[3] },
    +    recv: { type: 'line', field: 'recv_sessions_per_day', title: 'Receive sessions / day', color: COLORS[4] },
    +    bitrate: { type: 'line', field: 'avg_bitrate_kbps_per_day', title: 'Outbound kbps / day', color: COLORS[0] },
    +    'recv-kbps': { type: 'line', field: 'avg_recv_kbps_per_day', title: 'Inbound kbps / day', color: COLORS[1] },
    +    crashes: { type: 'line', field: 'crashes_per_day', title: 'Issues / day', color: '#f87171' },
    +    tickets: { type: 'line', field: 'tickets_per_day', title: 'Tickets / day', color: '#c084fc' },
    +    'install-pie': { type: 'pie', field: 'install_sources', title: 'Install sources' },
    +    'ntp-pie': { type: 'pie', field: 'ntp_sources', title: 'NTP sources' },
    +    transport: { type: 'breakdown', field: 'transport_mix', title: 'Transport mix' },
    +    versions: { type: 'breakdown', field: 'app_versions', title: 'App versions' },
    +    success: { type: 'kpi', field: 'success_ratio_pct', title: 'Success ratio', suffix: '%' },
    +    'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' },
    +    fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top issue fingerprints' },
    +    'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Issues by app version' },
    +    'live-casts': { type: 'line', field: 'casts_per_day', title: 'Live casts / day', color: COLORS[0] },
    +    'live-avg-length': { type: 'line', field: 'avg_length_s_per_day', title: 'Avg cast length (s) / day', color: COLORS[2] },
    +    'live-platform-pie': { type: 'pie', field: 'platform_mix', title: 'Cast platforms' },
    +    'live-join-platform-pie': { type: 'pie', field: 'joins_platform_mix', title: 'Join platforms' },
    +    'live-video-codec-pie': { type: 'pie', field: 'video_codec_mix', title: 'Video codecs' },
    +    'live-audio-codec-pie': { type: 'pie', field: 'audio_codec_mix', title: 'Audio codecs' },
    +    'live-bw-pie': { type: 'pie', field: 'bw_mode_mix', title: 'Bandwidth modes' },
    +    'live-top-users': { type: 'toplist', field: 'top_users', title: 'Top casters' },
    +  };
    +
    +  let graphScopes = { user: null, slug: null, platform: null };
    +  let liveCastScope = null;
    +  let detailState = null;
    +
    +  function basePath() {
    +    return document.body.getAttribute('data-base-path') || '';
    +  }
    +
    +  function el(id) {
    +    return document.getElementById(id);
    +  }
    +
    +  function escapeHtml(s) {
    +    return s
    +      .replace(/&/g, '&')
    +      .replace(//g, '>')
    +      .replace(/"/g, '"');
    +  }
    +
    +  function graphPermalink(scopeKey, suffix, days) {
    +    const url = new URL(window.location.href);
    +    url.searchParams.set('graph', scopeKey + '-' + suffix);
    +    if (days) {
    +      url.searchParams.set('days', String(days));
    +    }
    +    return url.toString();
    +  }
    +
    +  function syncGraphPermalink(scopeKey, suffix) {
    +    const daysSel = el('graphs-days');
    +    const days = daysSel && daysSel.value ? daysSel.value : '14';
    +    const link = graphPermalink(scopeKey, suffix, days);
    +    if (window.history && window.history.replaceState) {
    +      window.history.replaceState(null, '', link);
    +    }
    +    const share = el('graph-detail-share');
    +    if (share) {
    +      share.href = link;
    +    }
    +  }
    +
    +  function clearGraphPermalink() {
    +    const url = new URL(window.location.href);
    +    url.searchParams.delete('graph');
    +    if (window.history && window.history.replaceState) {
    +      window.history.replaceState(null, '', url.toString());
    +    }
    +  }
    +
    +  function openGraphFromPermalink() {
    +    const params = new URLSearchParams(window.location.search);
    +    const token = params.get('graph');
    +    if (!token) return;
    +    const node = document.getElementById('graph-' + token);
    +    if (!node) return;
    +    const card = node.closest('.card.card--lift');
    +    if (card) {
    +      openGraphDetail(card);
    +    }
    +  }
    +
    +  function parseCanvasId(id) {
    +    const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/);
    +    if (m) {
    +      const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
    +      return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null };
    +    }
    +    const live = String(id || '').match(/^graph-live-(.+)$/);
    +    if (live) {
    +      const suffix = 'live-' + live[1];
    +      return { scopeKey: 'live_cast', suffix: suffix, def: BRICK_DEFS[suffix] || null };
    +    }
    +    return null;
    +  }
    +
    +  function scopeData(scopeKey) {
    +    if (scopeKey === 'live_cast') return liveCastScope;
    +    if (scopeKey === 'user') return graphScopes.user;
    +    if (scopeKey === 'slug') return graphScopes.slug;
    +    return graphScopes.platform;
    +  }
    +
    +  const BRICK_ORDER_KEY = 'graphs_brick_order_v1';
    +
    +  function prepareCanvas(canvas, fallbackW, fallbackH) {
    +    const rect = canvas.getBoundingClientRect();
    +    const w = Math.max(280, Math.round(rect.width || fallbackW || canvas.width || 520));
    +    const h = Math.max(120, Math.round(rect.height || fallbackH || canvas.height || 160));
    +    const dpr = Math.min(2.5, window.devicePixelRatio || 1);
    +    canvas.width = Math.round(w * dpr);
    +    canvas.height = Math.round(h * dpr);
    +    canvas.style.width = w + 'px';
    +    canvas.style.height = h + 'px';
    +    const ctx = canvas.getContext('2d');
    +    if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    +    return { w, h, dpr };
    +  }
    +
    +  function drawLineChart(canvas, series, color, opts) {
    +    if (!canvas) return null;
    +    const interactive = opts && opts.interactive;
    +    const sized = prepareCanvas(canvas, 520, interactive ? 380 : 160);
    +    const w = sized.w;
    +    const h = sized.h;
    +    const ctx = canvas.getContext('2d');
    +    if (!ctx) return null;
    +    ctx.clearRect(0, 0, w, h);
    +    ctx.fillStyle = 'rgba(90,110,140,0.12)';
    +    ctx.fillRect(0, 0, w, h);
    +    const points = Array.isArray(series) ? series : [];
    +    if (!points.length) {
    +      ctx.fillStyle = '#94a3b8';
    +      ctx.font = '12px system-ui, sans-serif';
    +      ctx.fillText('No data', 24, h / 2);
    +      return { points: [], padL: 36, padT: 12, plotW: w - 48, plotH: h - 40, maxY: 1 };
    +    }
    +    let maxY = 0;
    +    points.forEach((p) => {
    +      const y = Number(p.y || 0);
    +      if (y > maxY) maxY = y;
    +    });
    +    if (maxY <= 0) maxY = 1;
    +    const padL = interactive ? 56 : 36;
    +    const padR = interactive ? 148 : 12;
    +    const padT = interactive ? 24 : 16;
    +    const padB = interactive ? 40 : 32;
    +    const plotW = w - padL - padR;
    +    const plotH = h - padT - padB;
    +    const lineColor = color || '#3d8bfd';
    +    const title = (opts && opts.title) || '';
    +
    +    ctx.strokeStyle = 'rgba(120,140,170,0.45)';
    +    ctx.lineWidth = 1;
    +    ctx.fillStyle = '#94a3b8';
    +    ctx.font = interactive ? '11px system-ui, sans-serif' : '10px system-ui, sans-serif';
    +    for (let i = 0; i <= 4; i++) {
    +      const y = padT + (plotH * i) / 4;
    +      ctx.beginPath();
    +      ctx.moveTo(padL, y);
    +      ctx.lineTo(w - padR, y);
    +      ctx.stroke();
    +      if (interactive) {
    +        const tickVal = Math.round((maxY * (4 - i)) / 4);
    +        ctx.fillText(String(tickVal), 8, y + 4);
    +      }
    +    }
    +
    +    const plotPoints = points.map((p, idx) => {
    +      const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
    +      const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
    +      return { x, y, raw: p, idx };
    +    });
    +
    +    ctx.strokeStyle = lineColor;
    +    ctx.lineWidth = interactive ? 2.5 : 2;
    +    ctx.beginPath();
    +    plotPoints.forEach((pt, idx) => {
    +      if (idx === 0) ctx.moveTo(pt.x, pt.y);
    +      else ctx.lineTo(pt.x, pt.y);
    +    });
    +    ctx.stroke();
    +
    +    if (interactive) {
    +      plotPoints.forEach((pt) => {
    +        ctx.beginPath();
    +        ctx.fillStyle = lineColor;
    +        ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
    +        ctx.fill();
    +      });
    +      if (title) {
    +        const lx = w - padR + 12;
    +        ctx.fillStyle = lineColor;
    +        ctx.fillRect(lx, padT, 12, 12);
    +        ctx.fillStyle = '#e2e8f0';
    +        ctx.font = '12px system-ui, sans-serif';
    +        const legend = title.length > 18 ? title.slice(0, 17) + '…' : title;
    +        ctx.fillText(legend, lx + 18, padT + 10);
    +        ctx.fillStyle = '#94a3b8';
    +        ctx.font = '10px system-ui, sans-serif';
    +        ctx.fillText('Y: value · X: date', lx, padT + 28);
    +      }
    +      const xTicks = Math.min(6, points.length);
    +      for (let t = 0; t < xTicks; t++) {
    +        const idx = xTicks === 1 ? 0 : Math.round((t * (points.length - 1)) / (xTicks - 1));
    +        const pt = plotPoints[idx];
    +        const label = String(points[idx].x || '');
    +        ctx.fillStyle = '#94a3b8';
    +        ctx.font = '10px system-ui, sans-serif';
    +        ctx.fillText(label, Math.max(padL, pt.x - 20), h - 12);
    +      }
    +    } else {
    +      ctx.fillStyle = '#94a3b8';
    +      ctx.font = '10px system-ui, sans-serif';
    +      if (points.length) {
    +        ctx.fillText(String(points[0].x || ''), padL, h - 8);
    +        const last = points[points.length - 1];
    +        ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8);
    +      }
    +    }
    +
    +    const highlightIdx = opts && opts.highlightIdx;
    +    if (highlightIdx != null && plotPoints[highlightIdx]) {
    +      const hi = plotPoints[highlightIdx];
    +      ctx.beginPath();
    +      ctx.fillStyle = '#f8fafc';
    +      ctx.strokeStyle = lineColor;
    +      ctx.lineWidth = 2;
    +      ctx.arc(hi.x, hi.y, 7, 0, Math.PI * 2);
    +      ctx.fill();
    +      ctx.stroke();
    +    }
    +
    +    return {
    +      points: plotPoints,
    +      padL,
    +      padT,
    +      padR,
    +      plotW,
    +      plotH,
    +      maxY,
    +      w,
    +      h,
    +      series: points,
    +      color: lineColor,
    +      title,
    +    };
    +  }
    +
    +  function detailCanvasSize() {
    +    const wrap = el('graph-detail-canvas-wrap');
    +    const maxW = wrap ? wrap.clientWidth - 8 : window.innerWidth - 48;
    +    return {
    +      w: Math.max(640, Math.min(1280, maxW)),
    +      h: Math.max(380, window.innerHeight - 200),
    +    };
    +  }
    +
    +  function redrawDetailChart(highlightIdx) {
    +    const state = detailState;
    +    if (!state || state.type !== 'line' || !state.canvas) return;
    +    const canvas = state.canvas;
    +    const meta = drawLineChart(canvas, state.series, state.def.color, {
    +      interactive: true,
    +      title: state.def.title,
    +    });
    +    if (meta && highlightIdx != null && meta.points[highlightIdx]) {
    +      const ctx = canvas.getContext('2d');
    +      const pt = meta.points[highlightIdx];
    +      ctx.strokeStyle = 'rgba(148,163,184,0.55)';
    +      ctx.lineWidth = 1;
    +      ctx.setLineDash([4, 4]);
    +      ctx.beginPath();
    +      ctx.moveTo(pt.x, meta.padT);
    +      ctx.lineTo(pt.x, meta.h - meta.padT);
    +      ctx.stroke();
    +      ctx.setLineDash([]);
    +      ctx.beginPath();
    +      ctx.fillStyle = state.def.color || '#3d8bfd';
    +      ctx.strokeStyle = '#fff';
    +      ctx.lineWidth = 2;
    +      ctx.arc(pt.x, pt.y, 7, 0, Math.PI * 2);
    +      ctx.fill();
    +      ctx.stroke();
    +    }
    +    detailState.meta = meta;
    +  }
    +
    +  function drawPie(canvas, rows, opts) {
    +    if (!canvas) return null;
    +    const interactive = opts && opts.interactive;
    +    const highlightIdx = opts && opts.highlightIdx;
    +    const sized = prepareCanvas(canvas, interactive ? 640 : 520, interactive ? 380 : 160);
    +    const w = sized.w;
    +    const h = sized.h;
    +    const ctx = canvas.getContext('2d');
    +    if (!ctx) return null;
    +    ctx.clearRect(0, 0, w, h);
    +    if (interactive) {
    +      ctx.fillStyle = 'rgba(90,110,140,0.12)';
    +      ctx.fillRect(0, 0, w, h);
    +    }
    +    const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : [];
    +    if (!data.length) {
    +      ctx.fillStyle = '#94a3b8';
    +      ctx.font = '12px system-ui, sans-serif';
    +      ctx.fillText('No data', 24, h / 2);
    +      return { slices: [], cx: 0, cy: 0, r: 0, data: [], total: 0, w, h };
    +    }
    +    const total = data.reduce((s, r) => s + Number(r.value || 0), 0);
    +    const cx = w * (interactive ? 0.38 : 0.35);
    +    const cy = h * 0.5;
    +    const r = Math.min(w, h) * (interactive ? 0.36 : 0.32);
    +    let angle = -Math.PI / 2;
    +    const slices = [];
    +    data.forEach((row, i) => {
    +      const slice = (Number(row.value) / total) * Math.PI * 2;
    +      const start = angle;
    +      const end = angle + slice;
    +      slices.push({ start, end, idx: i, row, color: COLORS[i % COLORS.length] });
    +      ctx.beginPath();
    +      ctx.moveTo(cx, cy);
    +      ctx.fillStyle = COLORS[i % COLORS.length];
    +      if (interactive && highlightIdx === i) {
    +        ctx.shadowColor = 'rgba(0,0,0,0.35)';
    +        ctx.shadowBlur = 10;
    +      } else {
    +        ctx.shadowBlur = 0;
    +      }
    +      ctx.arc(cx, cy, r, start, end);
    +      ctx.closePath();
    +      ctx.fill();
    +      ctx.shadowBlur = 0;
    +      angle = end;
    +    });
    +    const legendX = w * (interactive ? 0.58 : 0.62);
    +    let ly = interactive ? 28 : 16;
    +    data.forEach((row, i) => {
    +      const color = COLORS[i % COLORS.length];
    +      ctx.fillStyle = color;
    +      ctx.fillRect(legendX, ly, interactive ? 14 : 10, interactive ? 14 : 10);
    +      ctx.fillStyle = highlightIdx === i && interactive ? '#f8fafc' : '#cbd5e1';
    +      ctx.font = interactive ? '13px system-ui, sans-serif' : '11px system-ui, sans-serif';
    +      const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0;
    +      const label = String(row.label || '') + ' (' + pct + '%)';
    +      ctx.fillText(label, legendX + (interactive ? 22 : 16), ly + (interactive ? 12 : 9));
    +      ly += interactive ? 22 : 18;
    +    });
    +    return { slices, cx, cy, r, data, total, w, h };
    +  }
    +
    +  function redrawDetailPie(highlightIdx) {
    +    const state = detailState;
    +    if (!state || state.type !== 'pie' || !state.canvas) return;
    +    const meta = drawPie(state.canvas, state.series, { interactive: true, highlightIdx: highlightIdx });
    +    detailState.meta = meta;
    +  }
    +
    +  function pieSliceAt(meta, mx, my) {
    +    if (!meta || !meta.slices.length || meta.total <= 0) return -1;
    +    const dx = mx - meta.cx;
    +    const dy = my - meta.cy;
    +    if (Math.sqrt(dx * dx + dy * dy) > meta.r) return -1;
    +    let rel = Math.atan2(dy, dx) + Math.PI / 2;
    +    if (rel < 0) rel += Math.PI * 2;
    +    if (rel >= Math.PI * 2) rel -= Math.PI * 2;
    +    let acc = 0;
    +    for (let i = 0; i < meta.data.length; i++) {
    +      const slice = (Number(meta.data[i].value) / meta.total) * Math.PI * 2;
    +      if (rel >= acc && rel < acc + slice) return i;
    +      acc += slice;
    +    }
    +    return -1;
    +  }
    +
    +  function renderBreakdownList(target, rows) {
    +    if (!target) return;
    +    if (!Array.isArray(rows) || !rows.length) {
    +      target.textContent = 'No data';
    +      return;
    +    }
    +    const total = rows.reduce((s, r) => s + Number(r.value || 0), 0) || 1;
    +    target.innerHTML = rows
    +      .map((r) => {
    +        const pct = Math.round((100 * Number(r.value || 0)) / total);
    +        return (
    +          '
    ' + + escapeHtml(String(r.label || '')) + + '' + + Number(r.value || 0) + + ' (' + + pct + + '%)
    ' + ); + }) + .join(''); + } + + function renderTopList(target, rows) { + if (!target) return; + if (!Array.isArray(rows) || !rows.length) { + target.textContent = 'No data'; + return; + } + target.innerHTML = rows + .map((r) => { + const label = escapeHtml(String(r.label || '')); + const val = Number(r.value || 0); + if (r.href) { + return ( + '
    ' + + label + + '' + + val + + '
    ' + ); + } + return '
    ' + label + '' + val + '
    '; + }) + .join(''); + } + + function renderScope(prefix, scope) { + if (!scope) return; + drawLineChart(el('graph-' + prefix + '-sessions'), scope.sessions_per_day, COLORS[0]); + drawLineChart(el('graph-' + prefix + '-devices'), scope.unique_devices_per_day, COLORS[1]); + drawLineChart(el('graph-' + prefix + '-duration'), scope.avg_duration_s_per_day, COLORS[2]); + drawLineChart(el('graph-' + prefix + '-send'), scope.send_sessions_per_day, COLORS[3]); + drawLineChart(el('graph-' + prefix + '-recv'), scope.recv_sessions_per_day, COLORS[4]); + drawLineChart(el('graph-' + prefix + '-bitrate'), scope.avg_bitrate_kbps_per_day, COLORS[0]); + drawLineChart(el('graph-' + prefix + '-recv-kbps'), scope.avg_recv_kbps_per_day, COLORS[1]); + drawLineChart(el('graph-' + prefix + '-crashes'), scope.crashes_per_day, '#f87171'); + if (scope.tickets_per_day) { + drawLineChart(el('graph-' + prefix + '-tickets'), scope.tickets_per_day, '#c084fc'); + } + drawPie(el('graph-' + prefix + '-install-pie'), scope.install_sources); + drawPie(el('graph-' + prefix + '-ntp-pie'), scope.ntp_sources); + renderBreakdownList(el('graph-' + prefix + '-transport'), scope.transport_mix); + renderBreakdownList(el('graph-' + prefix + '-versions'), scope.app_versions); + const ratioEl = el('graph-' + prefix + '-success'); + if (ratioEl) { + ratioEl.textContent = + typeof scope.success_ratio_pct === 'number' ? scope.success_ratio_pct + '%' : '—'; + } + const ntpAvg = el('graph-' + prefix + '-ntp-avg'); + if (ntpAvg && scope.ntp_correction_avg_s != null) { + ntpAvg.textContent = scope.ntp_correction_avg_s + ' s avg correction'; + } + renderTopList(el('graph-' + prefix + '-fingerprints'), scope.top_crash_fingerprints); + drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version); + } + + function renderLiveCast(scope) { + if (!scope) { + const section = document.getElementById('graphs-live-cast-section'); + if (section) section.hidden = true; + return; + } + const section = document.getElementById('graphs-live-cast-section'); + if (section) section.hidden = false; + drawLineChart(el('graph-live-casts'), scope.casts_per_day, COLORS[0]); + drawLineChart(el('graph-live-avg-length'), scope.avg_length_s_per_day, COLORS[2]); + drawPie(el('graph-live-platform-pie'), scope.platform_mix); + drawPie(el('graph-live-join-platform-pie'), scope.joins_platform_mix); + drawPie(el('graph-live-video-codec-pie'), scope.video_codec_mix); + drawPie(el('graph-live-audio-codec-pie'), scope.audio_codec_mix); + drawPie(el('graph-live-bw-pie'), scope.bw_mode_mix); + renderTopList(el('graph-live-top-users'), scope.top_users); + } + + function setScopeVisibility(viewer) { + document.querySelectorAll('[data-graph-scope]').forEach((section) => { + const need = section.getAttribute('data-graph-scope'); + let show = false; + if (viewer === 'platform_admin') { + show = need === 'platform_admin'; + } else if (viewer === 'slug_admin') { + show = need === 'slug_admin' || need === 'user'; + } else { + show = need === 'user'; + } + section.hidden = !show; + }); + } + + function applyColumns(cols) { + const app = el('graphs-app'); + if (!app) return; + const n = Math.min(4, Math.max(1, Number(cols) || 2)); + app.style.setProperty('--graphs-columns', String(n)); + } + + function markGraphBricks() { + document.querySelectorAll('.graphs-grid .card.card--lift').forEach((card) => { + const canvas = card.querySelector('canvas'); + const breakdown = card.querySelector('.graph-breakdown'); + const kpi = card.querySelector('.graph-kpi'); + if (!canvas && !breakdown && !kpi) return; + card.classList.add('graph-brick--clickable'); + card.setAttribute('tabindex', '0'); + card.setAttribute('role', 'button'); + const title = card.querySelector('h3'); + if (title) card.setAttribute('aria-label', 'Open full view: ' + title.textContent); + if (canvas && canvas.id) { + card.setAttribute('data-brick-id', canvas.id.replace(/^graph-/, '')); + } else if (breakdown && breakdown.id) { + card.setAttribute('data-brick-id', breakdown.id.replace(/^graph-/, '')); + } else if (kpi && kpi.id) { + card.setAttribute('data-brick-id', kpi.id.replace(/^graph-/, '')); + } + if (title && !title.querySelector('.graph-brick-drag-handle')) { + title.classList.add('graph-brick-head'); + const dragLabel = + (typeof window.t === 'function' && window.t('graphs.brick_drag')) || 'Drag to reorder'; + const handle = document.createElement('button'); + handle.type = 'button'; + handle.className = 'graph-brick-drag-handle'; + handle.setAttribute('draggable', 'true'); + handle.setAttribute('aria-label', dragLabel); + handle.title = dragLabel; + handle.innerHTML = ''; + title.insertBefore(handle, title.firstChild); + } + if (canvas && !card.querySelector('.graph-brick-tip')) { + const tip = document.createElement('p'); + tip.className = 'graph-brick-tip'; + tip.hidden = true; + card.style.position = 'relative'; + card.appendChild(tip); + } + }); + } + + function fieldHasData(def, scope) { + if (!def || !scope) return false; + const field = scope[def.field]; + if (def.type === 'line') { + return Array.isArray(field) && field.some((p) => Number(p.y || 0) > 0); + } + if (def.type === 'pie' || def.type === 'breakdown' || def.type === 'toplist') { + return Array.isArray(field) && field.some((r) => Number(r.value || 0) > 0); + } + if (def.type === 'kpi') { + return field != null && field !== '' && String(field) !== '—'; + } + return false; + } + + function hideEmptyBricks() { + document.querySelectorAll('.graphs-grid .card.card--lift').forEach((card) => { + const canvas = card.querySelector('canvas'); + const breakdown = card.querySelector('.graph-breakdown'); + const kpi = card.querySelector('.graph-kpi'); + let empty = true; + const brickId = card.getAttribute('data-brick-id') || ''; + const m = brickId.match(/^(user|slug|platform)-(.+)$/); + if (m) { + const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; + const def = BRICK_DEFS[m[2]]; + empty = !fieldHasData(def, scopeData(scopeKey)); + } else if (brickId.startsWith('live-')) { + const def = BRICK_DEFS[brickId]; + empty = !fieldHasData(def, liveCastScope); + } else if (canvas && canvas.id) { + const parsed = parseCanvasId(canvas.id); + empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey)); + } else if (breakdown) { + empty = breakdown.textContent.trim() === 'No data' || breakdown.innerHTML.trim() === ''; + } else if (kpi) { + empty = (kpi.textContent || '').trim() === '—'; + } + card.classList.toggle('graph-brick--empty', empty); + }); + } + + function sessionMetricsDeficit(scope) { + if (!scope) return true; + const sessionFields = [ + 'sessions_per_day', + 'unique_devices_per_day', + 'avg_duration_s_per_day', + 'send_sessions_per_day', + 'recv_sessions_per_day', + 'avg_bitrate_kbps_per_day', + ]; + return !sessionFields.some((key) => { + const rows = scope[key]; + return Array.isArray(rows) && rows.some((p) => Number(p.y || 0) > 0); + }); + } + + function readBrickOrder(scopeKey) { + try { + const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}'); + return Array.isArray(all[scopeKey]) ? all[scopeKey] : []; + } catch { + return []; + } + } + + function saveBrickOrder(scopeKey, ids) { + try { + const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}'); + all[scopeKey] = ids; + localStorage.setItem(BRICK_ORDER_KEY, JSON.stringify(all)); + } catch { /* ignore */ } + } + + function applyBrickOrder() { + document.querySelectorAll('.graphs-scope').forEach((section) => { + if (section.hidden) return; + const scopeKey = section.getAttribute('data-graph-scope') || 'user'; + const grid = section.querySelector('.graphs-grid'); + if (!grid) return; + const order = readBrickOrder(scopeKey); + if (!order.length) return; + const cards = Array.from(grid.querySelectorAll('.card.card--lift')); + const byId = new Map(); + cards.forEach((c) => { + const id = c.getAttribute('data-brick-id'); + if (id) byId.set(id, c); + }); + order.forEach((id) => { + const card = byId.get(id); + if (card) grid.appendChild(card); + }); + }); + } + + let brickDragMoved = false; + + function initBrickDragDrop() { + let dragCard = null; + document.querySelectorAll('.graphs-grid').forEach((grid) => { + grid.addEventListener('dragstart', (ev) => { + const handle = ev.target.closest('.graph-brick-drag-handle'); + if (!handle) { + ev.preventDefault(); + return; + } + const card = handle.closest('.card.card--lift'); + if (!card || !grid.contains(card)) return; + dragCard = card; + brickDragMoved = false; + card.classList.add('graph-brick--dragging'); + if (ev.dataTransfer) { + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData('text/plain', card.getAttribute('data-brick-id') || ''); + } + }); + grid.addEventListener('dragend', () => { + if (dragCard) dragCard.classList.remove('graph-brick--dragging'); + dragCard = null; + const section = grid.closest('.graphs-scope'); + const scopeKey = section ? section.getAttribute('data-graph-scope') || 'user' : 'user'; + const ids = Array.from(grid.querySelectorAll('.card.card--lift')) + .map((c) => c.getAttribute('data-brick-id')) + .filter(Boolean); + saveBrickOrder(scopeKey, ids); + }); + grid.addEventListener('dragover', (ev) => { + ev.preventDefault(); + if (!dragCard) return; + brickDragMoved = true; + const over = ev.target.closest('.card.card--lift'); + if (!over || over === dragCard || !grid.contains(over)) return; + const rect = over.getBoundingClientRect(); + const before = ev.clientY < rect.top + rect.height / 2; + grid.insertBefore(dragCard, before ? over : over.nextSibling); + }); + }); + } + + function attachBrickPreviewHover() { + document.querySelectorAll('.graphs-grid .card.card--lift canvas').forEach((canvas) => { + const card = canvas.closest('.card'); + const tip = card ? card.querySelector('.graph-brick-tip') : null; + if (!card || !tip) return; + const parsed = parseCanvasId(canvas.id); + if (!parsed || !parsed.def || parsed.def.type !== 'line') return; + const scope = scopeData(parsed.scopeKey); + if (!scope || !Array.isArray(scope[parsed.def.field])) return; + const series = scope[parsed.def.field]; + const meta = drawLineChart(canvas, series, parsed.def.color, { interactive: false }); + if (!meta || !meta.points.length) return; + canvas.onmousemove = function (ev) { + const rect = canvas.getBoundingClientRect(); + const scaleX = meta.w / rect.width; + const x = (ev.clientX - rect.left) * scaleX; + let best = null; + let bestD = Infinity; + meta.points.forEach((pt) => { + const d = Math.abs(pt.x - x); + if (d < bestD) { + bestD = d; + best = pt; + } + }); + if (!best || bestD > 32) { + tip.hidden = true; + drawLineChart(canvas, series, parsed.def.color, { interactive: false }); + return; + } + const raw = best.raw || {}; + tip.hidden = false; + tip.textContent = parsed.def.title + ': ' + String(raw.x || '') + ' → ' + Number(raw.y || 0); + tip.style.left = Math.min(card.clientWidth - 12, ev.clientX - card.getBoundingClientRect().left + 10) + 'px'; + tip.style.top = Math.max(8, ev.clientY - card.getBoundingClientRect().top - 28) + 'px'; + drawLineChart(canvas, series, parsed.def.color, { interactive: false, highlightIdx: best.idx }); + }; + canvas.onmouseleave = function () { + tip.hidden = true; + drawLineChart(canvas, series, parsed.def.color, { interactive: false }); + }; + }); + } + + function detachDetailResize() { + if (detailState && detailState.resizeHandler) { + window.removeEventListener('resize', detailState.resizeHandler); + } + } + + function attachDetailResize(canvas, redrawFn) { + detachDetailResize(); + const handler = function () { + if (!detailState || detailState.canvas !== canvas) return; + const size = detailCanvasSize(); + canvas.width = size.w; + canvas.height = size.h; + redrawFn(null); + }; + if (detailState) detailState.resizeHandler = handler; + window.addEventListener('resize', handler, { passive: true }); + } + + function closeGraphDetail() { + detachDetailResize(); + const overlay = el('graph-detail-overlay'); + if (overlay) overlay.hidden = true; + detailState = null; + clearGraphPermalink(); + const tip = el('graph-detail-tooltip'); + if (tip) tip.hidden = true; + const statsEl = el('graph-detail-stats'); + if (statsEl) statsEl.textContent = ''; + } + + function openGraphDetail(card) { + const canvas = card.querySelector('canvas'); + const breakdown = card.querySelector('.graph-breakdown'); + const kpi = card.querySelector('.graph-kpi'); + let parsed = null; + if (canvas && canvas.id) parsed = parseCanvasId(canvas.id); + if (!parsed && breakdown && breakdown.id) parsed = parseCanvasId(breakdown.id); + if (!parsed && kpi && kpi.id) parsed = parseCanvasId(kpi.id); + if (!parsed || !parsed.def) return; + + const scope = scopeData(parsed.scopeKey); + if (!scope) return; + const def = parsed.def; + syncGraphPermalink(parsed.scopeKey, parsed.suffix); + const fieldVal = scope[def.field]; + const titleEl = el('graph-detail-title'); + const canvasEl = el('graph-detail-canvas'); + const bodyEl = el('graph-detail-body'); + const overlay = el('graph-detail-overlay'); + if (!overlay || !titleEl || !bodyEl) return; + + titleEl.textContent = def.title; + bodyEl.innerHTML = ''; + if (canvasEl) canvasEl.hidden = true; + + if (def.type === 'line' && canvasEl) { + canvasEl.hidden = false; + const size = detailCanvasSize(); + canvasEl.width = size.w; + canvasEl.height = size.h; + detailState = { + type: 'line', + def, + series: fieldVal, + canvas: canvasEl, + meta: null, + }; + redrawDetailChart(null); + attachLineHover(canvasEl, def); + attachDetailResize(canvasEl, redrawDetailChart); + const statsEl = el('graph-detail-stats'); + if (statsEl) { + statsEl.textContent = + (fieldVal && fieldVal.length ? fieldVal.length + ' points · ' : '') + + 'Move pointer over chart for values'; + } + } else if (def.type === 'pie' && canvasEl) { + canvasEl.hidden = false; + const size = detailCanvasSize(); + canvasEl.width = size.w; + canvasEl.height = size.h; + detailState = { + type: 'pie', + def, + series: fieldVal, + canvas: canvasEl, + meta: null, + }; + redrawDetailPie(null); + attachPieHover(canvasEl, def); + attachDetailResize(canvasEl, redrawDetailPie); + const statsEl = el('graph-detail-stats'); + if (statsEl) { + const n = Array.isArray(fieldVal) ? fieldVal.filter((r) => Number(r.value) > 0).length : 0; + statsEl.textContent = + (n ? n + ' segments · ' : '') + 'Hover slices for value breakdown'; + } + } else if (def.type === 'breakdown' || def.type === 'toplist') { + const wrap = document.createElement('div'); + wrap.className = 'graph-detail-table graph-breakdown'; + if (!Array.isArray(fieldVal) || !fieldVal.length) { + wrap.textContent = 'No data'; + } else if (def.type === 'toplist') { + renderTopList(wrap, fieldVal); + } else { + renderBreakdownList(wrap, fieldVal); + } + bodyEl.appendChild(wrap); + detailState = { type: def.type }; + } else if (def.type === 'kpi') { + const p = document.createElement('p'); + p.className = 'graph-kpi graph-detail-kpi'; + let text = '—'; + if (typeof fieldVal === 'number') { + text = String(fieldVal) + (def.suffix || ''); + } else if (fieldVal != null) { + text = String(fieldVal); + } + p.textContent = text; + bodyEl.appendChild(p); + detailState = { type: 'kpi' }; + } + + overlay.hidden = false; + } + + function valueUnit(def) { + if (!def || !def.title) return ''; + if (def.title.indexOf('kbps') >= 0) return ' kbps'; + if (def.title.indexOf('duration') >= 0) return ' s'; + return ''; + } + + function attachLineHover(canvas, def) { + const tip = el('graph-detail-tooltip'); + const statsEl = el('graph-detail-stats'); + const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap'); + if (!wrap) return; + + canvas.onmousemove = function (ev) { + const meta = detailState && detailState.meta; + if (!meta || !meta.points.length) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const x = (ev.clientX - rect.left) * scaleX; + let best = null; + let bestD = Infinity; + let bestIdx = -1; + meta.points.forEach((pt, idx) => { + const d = Math.abs(pt.x - x); + if (d < bestD) { + bestD = d; + best = pt; + bestIdx = idx; + } + }); + if (!best || bestD > 32) { + if (tip) tip.hidden = true; + redrawDetailChart(null); + return; + } + redrawDetailChart(bestIdx); + const raw = best.raw || {}; + const unit = valueUnit(def); + const label = String(raw.x || '') + ' → ' + Number(raw.y || 0) + unit; + if (tip) { + tip.hidden = false; + tip.textContent = label; + const wrapRect = wrap.getBoundingClientRect(); + tip.style.left = ev.clientX - wrapRect.left + 14 + 'px'; + tip.style.top = ev.clientY - wrapRect.top - 10 + 'px'; + } + if (statsEl) { + statsEl.textContent = def.title + ' · ' + label; + } + }; + canvas.onmouseleave = function () { + if (tip) tip.hidden = true; + redrawDetailChart(null); + if (statsEl && detailState && detailState.series) { + statsEl.textContent = + detailState.series.length + ' points · Move pointer over chart for values'; + } + }; + } + + function attachPieHover(canvas, def) { + const tip = el('graph-detail-tooltip'); + const statsEl = el('graph-detail-stats'); + const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap'); + if (!wrap) return; + + canvas.onmousemove = function (ev) { + const meta = detailState && detailState.meta; + if (!meta || !meta.slices.length) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const mx = (ev.clientX - rect.left) * scaleX; + const my = (ev.clientY - rect.top) * scaleY; + const idx = pieSliceAt(meta, mx, my); + if (idx < 0) { + if (tip) tip.hidden = true; + redrawDetailPie(null); + if (statsEl) { + statsEl.textContent = + meta.data.length + ' segments · Hover slices for value breakdown'; + } + return; + } + redrawDetailPie(idx); + const row = meta.data[idx] || {}; + const pct = meta.total > 0 ? Math.round((100 * Number(row.value)) / meta.total) : 0; + const label = + String(row.label || '') + ': ' + Number(row.value || 0) + ' (' + pct + '%)'; + if (tip) { + tip.hidden = false; + tip.textContent = label; + const wrapRect = wrap.getBoundingClientRect(); + tip.style.left = ev.clientX - wrapRect.left + 14 + 'px'; + tip.style.top = ev.clientY - wrapRect.top - 10 + 'px'; + } + if (statsEl) { + statsEl.textContent = def.title + ' · ' + label; + } + }; + canvas.onmouseleave = function () { + if (tip) tip.hidden = true; + redrawDetailPie(null); + if (statsEl && detailState && detailState.series) { + const n = detailState.series.filter((r) => Number(r.value) > 0).length; + statsEl.textContent = n + ' segments · Hover slices for value breakdown'; + } + }; + } + + function bootGraphs() { + const app = el('graphs-app'); + if (!app) return; + const status = el('graphs-status'); + const daysSel = el('graphs-days'); + const colsSel = el('graphs-columns'); + + const overlay = el('graph-detail-overlay'); + if (overlay) { + overlay.addEventListener('click', (ev) => { + if (ev.target === overlay) closeGraphDetail(); + }); + const closeBtn = el('graph-detail-close'); + if (closeBtn) closeBtn.addEventListener('click', closeGraphDetail); + const copyBtn = el('graph-detail-copy-link'); + if (copyBtn) { + copyBtn.addEventListener('click', function () { + const share = el('graph-detail-share'); + const link = (share && share.href && share.href !== '#') ? share.href : window.location.href; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(link).then(function () { + copyBtn.textContent = 'Copied'; + setTimeout(function () { copyBtn.textContent = 'Copy link'; }, 1500); + }).catch(function () { window.prompt('Copy graph link:', link); }); + } else { + window.prompt('Copy graph link:', link); + } + }); + } + } + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') closeGraphDetail(); + }); + + if (colsSel) { + applyColumns(colsSel.value); + colsSel.addEventListener('change', () => applyColumns(colsSel.value)); + } else { + applyColumns(2); + } + + initBrickDragDrop(); + + app.addEventListener('click', (ev) => { + if (ev.target.closest('.graph-brick-drag-handle') || brickDragMoved) { + brickDragMoved = false; + return; + } + const card = ev.target.closest('.graph-brick--clickable'); + if (card && app.contains(card)) openGraphDetail(card); + }); + app.addEventListener('keydown', (ev) => { + const card = ev.target.closest('.graph-brick--clickable'); + if (card && (ev.key === 'Enter' || ev.key === ' ')) { + ev.preventDefault(); + openGraphDetail(card); + } + }); + + function load() { + const params = new URLSearchParams(window.location.search); + const daysParam = params.get('days'); + if (daysParam && daysSel) { + daysSel.value = daysParam; + } + const days = Number(daysSel && daysSel.value ? daysSel.value : 14); + status.textContent = 'Loading…'; + const xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true); + xhr.onload = function () { + let payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch { + status.textContent = 'Invalid response'; + return; + } + if (!payload || !payload.ok) { + status.textContent = (payload && payload.error) || 'Failed'; + return; + } + const data = payload.data || {}; + const viewer = data.viewer || 'user'; + graphScopes.user = data.user || null; + graphScopes.slug = data.slug_admin || null; + graphScopes.platform = data.platform_admin || null; + liveCastScope = data.live_cast || null; + setScopeVisibility(viewer); + renderScope('user', graphScopes.user); + if (graphScopes.slug) renderScope('slug', graphScopes.slug); + if (graphScopes.platform) renderScope('platform', graphScopes.platform); + renderLiveCast(liveCastScope); + markGraphBricks(); + hideEmptyBricks(); + applyBrickOrder(); + attachBrickPreviewHover(); + const cols = colsSel && colsSel.value ? colsSel.value : '2'; + let statusText = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols; + const visibleScope = + viewer === 'platform_admin' ? graphScopes.platform : viewer === 'slug_admin' ? graphScopes.slug : graphScopes.user; + const deficitNotice = el('graphs-session-notice'); + const deficit = sessionMetricsDeficit(visibleScope); + if (deficitNotice) { + deficitNotice.hidden = !deficit; + } + status.textContent = statusText; + openGraphFromPermalink(); + }; + xhr.onerror = function () { + status.textContent = 'Network error'; + }; + xhr.send(); + } + + if (daysSel) { + daysSel.addEventListener('change', load); + } + load(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootGraphs); + } else { + bootGraphs(); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/i18n.js b/sim/cluster0/lab-seeds/backend/public/assets/js/i18n.js new file mode 100644 index 0000000..c858bb0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/i18n.js @@ -0,0 +1,207 @@ +/** + * Crash console i18n — JSON catalogs (en default), same key style as Android strings.xml. + */ +(function (global) { + const STORAGE_KEY = 'crash_console_lang'; + const SUPPORTED = ['en', 'ru']; + const DEFAULT_LANG = 'en'; + + /** @type {Record} */ + const LANG_DISPLAY = { + en: { flag: '\uD83C\uDDEC\uD83C\uDDE7', code: 'EN' }, + ru: { flag: '\uD83C\uDDF7\uD83C\uDDFA', code: 'RU' }, + }; + + let lang = DEFAULT_LANG; + let catalog = {}; + let fallback = {}; + let readyResolve; + const ready = new Promise((resolve) => { + readyResolve = resolve; + }); + + function basePath() { + const body = document.body; + return body ? body.getAttribute('data-base-path') || '' : ''; + } + + /** Close stray modal layers so fixed backdrops never block login/console UI. */ + function dismissPlatformOverlays() { + document.querySelectorAll('#graph-detail-overlay, #tag-modal, #hub-docs-modal').forEach((el) => { + el.hidden = true; + el.setAttribute('aria-hidden', 'true'); + }); + const dialog = document.getElementById('ticket-create-dialog'); + if (dialog && dialog.open && typeof dialog.close === 'function') { + dialog.close(); + } + } + + function storedLang() { + try { + const v = localStorage.getItem(STORAGE_KEY); + return SUPPORTED.includes(v) ? v : DEFAULT_LANG; + } catch { + return DEFAULT_LANG; + } + } + + function persistLang(code) { + try { + localStorage.setItem(STORAGE_KEY, code); + } catch { /* ignore */ } + } + + function fetchCatalog(code) { + const url = basePath() + '/assets/i18n/' + encodeURIComponent(code) + '.json'; + return fetch(url, { credentials: 'same-origin' }).then((res) => { + if (!res.ok) throw new Error('i18n load failed: ' + code); + return res.json(); + }); + } + + /** + * @param {string} key + * @param {Record|undefined} params + */ + function t(key, params) { + let s = catalog[key] ?? fallback[key] ?? key; + if (params) { + Object.keys(params).forEach((k) => { + s = s.split('{' + k + '}').join(String(params[k])); + }); + } + return s; + } + + function apply(root) { + const scope = root || document; + scope.querySelectorAll('[data-i18n]').forEach((el) => { + const key = el.getAttribute('data-i18n'); + if (!key) return; + if (el.hasAttribute('data-i18n-html')) { + el.innerHTML = t(key); + } else { + el.textContent = t(key); + } + }); + scope.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { + const key = el.getAttribute('data-i18n-placeholder'); + if (key) el.setAttribute('placeholder', t(key)); + }); + scope.querySelectorAll('[data-i18n-title]').forEach((el) => { + const key = el.getAttribute('data-i18n-title'); + if (key) el.setAttribute('title', t(key)); + }); + scope.querySelectorAll('[data-i18n-aria]').forEach((el) => { + const key = el.getAttribute('data-i18n-aria'); + if (key) el.setAttribute('aria-label', t(key)); + }); + scope.querySelectorAll('select[data-i18n-options]').forEach((sel) => { + const prefix = sel.getAttribute('data-i18n-options'); + if (!prefix) return; + sel.querySelectorAll('option[data-i18n]').forEach((opt) => { + const k = opt.getAttribute('data-i18n'); + if (k) opt.textContent = t(k); + }); + }); + scope.querySelectorAll('.platform-footer__left[data-i18n], footer[data-i18n="footer.copyright"]').forEach((footer) => { + const year = footer.getAttribute('data-year') || String(new Date().getFullYear()); + footer.textContent = t(footer.getAttribute('data-i18n') || 'footer.copyright', { year }); + }); + scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => { + el.textContent = t('detail.report_meta', { + id: el.getAttribute('data-i18n-id') || '', + type: el.getAttribute('data-i18n-type') || '', + }); + }); + } + + function langOptionLabel(code) { + const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG]; + return meta.flag + ' ' + meta.code; + } + + function syncLangSelect() { + document.querySelectorAll('.lang-select').forEach((sel) => { + SUPPORTED.forEach((code) => { + const opt = sel.querySelector('option[value="' + code + '"]'); + if (opt) opt.textContent = langOptionLabel(code); + }); + if (sel.value !== lang) sel.value = lang; + }); + syncLocaleDisplay(lang); + } + + function syncLocaleDisplay(code) { + const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG]; + document.querySelectorAll('.locale-flag').forEach((el) => { + el.textContent = meta.flag; + }); + document.querySelectorAll('.locale-code').forEach((el) => { + el.textContent = meta.code; + }); + } + + async function setLang(code, options) { + const opts = options || {}; + if (!SUPPORTED.includes(code)) code = DEFAULT_LANG; + fallback = await fetchCatalog(DEFAULT_LANG); + catalog = code === DEFAULT_LANG ? { ...fallback } : await fetchCatalog(code); + lang = code; + document.documentElement.setAttribute('lang', code); + persistLang(code); + syncLangSelect(); + if (!opts.silent) { + apply(document); + document.dispatchEvent(new CustomEvent('crash-i18n-change', { detail: { lang: code } })); + } + } + + function getLang() { + return lang; + } + + function initLangSelect() { + document.querySelectorAll('.lang-select').forEach((sel) => { + sel.value = lang; + if (sel.dataset.i18nBound === '1') return; + sel.dataset.i18nBound = '1'; + sel.addEventListener('change', () => { + setLang(sel.value).catch(() => setLang(DEFAULT_LANG)); + }); + }); + } + + async function init() { + dismissPlatformOverlays(); + lang = storedLang(); + document.documentElement.setAttribute('lang', lang); + await setLang(lang, { silent: true }); + apply(document); + initLangSelect(); + syncLocaleDisplay(lang); + readyResolve(); + } + + global.CrashI18n = { + t, + apply, + setLang, + getLang, + init, + ready, + SUPPORTED, + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + init().catch(() => { + lang = DEFAULT_LANG; + readyResolve(); + }); + }); + } else { + init().catch(() => readyResolve()); + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/live_cast_webrtc.js b/sim/cluster0/lab-seeds/backend/public/assets/js/live_cast_webrtc.js new file mode 100644 index 0000000..4e98a09 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/live_cast_webrtc.js @@ -0,0 +1,256 @@ +(function (global) { + 'use strict'; + + var STUN = [{ urls: 'stun:stun.l.google.com:19302' }]; + + function signalingUrl(basePath) { + return basePath + '/api/live_cast_signaling.php'; + } + + function castApiUrl(basePath) { + return basePath + '/api/live_cast.php'; + } + + function xhrJson(method, url, body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url, true); + if (body != null) { + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + } + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(body != null ? JSON.stringify(body) : null); + } + + function postSignal(basePath, sessionId, peerRole, msgType, payload, cb) { + xhrJson('POST', signalingUrl(basePath), { + action: 'post', + session_id: sessionId, + peer_role: peerRole, + msg_type: msgType, + payload: payload + }, cb); + } + + function pollSignals(basePath, sessionId, sinceId, fromRole, cb) { + var qs = '?action=poll&session_id=' + encodeURIComponent(sessionId) + + '&since_id=' + encodeURIComponent(String(sinceId || 0)); + if (fromRole) { + qs += '&from_role=' + encodeURIComponent(fromRole); + } + xhrJson('GET', signalingUrl(basePath) + qs, null, cb); + } + + function collectStats(pc, videoEl) { + var stats = { + ice_state: pc ? pc.iceConnectionState : 'new', + signaling_state: pc ? pc.signalingState : 'stable', + fps: 0, + inbound_kbps: 0, + outbound_kbps: 0, + rtt_ms: 0, + frames_decoded: 0 + }; + if (videoEl && videoEl.videoWidth) { + stats.frame_w = videoEl.videoWidth; + stats.frame_h = videoEl.videoHeight; + } + return stats; + } + + function enrichStatsFromRtc(pc, stats, cb) { + if (!pc || !pc.getStats) { + cb(stats); + return; + } + pc.getStats().then(function (report) { + report.forEach(function (r) { + if (r.type === 'inbound-rtp' && r.kind === 'video') { + stats.frames_decoded = r.framesDecoded || stats.frames_decoded; + if (r.bytesReceived && r.timestamp) { + stats.inbound_kbps = Math.round((r.bytesReceived * 8) / 1000); + } + } + if (r.type === 'outbound-rtp' && r.kind === 'video') { + if (r.bytesSent) { + stats.outbound_kbps = Math.round((r.bytesSent * 8) / 1000); + } + } + if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime) { + stats.rtt_ms = Math.round(r.currentRoundTripTime * 1000); + } + }); + cb(stats); + }).catch(function () { cb(stats); }); + } + + function sendHeartbeat(basePath, sessionId, status, stats, cb) { + var body = { action: 'heartbeat', session_id: sessionId, status: status || 'active' }; + if (stats) body.stats = stats; + xhrJson('POST', castApiUrl(basePath), body, cb || function () {}); + } + + /** + * @param {object} opts + * @param {string} opts.basePath + * @param {string} opts.sessionId + * @param {'caster'|'viewer'|'mobile'} opts.role + * @param {MediaStream|null} opts.localStream + * @param {function(MediaStream)} [opts.onRemoteStream] + * @param {function(object)} [opts.onStats] + * @param {function(string)} [opts.onStatus] + */ + function connect(opts) { + var basePath = opts.basePath || ''; + var sessionId = opts.sessionId || ''; + var role = opts.role || 'viewer'; + var localStream = opts.localStream || null; + var onRemoteStream = opts.onRemoteStream || function () {}; + var onStats = opts.onStats || function () {}; + var onStatus = opts.onStatus || function () {}; + var remoteRole = role === 'caster' ? 'viewer' : 'caster'; + var sinceId = 0; + var pc = new RTCPeerConnection({ iceServers: STUN }); + var pollTimer = null; + var hbTimer = null; + var remoteVideo = null; + var stopped = false; + + function status(msg) { + onStatus(msg); + } + + function teardown() { + stopped = true; + if (pollTimer) clearInterval(pollTimer); + if (hbTimer) clearInterval(hbTimer); + pollTimer = null; + hbTimer = null; + try { pc.close(); } catch (e) { /* ignore */ } + } + + pc.onicecandidate = function (ev) { + if (!ev.candidate) return; + postSignal(basePath, sessionId, role, 'candidate', { + candidate: ev.candidate.candidate, + sdpMid: ev.candidate.sdpMid, + sdpMLineIndex: ev.candidate.sdpMLineIndex + }, function () {}); + }; + + pc.ontrack = function (ev) { + var stream = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([ev.track]); + onRemoteStream(stream); + }; + + pc.oniceconnectionstatechange = function () { + status('ICE: ' + pc.iceConnectionState); + }; + + if (localStream) { + localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); }); + } + + function applyRemoteDescription(payload, cb) { + if (!payload || !payload.type || !payload.sdp) { + cb(false); + return; + } + pc.setRemoteDescription(payload).then(function () { + cb(true); + }).catch(function () { cb(false); }); + } + + function handlePollMessages(messages) { + if (!messages || !messages.length) return; + messages.forEach(function (msg) { + sinceId = Math.max(sinceId, msg.id || 0); + var payload = msg.payload || {}; + if (msg.msg_type === 'offer' && role === 'viewer') { + applyRemoteDescription(payload, function (ok) { + if (!ok) return; + pc.createAnswer().then(function (answer) { + return pc.setLocalDescription(answer); + }).then(function () { + postSignal(basePath, sessionId, role, 'answer', pc.localDescription, function () {}); + status('Answer sent — connecting…'); + }).catch(function () { + status('Could not create WebRTC answer.'); + }); + }); + } else if (msg.msg_type === 'answer' && role === 'caster') { + applyRemoteDescription(payload, function (ok) { + if (ok) status('Viewer connected — streaming.'); + }); + } else if (msg.msg_type === 'candidate' && payload.candidate) { + pc.addIceCandidate({ + candidate: payload.candidate, + sdpMid: payload.sdpMid, + sdpMLineIndex: payload.sdpMLineIndex + }).catch(function () {}); + } + }); + } + + pollTimer = setInterval(function () { + if (stopped) return; + pollSignals(basePath, sessionId, sinceId, remoteRole, function (resp) { + if (!resp || !resp.ok) return; + handlePollMessages(resp.messages || []); + if (typeof resp.since_id === 'number') { + sinceId = Math.max(sinceId, resp.since_id); + } + }); + }, 800); + + hbTimer = setInterval(function () { + if (stopped) return; + enrichStatsFromRtc(pc, collectStats(pc, remoteVideo), function (stats) { + onStats(stats); + sendHeartbeat(basePath, sessionId, 'active', stats); + }); + }, 5000); + + if (role === 'caster') { + status('Publishing offer…'); + pc.createOffer().then(function (offer) { + return pc.setLocalDescription(offer); + }).then(function () { + postSignal(basePath, sessionId, role, 'offer', pc.localDescription, function (resp) { + if (resp && resp.ok) { + status('Waiting for viewer…'); + } else { + status('Signaling failed.'); + } + }); + }).catch(function () { + status('Could not create WebRTC offer.'); + }); + } else { + status('Waiting for caster offer…'); + } + + return { + setRemoteVideo: function (el) { remoteVideo = el; }, + stop: function () { + sendHeartbeat(basePath, sessionId, 'ended', null); + teardown(); + } + }; + } + + global.LiveCastWebRtc = { + connect: connect, + sendHeartbeat: sendHeartbeat, + collectStats: collectStats + }; +})(typeof window !== 'undefined' ? window : this); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/live_education.js b/sim/cluster0/lab-seeds/backend/public/assets/js/live_education.js new file mode 100644 index 0000000..ddd51c0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/live_education.js @@ -0,0 +1,210 @@ +(function () { + 'use strict'; + + var MAX_DEMO_S = 300; + var timer = null; + var secondsLeft = MAX_DEMO_S; + var mediaStream = null; + var sessionId = ''; + var webrtc = null; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function detectPlatform() { + var ua = navigator.userAgent || ''; + if (/Firefox\//i.test(ua)) return 'firefox'; + if (/Edg\//i.test(ua)) return 'edge'; + if (/Chrome\//i.test(ua)) return 'chrome'; + if (/Safari\//i.test(ua)) return 'safari'; + return 'desktop'; + } + + function apiUrl() { + return basePath() + '/api/live_cast.php'; + } + + function postJson(body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl(), true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(JSON.stringify(body)); + } + + function setStatus(text) { + var node = el('edu-status'); + if (node) node.textContent = text; + } + + function updateTimer() { + var node = el('edu-timer'); + if (!node) return; + var m = Math.floor(secondsLeft / 60); + var s = secondsLeft % 60; + node.textContent = m + ':' + (s < 10 ? '0' : '') + s; + } + + function renderShare(session) { + var box = el('edu-share'); + if (!box || !session) return; + var joinUrl = session.join_short_url || ''; + var qrUrl = session.join_qr_url || ''; + var longJoin = basePath() + '/live/join?session_id=' + encodeURIComponent(session.session_id || sessionId); + if (!joinUrl) joinUrl = longJoin; + var html = '

    Share this demo

    Viewers open this link in another browser (same LAN or online).

    '; + html += '

    ' + joinUrl + '

    '; + if (qrUrl) { + html += '
    Viewer QR code
    Scan to open viewer page
    '; + } + box.hidden = false; + box.innerHTML = html; + } + + function stopDemo() { + if (timer) { + clearInterval(timer); + timer = null; + } + if (webrtc) { + webrtc.stop(); + webrtc = null; + } + if (mediaStream) { + mediaStream.getTracks().forEach(function (t) { t.stop(); }); + mediaStream = null; + } + var preview = el('edu-preview'); + if (preview) preview.srcObject = null; + if (sessionId) { + postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {}); + sessionId = ''; + } + var share = el('edu-share'); + if (share) share.hidden = true; + el('edu-start').disabled = false; + el('edu-stop').disabled = true; + setStatus('Demo ended. Thanks for trying AndroidCast education mode.'); + } + + function startWebRtc() { + if (!window.LiveCastWebRtc || !sessionId || !mediaStream) return; + webrtc = window.LiveCastWebRtc.connect({ + basePath: basePath(), + sessionId: sessionId, + role: 'caster', + localStream: mediaStream, + onStatus: setStatus, + onStats: function (stats) { + var nerds = el('edu-stats'); + if (!nerds) return; + nerds.hidden = false; + nerds.textContent = 'Stats: ICE ' + (stats.ice_state || '—') + + ' · out ' + (stats.outbound_kbps || 0) + ' kbps' + + ' · RTT ' + (stats.rtt_ms || 0) + ' ms'; + } + }); + } + + function startDemo() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { + setStatus('Screen sharing is not supported in this browser.'); + return; + } + if (!window.RTCPeerConnection) { + setStatus('WebRTC is not available in this browser.'); + return; + } + setStatus('Requesting screen share permission…'); + navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }) + .then(function (stream) { + mediaStream = stream; + var preview = el('edu-preview'); + if (preview) { + preview.hidden = false; + preview.srcObject = stream; + preview.play().catch(function () {}); + } + stream.getVideoTracks()[0].addEventListener('ended', stopDemo); + setStatus('Creating demo session…'); + postJson({ + action: 'create_intent', + platform: 'education_' + detectPlatform(), + max_duration_s: MAX_DEMO_S, + codec_video: 'browser_screen', + codec_audio: 'none', + bw_mode: 'demo' + }, function (resp) { + if (!resp || !resp.ok || !resp.session) { + setStatus('Could not register demo session. Try again later.'); + stopDemo(); + return; + } + sessionId = resp.session.session_id || ''; + renderShare(resp.session); + startWebRtc(); + secondsLeft = MAX_DEMO_S; + updateTimer(); + el('edu-start').disabled = true; + el('edu-stop').disabled = false; + setStatus('Live demo — share the link so others can watch.'); + timer = setInterval(function () { + secondsLeft -= 1; + updateTimer(); + if (secondsLeft <= 0) { + stopDemo(); + } + }, 1000); + if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) { + window.AndroidCastAnalytics.trackEvent('live_education_start', { platform: detectPlatform() }); + } + }); + }) + .catch(function () { + setStatus('Screen share permission denied or cancelled.'); + }); + } + + function boot() { + if (!el('live-education-app')) return; + updateTimer(); + var startBtn = el('edu-start'); + var stopBtn = el('edu-stop'); + if (startBtn) startBtn.addEventListener('click', startDemo); + if (stopBtn) { + stopBtn.disabled = true; + stopBtn.addEventListener('click', stopDemo); + } + var notifBtn = el('edu-notify'); + if (notifBtn && 'Notification' in window) { + notifBtn.addEventListener('click', function () { + Notification.requestPermission().then(function (perm) { + setStatus(perm === 'granted' ? 'Notifications enabled.' : 'Notifications not granted.'); + }); + }); + } else if (notifBtn) { + notifBtn.hidden = true; + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/live_join.js b/sim/cluster0/lab-seeds/backend/public/assets/js/live_join.js new file mode 100644 index 0000000..45d4658 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/live_join.js @@ -0,0 +1,224 @@ +(function () { + 'use strict'; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function detectPlatform() { + var ua = navigator.userAgent || ''; + if (/Android/i.test(ua)) return 'android'; + if (/iPhone|iPad|iPod/i.test(ua)) return 'ios'; + if (/Firefox\//i.test(ua)) return 'firefox'; + if (/Edg\//i.test(ua)) return 'edge'; + if (/Chrome\//i.test(ua)) return 'chrome'; + if (/Safari\//i.test(ua)) return 'safari'; + return 'desktop'; + } + + function anonId() { + var key = 'ac_live_anon_id'; + try { + var existing = localStorage.getItem(key); + if (existing) return existing; + var id = 'anon-' + Math.random().toString(36).slice(2) + Date.now().toString(36); + localStorage.setItem(key, id); + return id; + } catch (e) { + return 'anon-' + Date.now(); + } + } + + function apiUrl() { + return basePath() + '/api/live_cast.php'; + } + + function postJoinEvent(sessionId, eventType, direct) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl(), true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.send(JSON.stringify({ + action: 'join_event', + session_id: sessionId, + event_type: eventType, + platform: detectPlatform(), + channel: direct ? 'direct_link' : 'viewer_link', + direct: !!direct, + anon_id: anonId() + })); + } + + function fetchSession(sessionId, cb) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', apiUrl() + '?action=session&session_id=' + encodeURIComponent(sessionId), true); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload && payload.ok ? payload.session : null); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(); + } + + function formatStatus(session) { + if (!session) return 'Unknown'; + var st = session.status || ''; + if (st === 'live' || st === 'intent') return 'Live'; + if (st === 'closed') return 'Ended'; + if (st === 'expired') return 'Expired'; + return st; + } + + function renderStats(statsEl, stats) { + if (!statsEl || !stats) return; + statsEl.hidden = false; + statsEl.innerHTML = + 'Statistics for nerds
    ' + + 'ICE: ' + escapeHtml(stats.ice_state || '—') + + ' · sig: ' + escapeHtml(stats.signaling_state || '—') + + '
    In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps' + + '
    RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0) + + (stats.frame_w ? '
    Frame: ' + stats.frame_w + '×' + stats.frame_h : ''); + } + + function startViewer(sessionId, session, playerEl, statusEl, statsEl) { + if (!window.RTCPeerConnection || !window.LiveCastWebRtc) { + playerEl.innerHTML = '

    WebRTC is not available in this browser.

    '; + return null; + } + playerEl.innerHTML = + '' + + ''; + var video = el('live-remote-video'); + var nerds = el('live-join-stats'); + var conn = window.LiveCastWebRtc.connect({ + basePath: basePath(), + sessionId: sessionId, + role: 'viewer', + onStatus: function (msg) { + statusEl.textContent = formatStatus(session) + ' — ' + msg; + }, + onRemoteStream: function (stream) { + if (video) { + video.srcObject = stream; + video.play().catch(function () {}); + } + statusEl.textContent = formatStatus(session) + ' — playing stream'; + }, + onStats: function (stats) { + renderStats(nerds || statsEl, stats); + } + }); + if (conn && video) conn.setRemoteVideo(video); + return conn; + } + + function boot() { + var root = el('live-join-app'); + if (!root) return; + + var params = new URLSearchParams(window.location.search); + var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || ''; + var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1'; + var role = params.get('role') || (direct ? 'direct' : 'viewer'); + var webrtcConn = null; + + if (!sessionId) { + el('live-join-status').textContent = 'Missing session_id in URL.'; + return; + } + + postJoinEvent(sessionId, 'open', direct); + + var statusEl = el('live-join-status'); + var metaEl = el('live-join-meta'); + var playerEl = el('live-join-player'); + var statsEl = el('live-join-stats-panel'); + + function render(session) { + if (!session) { + statusEl.textContent = 'Session not found or no longer available.'; + return; + } + var live = session.status === 'live' || session.status === 'intent'; + statusEl.textContent = formatStatus(session) + (live ? ' — connecting…' : ''); + var owner = session.owner_username || 'host'; + var lines = [ + 'Host: ' + owner, + 'Platform: ' + (session.platform || '—'), + 'Video: ' + (session.codec_video || '—'), + 'Audio: ' + (session.codec_audio || '—'), + 'Bandwidth: ' + (session.bw_mode || '—') + ]; + if (session.join_short_url) { + lines.push('Viewer link: ' + session.join_short_url); + } + metaEl.innerHTML = lines.map(function (l) { + return '
  • ' + escapeHtml(l) + '
  • '; + }).join(''); + + if (direct && /android|ios/i.test(detectPlatform())) { + playerEl.innerHTML = + '

    Open in the AndroidCast app for direct P2P on LAN.

    ' + + '

    androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1

    ' + + '

    Mobile WebRTC signaling uses the same REST API when the app transport is wired.

    '; + postJoinEvent(sessionId, 'join', true); + } else if (live) { + webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl); + postJoinEvent(sessionId, 'join', false); + } else { + playerEl.innerHTML = '

    Session is not live.

    '; + } + + if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) { + window.AndroidCastAnalytics.trackEvent('live_join_view', { + session_id: sessionId, + direct: direct ? 1 : 0, + platform: detectPlatform(), + status: session.status || '' + }); + } + } + + fetchSession(sessionId, render); + setInterval(function () { + fetchSession(sessionId, function (session) { + if (!session) return; + if (!(session.status === 'live' || session.status === 'intent')) { + statusEl.textContent = formatStatus(session); + if (webrtcConn) { + webrtcConn.stop(); + webrtcConn = null; + } + } + }); + }, 30000); + + window.addEventListener('beforeunload', function () { + if (webrtcConn) webrtcConn.stop(); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/live_sessions.js b/sim/cluster0/lab-seeds/backend/public/assets/js/live_sessions.js new file mode 100644 index 0000000..8167f4d --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/live_sessions.js @@ -0,0 +1,113 @@ +(function () { + 'use strict'; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function formatMs(ms) { + if (!ms) return '—'; + try { + return new Date(ms).toLocaleString(); + } catch (e) { + return String(ms); + } + } + + function durationS(startMs, endMs) { + if (!startMs) return '—'; + var end = endMs || Date.now(); + var sec = Math.max(0, Math.round((end - startMs) / 1000)); + if (sec < 60) return sec + 's'; + return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's'; + } + + function statusClass(status) { + if (status === 'live' || status === 'intent') return 'tag-pill tag-pill--ok'; + if (status === 'expired') return 'tag-pill tag-pill--warn'; + return 'tag-pill'; + } + + function renderRows(sessions) { + var tbody = el('live-sessions-tbody'); + if (!tbody) return; + if (!sessions || !sessions.length) { + tbody.innerHTML = 'No sessions in this window.'; + return; + } + tbody.innerHTML = sessions.map(function (s) { + var joinUrl = s.join_short_url || ''; + var joinCell = joinUrl + ? 'Open' + : '—'; + return ( + '' + + '' + escapeHtml(s.status) + '' + + '' + escapeHtml(s.owner_username || '—') + '' + + '' + escapeHtml(s.platform || '—') + '' + + '' + escapeHtml(s.codec_video || '—') + '' + + '' + durationS(s.started_at_ms, s.ended_at_ms) + '' + + '' + formatMs(s.last_heartbeat_ms) + '' + + '' + Number(s.join_opens || 0) + '' + + '' + joinCell + '' + + '' + ); + }).join(''); + } + + function load() { + var status = el('live-sessions-status'); + var daysSel = el('live-sessions-days'); + var days = daysSel && daysSel.value ? daysSel.value : '14'; + if (status) status.textContent = 'Loading…'; + var xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/live_cast.php?action=list&days=' + encodeURIComponent(days), true); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + if (status) status.textContent = 'Invalid response'; + return; + } + if (!payload || !payload.ok) { + if (status) status.textContent = (payload && payload.error) || 'Failed'; + return; + } + renderRows(payload.sessions || []); + if (status) { + status.textContent = 'Loaded ' + (payload.sessions || []).length + ' session(s) · ' + days + 'd window'; + } + }; + xhr.onerror = function () { + if (status) status.textContent = 'Network error'; + }; + xhr.send(); + } + + function boot() { + if (!el('live-sessions-app')) return; + var daysSel = el('live-sessions-days'); + if (daysSel) daysSel.addEventListener('change', load); + load(); + setInterval(load, 60000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/nav_shell.js b/sim/cluster0/lab-seeds/backend/public/assets/js/nav_shell.js new file mode 100644 index 0000000..895d39f --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/nav_shell.js @@ -0,0 +1,83 @@ +/** + * Collapsible left nav: click toggle + horizontal drag on handle (open on drag right). + */ +(function () { + 'use strict'; + + var DRAG_THRESHOLD_PX = 20; + + function setOpen(nav, handle, open) { + nav.classList.toggle('open', open); + if (handle) { + handle.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + } + + function bindNavShell(paneId, handleId) { + var nav = document.getElementById(paneId); + var handle = document.getElementById(handleId); + if (!nav || !handle) return; + + setOpen(nav, handle, nav.classList.contains('open')); + + var drag = null; + var suppressClick = false; + + handle.addEventListener('click', function (ev) { + if (suppressClick) { + suppressClick = false; + ev.preventDefault(); + return; + } + setOpen(nav, handle, !nav.classList.contains('open')); + }); + + handle.addEventListener('pointerdown', function (ev) { + if (ev.button !== 0) return; + drag = { x: ev.clientX, moved: false }; + handle.setPointerCapture(ev.pointerId); + handle.classList.add('is-dragging'); + ev.preventDefault(); + }); + + handle.addEventListener('pointermove', function (ev) { + if (!drag || !handle.hasPointerCapture(ev.pointerId)) return; + var dx = ev.clientX - drag.x; + if (Math.abs(dx) < 4) return; + drag.moved = true; + if (dx > DRAG_THRESHOLD_PX) { + setOpen(nav, handle, true); + drag.x = ev.clientX; + } else if (dx < -DRAG_THRESHOLD_PX) { + setOpen(nav, handle, false); + drag.x = ev.clientX; + } + }); + + function endDrag(ev) { + if (!drag) return; + if (drag.moved) suppressClick = true; + drag = null; + handle.classList.remove('is-dragging'); + if (handle.hasPointerCapture(ev.pointerId)) { + handle.releasePointerCapture(ev.pointerId); + } + } + + handle.addEventListener('pointerup', endDrag); + handle.addEventListener('pointercancel', endDrag); + } + + window.initNavShell = bindNavShell; + + function autoInit() { + bindNavShell('nav-pane', 'nav-handle'); + bindNavShell('landing-nav-pane', 'landing-nav-handle'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', autoInit); + } else { + autoInit(); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/rbac_admin.js b/sim/cluster0/lab-seeds/backend/public/assets/js/rbac_admin.js new file mode 100644 index 0000000..d85653a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/rbac_admin.js @@ -0,0 +1,196 @@ +(function () { + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function canEditGlobal() { + return document.body.getAttribute('data-can-rbac-root') === '1'; + } + + function setStatus(msg, isError) { + const el = document.getElementById('rbac-status'); + if (!el) return; + el.textContent = msg; + el.classList.toggle('error', !!isError); + } + + async function fetchJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {})); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) { + throw new Error(data.error || 'HTTP ' + res.status); + } + return data; + } + + function esc(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + function renderPanel(data) { + const tbody = document.getElementById('rbac-users-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + const companies = data.companies || []; + const companyRoles = data.company_roles || []; + const globalRoles = data.global_roles || []; + const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []); + const seenAuth = new Set(); + + (data.users || []).forEach((u) => { + (u.memberships || []).forEach((m) => { + const tr = document.createElement('tr'); + const globalSelect = canEditGlobal() + ? '' + : '' + esc(u.global_role) + ''; + + const roleSelect = + ''; + + const setSelect = + ''; + + let authCell = ''; + if (!seenAuth.has(u.id)) { + seenAuth.add(u.id); + const fails = Number(u.auth_failures || 0); + if (canEditGlobal() && fails > 0) { + authCell = + '' + fails + ' fail ' + + ''; + } else if (fails > 0) { + authCell = '' + fails + ' recent fail'; + } else { + authCell = ''; + } + } + + tr.innerHTML = + '' + esc(u.username) + '' + + '' + globalSelect + '' + + '' + esc(m.slug) + ' ' + esc(m.name) + '' + + '' + roleSelect + '' + + '' + setSelect + '' + + '' + authCell + ''; + tbody.appendChild(tr); + }); + if (!(u.memberships || []).length && canEditGlobal()) { + const tr = document.createElement('tr'); + const fails = Number(u.auth_failures || 0); + let authCell = fails > 0 + ? '' + : ''; + tr.innerHTML = + '' + esc(u.username) + '' + + '' + esc(u.global_role) + '' + + 'No company membership' + + '' + authCell + ''; + tbody.appendChild(tr); + } + }); + + if (!tbody.children.length) { + tbody.innerHTML = 'No users in scope.'; + } + + const hint = document.getElementById('rbac-scope-hint'); + if (hint) { + hint.textContent = + 'Slug admin = company owner/admin (graphs company scope). Root may change global roles. Privilege sets add remote-access grants to members.'; + if (companies.length) { + hint.textContent += ' Companies: ' + companies.map((c) => c.slug).join(', '); + } + } + } + + async function postAction(action, body) { + await fetchJson(basePath() + '/api/rbac.php?action=' + encodeURIComponent(action), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + function bindChanges() { + document.addEventListener('click', async (ev) => { + const btn = ev.target && ev.target.closest ? ev.target.closest('.rbac-clear-auth') : null; + if (!btn || !canEditGlobal()) return; + const userId = Number(btn.getAttribute('data-user-id')); + if (!userId) return; + try { + await postAction('clear_auth_lockouts', { user_id: userId }); + setStatus('Auth lockouts cleared.'); + load(); + } catch (e) { + setStatus(String(e.message || e), true); + } + }); + document.addEventListener('change', async (ev) => { + const t = ev.target; + if (!t || !t.classList) return; + try { + if (t.classList.contains('rbac-global-role') && canEditGlobal()) { + await postAction('set_global_role', { + user_id: Number(t.getAttribute('data-user-id')), + role: t.value, + }); + setStatus('Global role updated.'); + return; + } + if (t.classList.contains('rbac-company-role')) { + await postAction('set_company_role', { + user_id: Number(t.getAttribute('data-user-id')), + company_id: Number(t.getAttribute('data-company-id')), + role: t.value, + }); + setStatus('Company role updated.'); + return; + } + if (t.classList.contains('rbac-privilege-set')) { + await postAction('apply_privilege_set', { + user_id: Number(t.getAttribute('data-user-id')), + company_id: Number(t.getAttribute('data-company-id')), + privilege_set: t.value, + }); + setStatus('Privilege set applied.'); + } + } catch (e) { + setStatus(String(e.message || e), true); + } + }); + } + + async function load() { + setStatus('Loading…'); + try { + const data = await fetchJson(basePath() + '/api/rbac.php?action=panel'); + renderPanel(data); + setStatus('Ready — changes save on selection.'); + } catch (e) { + setStatus(String(e.message || e), true); + } + } + + document.addEventListener('DOMContentLoaded', function () { + bindChanges(); + load(); + }); +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js b/sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js new file mode 100644 index 0000000..b4aa098 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js @@ -0,0 +1,801 @@ +(function () { + const STORAGE = { + colWidths: 'ra_console_col_widths', + colOrder: 'ra_console_col_order', + }; + + const RA_COLUMNS = [ + { key: 'device_name', label: 'Device' }, + { key: 'status', label: 'Status' }, + { key: 'opt_in', label: 'Opt-in' }, + { key: 'android_api', label: 'Android API' }, + { key: 'android_version', label: 'Android version' }, + { key: 'last_seen', label: 'Last seen' }, + { key: 'app_version', label: 'App' }, + ]; + + const DEFAULT_COL_WIDTHS = { + expand: 40, + device_name: 160, + status: 120, + opt_in: 88, + android_api: 96, + android_version: 112, + last_seen: 152, + app_version: 80, + actions: 220, + }; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function canOperate() { + return document.body.getAttribute('data-can-ra-operate') === '1'; + } + + function canAdmin() { + return document.body.getAttribute('data-can-ra-admin') === '1'; + } + + function lsGet(key, fallback) { + try { + const v = localStorage.getItem(key); + return v === null ? fallback : v; + } catch { + return fallback; + } + } + + function lsSet(key, value) { + try { + localStorage.setItem(key, value); + } catch { /* ignore */ } + } + + function apiUrl(action, params) { + const q = new URLSearchParams(params || {}); + q.set('action', action); + return basePath() + '/api/remote_access.php?' + q.toString(); + } + + function setStatus(msg, isError) { + const el = document.getElementById('ra-status'); + if (!el) return; + el.textContent = msg; + el.classList.toggle('error', !!isError); + } + + async function fetchJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {})); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) { + throw new Error(data.error || ('HTTP ' + res.status)); + } + return data; + } + + function esc(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + function formatBytes(n) { + const b = Number(n) || 0; + if (b < 1024) return b + ' B'; + if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KiB'; + if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(2) + ' MiB'; + return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GiB'; + } + + function getColWidths() { + try { + const w = JSON.parse(lsGet(STORAGE.colWidths, '{}')); + return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) }; + } catch { + return { ...DEFAULT_COL_WIDTHS }; + } + } + + function saveColWidths(widths) { + lsSet(STORAGE.colWidths, JSON.stringify(widths)); + } + + function getOrderedColumnKeys() { + const defaults = RA_COLUMNS.map((c) => c.key); + try { + const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]')); + if (Array.isArray(saved) && saved.length) { + const out = saved.filter((k) => defaults.includes(k)); + defaults.forEach((k) => { + if (!out.includes(k)) out.push(k); + }); + return out; + } + } catch { /* ignore */ } + return defaults; + } + + function saveColumnOrder(keys) { + lsSet(STORAGE.colOrder, JSON.stringify(keys)); + } + + function getDisplayColumns() { + return getOrderedColumnKeys() + .map((k) => RA_COLUMNS.find((c) => c.key === k)) + .filter(Boolean); + } + + function columnLabelByKey(key) { + const c = RA_COLUMNS.find((col) => col.key === key); + if (c) return c.label; + if (key === 'actions') return 'Actions'; + return key; + } + + function colspan() { + return getDisplayColumns().length + 2; + } + + function applyColgroup() { + const cg = document.getElementById('ra-devices-colgroup'); + if (!cg) return; + const widths = getColWidths(); + const order = getOrderedColumnKeys(); + let html = + ''; + order.forEach((k) => { + html += + ''; + }); + html += + ''; + cg.innerHTML = html; + } + + function clearColumnDropMarkers(thead) { + thead.querySelectorAll('.th-drop-target').forEach((el) => { + el.classList.remove('th-drop-target'); + }); + thead.querySelectorAll('.th-dragging').forEach((el) => { + el.classList.remove('th-dragging'); + }); + } + + function renderHead() { + const thead = document.getElementById('ra-devices-thead'); + if (!thead) return; + const cols = getDisplayColumns(); + const widths = getColWidths(); + let html = + ''; + cols.forEach((c) => { + const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : ''; + html += + '' + + '' + + '' + + esc(c.label) + + '' + + ''; + }); + html += + 'Actions'; + thead.innerHTML = html; + } + + function bindTableLayout() { + const table = document.getElementById('ra-devices-table'); + const thead = document.getElementById('ra-devices-thead'); + if (!table || !thead || table.dataset.layoutBound === '1') return; + table.dataset.layoutBound = '1'; + + thead.addEventListener('dragstart', (e) => { + if (e.target.closest('.col-resizer')) { + e.preventDefault(); + return; + } + const th = e.target.closest('th.th-draggable[data-col-key]'); + if (!th) return; + const sourceKey = th.getAttribute('data-col-key'); + if (!sourceKey) return; + table.dataset.dragColKey = sourceKey; + th.classList.add('th-dragging'); + const ghost = document.createElement('div'); + ghost.className = 'col-drag-ghost'; + ghost.textContent = columnLabelByKey(sourceKey); + ghost.setAttribute('aria-hidden', 'true'); + document.body.appendChild(ghost); + table._dragGhost = ghost; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', sourceKey); + try { + e.dataTransfer.setDragImage(ghost, 16, 14); + } catch { /* ignore */ } + } + }); + + thead.addEventListener('dragend', () => { + clearColumnDropMarkers(thead); + delete table.dataset.dragColKey; + if (table._dragGhost) { + table._dragGhost.remove(); + table._dragGhost = null; + } + }); + + thead.addEventListener('dragenter', (e) => { + if (!table.dataset.dragColKey) return; + e.preventDefault(); + }); + + thead.addEventListener('dragover', (e) => { + if (!table.dataset.dragColKey) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + const active = thead.querySelector('.th-drop-target'); + if (active && active !== th) active.classList.remove('th-drop-target'); + th.classList.add('th-drop-target'); + }); + + thead.addEventListener('dragleave', (e) => { + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + const rel = e.relatedTarget; + if (rel && th.contains(rel)) return; + th.classList.remove('th-drop-target'); + }); + + thead.addEventListener('drop', (e) => { + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + e.preventDefault(); + e.stopPropagation(); + const sourceKey = + table.dataset.dragColKey || + (e.dataTransfer && e.dataTransfer.getData('text/plain')) || + ''; + const targetKey = th.getAttribute('data-col-key'); + clearColumnDropMarkers(thead); + delete table.dataset.dragColKey; + if (table._dragGhost) { + table._dragGhost.remove(); + table._dragGhost = null; + } + if (!sourceKey || !targetKey || sourceKey === targetKey) return; + const order = getOrderedColumnKeys(); + const from = order.indexOf(sourceKey); + const to = order.indexOf(targetKey); + if (from < 0 || to < 0) return; + order.splice(from, 1); + order.splice(to, 0, sourceKey); + saveColumnOrder(order); + refreshTableChrome(lastDevices, lastExpanded); + }); + + table.addEventListener('mousedown', (e) => { + const handle = e.target.closest('.col-resizer'); + if (!handle) return; + e.preventDefault(); + e.stopPropagation(); + const colKey = handle.getAttribute('data-resize-col'); + const th = handle.closest('th'); + if (!colKey || !th) return; + const widths = getColWidths(); + const startX = e.clientX; + const startW = th.getBoundingClientRect().width; + + const onMove = (ev) => { + const w = Math.max(48, Math.round(startW + (ev.clientX - startX))); + widths[colKey] = w; + th.style.width = w + 'px'; + th.style.minWidth = w + 'px'; + th.style.maxWidth = w + 'px'; + const cg = document.getElementById('ra-devices-colgroup'); + const col = cg && cg.querySelector('col[data-col="' + colKey + '"]'); + if (col) col.style.width = w + 'px'; + else if (colKey === 'actions') { + const actionCol = cg && cg.querySelector('col.col-actions'); + if (actionCol) actionCol.style.width = w + 'px'; + } else if (colKey === 'expand') { + const expandCol = cg && cg.querySelector('col.col-expand'); + if (expandCol) expandCol.style.width = w + 'px'; + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + saveColWidths(widths); + applyColgroup(); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } + + function statusBadge(d) { + const label = d.status_label || ''; + if (label === 'needs_whitelist') { + return 'Needs whitelist'; + } + if (label === 'whitelisted') { + return 'Whitelisted'; + } + if (label === 'stale') { + return 'Stale'; + } + if (label === 'not_opted_in') { + return 'Not opted in'; + } + return 'Polling'; + } + + function deviceTitle(d) { + const name = (d.device_name || d.device_display || '').trim(); + if (name) return name; + const id = String(d.device_id || ''); + if (id.length > 14) return id.slice(0, 8) + '…' + id.slice(-4); + return id; + } + + function formatAbis(abis) { + if (!Array.isArray(abis) || !abis.length) return '—'; + return abis.join(', '); + } + + function formatCell(key, d) { + switch (key) { + case 'device_name': + return ( + '' + + esc(deviceTitle(d)) + + '' + ); + case 'status': + return statusBadge(d); + case 'opt_in': + return esc(d.opt_in_mode || 'none'); + case 'android_api': + return d.sdk_int != null && d.sdk_int !== '' ? esc(String(d.sdk_int)) : '—'; + case 'android_version': + return esc(d.os_release || '—'); + case 'last_seen': + return esc(d.last_seen_at || '—'); + case 'app_version': + return esc(d.app_version || '—'); + default: + return '—'; + } + } + + function detailLines(d) { + const lines = []; + lines.push('Device ID: ' + (d.device_id || '—')); + const name = (d.device_name || d.device_display || '').trim(); + if (name) lines.push('Device name: ' + name); + if (d.manufacturer || d.model) { + lines.push('Hardware: ' + [d.manufacturer, d.model].filter(Boolean).join(' ')); + } + if (d.brand) lines.push('Brand: ' + d.brand); + if (d.product) lines.push('Product: ' + d.product); + if (d.hardware_device) lines.push('Device codename: ' + d.hardware_device); + if (d.sdk_int != null) lines.push('Android API: ' + d.sdk_int); + if (d.os_release) lines.push('Android version: ' + d.os_release); + if (d.abis && d.abis.length) lines.push('ABIs: ' + formatAbis(d.abis)); + if (d.lan_ip) lines.push('Device LAN IP: ' + d.lan_ip); + if (d.device_wan_ip) lines.push('Device WAN IP: ' + d.device_wan_ip); + if (d.poll_source_ip) { + let pollLabel = 'Poll source IP (server view)'; + if (d.poll_source_is_private) { + pollLabel += ' — intra/private hop, not device WAN'; + } + lines.push(pollLabel + ': ' + d.poll_source_ip); + } + if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip); + if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope); + if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope); + if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key); + if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) { + const rx = formatBytes(d.wg_rx_bytes || 0); + const tx = formatBytes(d.wg_tx_bytes || 0); + lines.push('VPN traffic (BE wg): ↓' + rx + ' ↑' + tx); + } + if (d.wg_latest_handshake > 0) { + lines.push('VPN last handshake: ' + new Date(d.wg_latest_handshake * 1000).toISOString()); + } + if (d.wg_endpoint) lines.push('VPN endpoint: ' + d.wg_endpoint); + lines.push('App version: ' + (d.app_version || '—')); + lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none')); + lines.push('Last seen: ' + (d.last_seen_at || '—')); + if (d.notes) lines.push('Notes: ' + d.notes); + if (d.issue_count != null) lines.push('Linked issues: ' + d.issue_count); + if (d.graph_session_count != null) lines.push('Graph sessions: ' + d.graph_session_count); + return lines; + } + + function detailLinks(d) { + const links = d.links || []; + if (!links.length) return ''; + let html = ''; + return html; + } + + function isInteractiveTarget(el) { + return !!el.closest('.ra-device-actions, .ra-detail-links, .report-tree-toggle, button, a, input, select, textarea, label'); + } + + function bindRaTree(root) { + root.querySelectorAll('.report-row[data-device-id]').forEach((row) => { + const btn = row.querySelector('.report-tree-toggle'); + const briefId = btn ? btn.getAttribute('aria-controls') : null; + const briefRow = briefId ? document.getElementById(briefId) : null; + if (!briefRow || !btn) return; + + const toggle = (e) => { + if (e && isInteractiveTarget(e.target)) return; + const open = briefRow.hidden; + briefRow.hidden = !open; + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + row.classList.toggle('report-row--open', open); + }; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggle(e); + }); + row.addEventListener('click', (e) => toggle(e)); + row.addEventListener('keydown', (e) => { + if (isInteractiveTarget(e.target)) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(e); + } + }); + }); + } + + function captureExpandedDeviceIds() { + const ids = new Set(); + const tbody = document.getElementById('ra-devices-tbody'); + if (!tbody) return ids; + tbody.querySelectorAll('.report-row.report-row--open[data-device-id]').forEach((row) => { + const id = row.getAttribute('data-device-id'); + if (id) ids.add(id); + }); + return ids; + } + + let lastDevices = []; + let lastExpanded = new Set(); + + function renderDevices(devices, expandedIds) { + const tbody = document.getElementById('ra-devices-tbody'); + if (!tbody) return; + lastDevices = devices || []; + if (expandedIds) lastExpanded = expandedIds; + + const cols = getDisplayColumns(); + const span = colspan(); + + if (!devices || !devices.length) { + tbody.innerHTML = + 'No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll.'; + return; + } + + let html = ''; + devices.forEach((d, i) => { + const rowKey = 'ra-' + i; + const briefId = 'ra-brief-' + rowKey; + const wl = Number(d.whitelisted) === 1; + const optIn = d.opt_in_mode || 'none'; + const openReady = wl && (optIn === 'wireguard' || optIn === 'rssh'); + const openHint = !canOperate() + ? 'Need remote_access_operate permission' + : !wl + ? 'Whitelist device first' + : openReady + ? 'Open session — device connects on next poll (≤7 min)' + : 'Phone must poll with RSSH/WG enabled (dev settings on device; wait ≤7 min)'; + const openDisabled = canOperate() ? '' : ' disabled'; + const wlDisabled = canAdmin() ? '' : ' disabled'; + const isOpen = lastExpanded.has(String(d.device_id || '')); + const rowClass = + 'report-row' + + (d.needs_whitelist ? ' ra-device-row--needs-wl' : '') + + (isOpen ? ' report-row--open' : ''); + + html += ''; + html += + ''; + + cols.forEach((c) => { + html += '' + formatCell(c.key, d) + ''; + }); + + html += + '' + + ' ' + + ' ' + + '' + + ''; + html += ''; + + html += + '
    '; + detailLines(d).forEach((line) => { + html += '

    ' + esc(line) + '

    '; + }); + html += detailLinks(d); + html += '
    '; + }); + tbody.innerHTML = html; + bindRaTree(tbody); + } + + function refreshTableChrome(devices, expandedIds) { + applyColgroup(); + renderHead(); + renderDevices(devices || lastDevices, expandedIds || lastExpanded); + } + + function renderSessions(active, inactive) { + const aBody = document.getElementById('ra-active-tbody'); + const iBody = document.getElementById('ra-inactive-tbody'); + if (aBody) { + aBody.innerHTML = ''; + if (!active || !active.length) { + aBody.innerHTML = 'No pending or active sessions.'; + } + (active || []).forEach((s) => { + const tr = document.createElement('tr'); + const closeBtn = canOperate() + ? '' + : ''; + let endpoint = s.endpoint || '—'; + if (s.tunnel === 'ssh_reverse' && s.rssh_remote_bind) { + endpoint = endpoint + ' → ' + esc(s.rssh_remote_bind); + } + let operator = ''; + if (s.rssh_operator) { + operator = + '
    ' + + esc(s.rssh_operator.shell || '') + + '
    '; + } + tr.innerHTML = + '' + esc(s.session_id) + '' + + '' + esc(s.device_id) + '' + + '' + esc(s.status) + '' + + '' + esc(s.tunnel) + '' + + '' + endpoint + operator + '' + + '' + closeBtn + ''; + aBody.appendChild(tr); + }); + } + if (iBody) { + iBody.innerHTML = ''; + (inactive || []).forEach((s) => { + const tr = document.createElement('tr'); + tr.innerHTML = + '' + esc(s.session_id) + '' + + '' + esc(s.device_id) + '' + + '' + esc(s.status) + '' + + '' + esc(s.closed_at || '—') + '' + + '' + esc(s.close_reason || '—') + ''; + iBody.appendChild(tr); + }); + if (!inactive || !inactive.length) { + iBody.innerHTML = 'No closed sessions yet.'; + } + } + } + + function renderEvents(events) { + const tbody = document.getElementById('ra-events-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!events || !events.length) { + tbody.innerHTML = 'No audit events.'; + return; + } + (events || []).forEach((e) => { + const tr = document.createElement('tr'); + tr.innerHTML = + '' + esc(e.created_at) + '' + + '' + esc(e.device_id) + '' + + '' + esc(e.action) + '' + + '' + esc(e.reason || '—') + ''; + tbody.appendChild(tr); + }); + } + + let lastConfig = {}; + + async function refresh() { + const expanded = captureExpandedDeviceIds(); + setStatus('Loading…'); + try { + const data = await fetchJson(apiUrl('dashboard')); + lastConfig = data.config || {}; + refreshTableChrome(data.devices, expanded); + renderSessions(data.active_sessions, data.inactive_sessions); + renderEvents(data.recent_events); + const ep = lastConfig.wg_endpoint ? ' · WG ' + lastConfig.wg_endpoint : ''; + setStatus('Updated ' + new Date().toLocaleTimeString() + ep); + } catch (e) { + setStatus('Failed: ' + e.message, true); + } + } + + document.addEventListener('click', async (ev) => { + const t = ev.target; + if (!(t instanceof HTMLElement)) return; + if (t.closest('.report-tree-toggle, .ra-detail-links a')) return; + const copyId = t.getAttribute('data-copy-id'); + if (copyId) { + try { + await navigator.clipboard.writeText(copyId); + setStatus('Copied device ID'); + } catch (e) { + setStatus('Copy failed', true); + } + return; + } + const openId = t.getAttribute('data-open-session'); + if (openId) { + if (!canOperate()) return; + const dev = (lastDevices || []).find((d) => String(d.device_id) === openId); + const wl = dev && Number(dev.whitelisted) === 1; + const optIn = dev ? dev.opt_in_mode || 'none' : 'none'; + if (!wl) { + setStatus('Whitelist device ' + openId + ' first', true); + return; + } + if (optIn !== 'wireguard' && optIn !== 'rssh') { + setStatus( + 'Device opt-in is "' + + optIn + + '". On phone: dev settings → Remote access → RSSH, then wait for poll (≤7 min).', + true + ); + return; + } + try { + setStatus('Opening session for ' + openId + '…'); + await fetchJson(apiUrl('open_session'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_id: openId }), + }); + setStatus('Session opened — device will connect on next poll (≤7 min)'); + await refresh(); + } catch (e) { + setStatus('Open session: ' + e.message, true); + } + return; + } + const closeId = t.getAttribute('data-close-session'); + if (closeId) { + if (!canOperate()) return; + try { + await fetchJson(apiUrl('close_session'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: closeId, + device_id: t.getAttribute('data-device') || '', + }), + }); + await refresh(); + } catch (e) { + setStatus('Close session: ' + e.message, true); + } + return; + } + const wlDevice = t.getAttribute('data-toggle-wl'); + if (wlDevice) { + if (!canAdmin()) return; + try { + await fetchJson(apiUrl('whitelist'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_id: wlDevice, + whitelisted: t.getAttribute('data-wl') === '1', + }), + }); + await refresh(); + } catch (e) { + setStatus('Whitelist: ' + e.message, true); + } + } + }); + + const form = document.getElementById('ra-whitelist-form'); + if (form) { + if (!canAdmin()) form.hidden = true; + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + if (!canAdmin()) return; + const fd = new FormData(form); + try { + await fetchJson(apiUrl('whitelist'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_id: fd.get('device_id'), + notes: fd.get('notes'), + whitelisted: true, + }), + }); + form.reset(); + await refresh(); + } catch (e) { + setStatus('Whitelist add: ' + e.message, true); + } + }); + } + + bindTableLayout(); + applyColgroup(); + renderHead(); + refresh(); + setInterval(refresh, 30000); +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/short_links.js b/sim/cluster0/lab-seeds/backend/public/assets/js/short_links.js new file mode 100644 index 0000000..d025197 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/short_links.js @@ -0,0 +1,448 @@ +(function () { + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function canOperate() { + return document.body.getAttribute('data-can-sl-operate') === '1'; + } + + function canAdmin() { + return document.body.getAttribute('data-can-sl-admin') === '1'; + } + + function apiUrl(action, params) { + const q = new URLSearchParams(params || {}); + q.set('action', action); + return basePath() + '/api/short_links.php?' + q.toString(); + } + + function setStatus(msg, isError) { + const el = document.getElementById('sl-status'); + if (!el) return; + el.textContent = msg; + el.classList.toggle('error', !!isError); + } + + async function fetchJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {})); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) { + const err = data.error || data.reason || ('HTTP ' + res.status); + throw new Error(err); + } + return data; + } + + function esc(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + function truncate(s, n) { + const t = String(s || ''); + return t.length <= n ? t : t.slice(0, n - 1) + '…'; + } + + let selectedBearerId = 0; + let selectedBearerLabel = ''; + + function bearerCaps(b) { + const parts = []; + if (b.can_temporary) parts.push('temp'); + if (b.can_permanent) parts.push('perm'); + return parts.length ? parts.join(', ') : '—'; + } + + function renderBearers(bearers) { + const tbody = document.getElementById('sl-bearers-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!bearers || !bearers.length) { + tbody.innerHTML = 'No bearer tokens yet — mint one below.'; + return; + } + (bearers || []).forEach((b) => { + const tr = document.createElement('tr'); + const active = b.active !== false && !b.revoked_at; + const status = active ? 'active' : 'revoked'; + const revokeBtn = canOperate() && active + ? ' ' + : ''; + const viewBtn = + ''; + tr.innerHTML = + '' + esc(b.id) + '' + + '' + esc(b.label) + '' + + '' + status + '' + + '' + esc(bearerCaps(b)) + '' + + '' + esc(b.link_count) + (b.expired_link_count > 0 ? ' (' + esc(b.expired_link_count) + ' exp)' : '') + '' + + '' + esc(b.rate_limit_per_hour) + '' + + '' + esc(b.created_at || '—') + '' + + '' + viewBtn + revokeBtn + ''; + tbody.appendChild(tr); + }); + } + + function renderSigners(signers) { + const tbody = document.getElementById('sl-signers-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!signers || !signers.length) { + tbody.innerHTML = 'No signer keys — mint one for permanent links.'; + return; + } + (signers || []).forEach((s) => { + const tr = document.createElement('tr'); + const active = s.active !== false && !s.revoked_at; + const status = active ? 'active' : 'revoked'; + const revokeBtn = canOperate() && active + ? ' ' + : ''; + tr.innerHTML = + '' + esc(s.id) + '' + + '' + esc(s.label) + '' + + '' + status + '' + + '' + esc(s.created_at || '—') + '' + + '' + revokeBtn + ''; + tbody.appendChild(tr); + }); + } + + function renderLinks(links) { + const tbody = document.getElementById('sl-links-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!links || !links.length) { + tbody.innerHTML = 'No links for this bearer.'; + return; + } + (links || []).forEach((l) => { + const tr = document.createElement('tr'); + const exp = l.expired + ? 'expired' + : (l.ttl_seconds === 0 || !l.expires_at ? 'permanent' : esc(l.expires_at)); + tr.innerHTML = + '' + esc(l.short_url) + ' ' + + '' + + '' + esc(truncate(l.origin, 64)) + '' + + '' + exp + '' + + '' + esc(l.access_count) + '' + + 'PNG'; + tbody.appendChild(tr); + }); + } + + function renderAudit(rows) { + const tbody = document.getElementById('sl-audit-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!rows || !rows.length) { + tbody.innerHTML = 'No audit events.'; + return; + } + (rows || []).forEach((e) => { + const tr = document.createElement('tr'); + tr.innerHTML = + '' + esc(e.created_at) + '' + + '' + esc(e.event) + '' + + '' + esc(e.bearer_id != null ? e.bearer_id : '—') + '' + + '' + esc(e.slug || '—') + '' + + '' + esc(e.detail || '—') + ''; + tbody.appendChild(tr); + }); + } + + function showSecretModal(modalId, valueId, value) { + const modal = document.getElementById(modalId); + const val = document.getElementById(valueId); + if (!modal || !val) return; + val.textContent = value; + modal.hidden = false; + } + + function hideModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) modal.hidden = true; + } + + function syncPermanentUi() { + const permCb = document.getElementById('sl-permanent-cb'); + const ttlWrap = document.getElementById('sl-ttl-wrap'); + const signerWrap = document.getElementById('sl-signer-wrap'); + const ttlInput = document.getElementById('sl-ttl-input'); + const permanent = permCb && permCb.checked; + if (ttlWrap) ttlWrap.hidden = !!permanent; + if (signerWrap) signerWrap.hidden = !permanent; + if (ttlInput && permanent) ttlInput.value = '0'; + } + + function syncMintPermanentUi() { + const permCb = document.getElementById('sl-can-permanent'); + const wrap = document.getElementById('sl-mint-signer-wrap'); + if (wrap) wrap.hidden = !(permCb && permCb.checked); + } + + async function loadLinks(bearerId, label) { + selectedBearerId = bearerId; + selectedBearerLabel = label || String(bearerId); + const section = document.getElementById('sl-links-section'); + const labelEl = document.getElementById('sl-links-bearer-label'); + const hidden = document.getElementById('sl-shorten-bearer-id'); + if (section) section.hidden = false; + if (labelEl) labelEl.textContent = selectedBearerLabel + ' (#' + bearerId + ')'; + if (hidden) hidden.value = String(bearerId); + setStatus('Loading links…'); + const data = await fetchJson(apiUrl('links', { bearer_id: String(bearerId) })); + renderLinks(data.links); + setStatus('Links for bearer #' + bearerId); + } + + async function refresh() { + setStatus('Loading…'); + try { + const data = await fetchJson(apiUrl('dashboard')); + renderBearers(data.bearers); + renderSigners(data.signers); + renderAudit(data.audit); + if (selectedBearerId > 0) { + await loadLinks(selectedBearerId, selectedBearerLabel); + } + setStatus('Updated ' + new Date().toLocaleTimeString() + ' · ' + (data.public_base || '')); + } catch (e) { + setStatus('Failed: ' + e.message, true); + } + } + + document.addEventListener('click', async (ev) => { + const t = ev.target; + if (!(t instanceof HTMLElement)) return; + + if (t.id === 'sl-token-close' || t.closest('#sl-token-close')) { + hideModal('sl-token-modal'); + return; + } + if (t.id === 'sl-signer-close' || t.closest('#sl-signer-close')) { + hideModal('sl-signer-modal'); + return; + } + if (t.id === 'sl-token-copy') { + const val = document.getElementById('sl-token-value'); + if (val) { + try { + await navigator.clipboard.writeText(val.textContent || ''); + setStatus('Token copied'); + } catch (e) { + setStatus('Copy failed', true); + } + } + return; + } + if (t.id === 'sl-signer-copy') { + const val = document.getElementById('sl-signer-value'); + if (val) { + try { + await navigator.clipboard.writeText(val.textContent || ''); + setStatus('Signer copied'); + } catch (e) { + setStatus('Copy failed', true); + } + } + return; + } + + const copy = t.getAttribute('data-copy'); + if (copy) { + try { + await navigator.clipboard.writeText(copy); + setStatus('Copied short URL'); + } catch (e) { + setStatus('Copy failed', true); + } + return; + } + + const viewId = t.getAttribute('data-view-links'); + if (viewId) { + try { + await loadLinks(parseInt(viewId, 10), t.getAttribute('data-label') || ''); + } catch (e) { + setStatus('Links: ' + e.message, true); + } + return; + } + + const revokeId = t.getAttribute('data-revoke-bearer'); + if (revokeId) { + if (!canOperate()) return; + if (!window.confirm('Revoke bearer #' + revokeId + '?')) return; + try { + await fetchJson(apiUrl('revoke_bearer'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bearer_id: parseInt(revokeId, 10) }), + }); + if (selectedBearerId === parseInt(revokeId, 10)) { + selectedBearerId = 0; + const section = document.getElementById('sl-links-section'); + if (section) section.hidden = true; + } + await refresh(); + } catch (e) { + setStatus('Revoke: ' + e.message, true); + } + return; + } + + const revokeSignerId = t.getAttribute('data-revoke-signer'); + if (revokeSignerId) { + if (!canOperate()) return; + if (!window.confirm('Revoke signer #' + revokeSignerId + '?')) return; + try { + await fetchJson(apiUrl('revoke_signer'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signer_id: parseInt(revokeSignerId, 10) }), + }); + await refresh(); + } catch (e) { + setStatus('Revoke signer: ' + e.message, true); + } + } + }); + + const permCb = document.getElementById('sl-permanent-cb'); + if (permCb) { + permCb.addEventListener('change', syncPermanentUi); + syncPermanentUi(); + } + + const mintPermCb = document.getElementById('sl-can-permanent'); + if (mintPermCb) { + mintPermCb.addEventListener('change', syncMintPermanentUi); + syncMintPermanentUi(); + } + + const mintForm = document.getElementById('sl-mint-form'); + if (mintForm) { + mintForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + if (!canOperate()) return; + const fd = new FormData(mintForm); + try { + setStatus('Minting bearer…'); + const data = await fetchJson(apiUrl('mint_bearer'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: fd.get('label'), + rate_limit_per_hour: parseInt(String(fd.get('rate_limit') || '1000'), 10), + can_temporary: fd.get('can_temporary') === '1', + can_permanent: fd.get('can_permanent') === '1', + mint_signer: fd.get('mint_signer') === '1', + }), + }); + mintForm.reset(); + syncMintPermanentUi(); + showSecretModal('sl-token-modal', 'sl-token-value', data.token); + if (data.signer_key) { + showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key); + } + await refresh(); + } catch (e) { + setStatus('Mint: ' + e.message, true); + } + }); + } + + const mintSignerForm = document.getElementById('sl-mint-signer-form'); + if (mintSignerForm) { + mintSignerForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + if (!canOperate()) return; + const fd = new FormData(mintSignerForm); + try { + const data = await fetchJson(apiUrl('mint_signer'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: fd.get('label') }), + }); + mintSignerForm.reset(); + showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key); + await refresh(); + } catch (e) { + setStatus('Mint signer: ' + e.message, true); + } + }); + } + + const shortenForm = document.getElementById('sl-shorten-form'); + if (shortenForm) { + shortenForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + if (!canOperate()) return; + const fd = new FormData(shortenForm); + const permanent = fd.get('permanent') === '1'; + try { + const payload = { + bearer_id: parseInt(String(fd.get('bearer_id') || '0'), 10), + url: fd.get('url'), + ttl: permanent ? 0 : parseInt(String(fd.get('ttl') || '86400'), 10), + }; + if (permanent) { + payload.signer = fd.get('signer'); + } + const data = await fetchJson(apiUrl('create_link'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (data.link && data.link.short_url) { + setStatus('Short URL: ' + data.link.short_url); + } + shortenForm.querySelector('[name="url"]').value = ''; + await refresh(); + } catch (e) { + setStatus('Shorten: ' + e.message, true); + } + }); + } + + const purgeBtn = document.getElementById('sl-purge-btn'); + if (purgeBtn) { + if (!canAdmin()) purgeBtn.hidden = true; + purgeBtn.addEventListener('click', async () => { + if (!canAdmin()) return; + if (!window.confirm('Delete all expired links and audit rows older than 90 days?')) return; + try { + const data = await fetchJson(apiUrl('purge_expired'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + setStatus('Purged links=' + (data.purged && data.purged.links) + ' audit=' + (data.purged && data.purged.audit)); + await refresh(); + } catch (e) { + setStatus('Purge: ' + e.message, true); + } + }); + } + + ['sl-token-modal', 'sl-signer-modal'].forEach((id) => { + const modal = document.getElementById(id); + if (modal) { + modal.addEventListener('click', (ev) => { + if (ev.target === modal) hideModal(id); + }); + } + }); + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') { + hideModal('sl-token-modal'); + hideModal('sl-signer-modal'); + } + }); + + refresh(); +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/ticket_create.js b/sim/cluster0/lab-seeds/backend/public/assets/js/ticket_create.js new file mode 100644 index 0000000..3a8a792 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/ticket_create.js @@ -0,0 +1,132 @@ +/** + * Create ticket/issue dialog — Issues toolbar uses "issue" type tag; Tickets uses "ticket". + */ +(function () { + const META = { + ticket: { id: 'ticket', label: 'ticket', bg: '#6366f1' }, + issue: { id: 'issue', label: 'issue', bg: '#0d9488' }, + }; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function t(key) { + if (window.CrashI18n && window.CrashI18n.t) { + return window.CrashI18n.t(key); + } + return key; + } + + function openDialog(kind) { + const dialog = document.getElementById('ticket-create-dialog'); + const form = document.getElementById('ticket-create-form'); + const status = document.getElementById('ticket-create-status'); + const heading = document.getElementById('ticket-create-heading'); + if (!dialog || !form) return; + const createKind = kind === 'issue' ? 'issue' : 'ticket'; + form.dataset.createKind = createKind; + if (heading) { + heading.textContent = t(createKind === 'issue' ? 'issues.new' : 'tickets.new'); + } + if (status) status.textContent = ''; + form.reset(); + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } + } + + function init() { + const dialog = document.getElementById('ticket-create-dialog'); + const form = document.getElementById('ticket-create-form'); + const cancelBtn = document.getElementById('ticket-create-cancel'); + const closeBtn = document.getElementById('ticket-create-close'); + const status = document.getElementById('ticket-create-status'); + if (!dialog || !form) return; + + document.querySelectorAll('.js-new-issue-btn').forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + openDialog('issue'); + }); + }); + document.querySelectorAll('.js-new-ticket-btn').forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + openDialog('ticket'); + }); + }); + + function closeDialog() { + dialog.close(); + } + if (cancelBtn) cancelBtn.addEventListener('click', closeDialog); + if (closeBtn) closeBtn.addEventListener('click', closeDialog); + dialog.addEventListener('click', (ev) => { + if (ev.target === dialog) closeDialog(); + }); + + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const titleEl = document.getElementById('ticket-create-title'); + const briefEl = document.getElementById('ticket-create-brief'); + const bodyEl = document.getElementById('ticket-create-body'); + const kind = form.dataset.createKind === 'issue' ? 'issue' : 'ticket'; + const meta = META[kind] || META.ticket; + const payload = { + title: titleEl && titleEl.value ? titleEl.value.trim() : '', + brief: briefEl && briefEl.value ? briefEl.value.trim() : '', + body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '', + tags: [ + meta, + { id: 'open', label: 'open', bg: '#22c55e' }, + ], + }; + if (!payload.title) return; + if (status) status.textContent = 'Creating…'; + const xhr = new XMLHttpRequest(); + xhr.open('POST', basePath() + '/api/ticket_create.php', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = function () { + let data = null; + try { + data = JSON.parse(xhr.responseText); + } catch { + if (status) status.textContent = 'Invalid response'; + return; + } + if (!data || !data.ok) { + if (status) status.textContent = (data && data.error) || 'Create failed'; + return; + } + dialog.close(); + const view = document.body.getAttribute('data-view') || ''; + if (view === 'tickets' && typeof window.__ticketsReload === 'function') { + window.__ticketsReload(); + } else if (data.id) { + window.location.href = basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.id)); + } else { + window.location.href = basePath() + '/?view=tickets'; + } + }; + xhr.onerror = function () { + if (status) status.textContent = 'Network error'; + }; + xhr.send(JSON.stringify(payload)); + }); + } + + function onReady(fn) { + if (window.CrashI18n && window.CrashI18n.ready) { + window.CrashI18n.ready.then(fn); + } else { + fn(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => onReady(init)); + } else { + onReady(init); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/tickets.js b/sim/cluster0/lab-seeds/backend/public/assets/js/tickets.js new file mode 100644 index 0000000..83e7f9a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/tickets.js @@ -0,0 +1,373 @@ +(function () { + function t(key, params) { + return window.CrashI18n ? window.CrashI18n.t(key, params) : key; + } + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function formatTime(ms) { + if (!ms) return '—'; + const d = new Date(Number(ms)); + return d.toISOString().replace('T', ' ').slice(0, 19); + } + + function formatStars(n) { + const score = Math.max(0, Math.min(5, Number(n) || 0)); + let html = + ''; + for (let i = 1; i <= 5; i++) { + html += ''; + } + return html + ''; + } + + function renderTagPill(tag) { + const id = String(tag.id || '').trim(); + const label = escapeHtml(tag.label || tag.id); + const bg = escapeHtml(tag.bg || '#5c6b82'); + if (!id) { + return '' + label + ''; + } + return ( + '' + ); + } + + function canTagEdit() { + return document.body.getAttribute('data-can-tag-edit') === '1'; + } + + function initTicketsApp() { + const app = document.getElementById('tickets-app'); + const tbody = document.getElementById('tickets-tbody'); + const statusEl = document.getElementById('tickets-status'); + const pagination = document.getElementById('tickets-pagination'); + const perPageSel = document.getElementById('tickets-per-page'); + const tagFilter = document.getElementById('tickets-tag-filter'); + const thead = document.querySelector('#tickets-table thead'); + if (!app || !tbody) return; + + let page = 1; + let perPage = 50; + let sort = 'opened_at_ms'; + let dir = 'desc'; + let tag = ''; + + function load() { + statusEl.textContent = t('reports.loading'); + const q = + '?page=' + + page + + '&per_page=' + + perPage + + '&sort=' + + encodeURIComponent(sort) + + '&dir=' + + encodeURIComponent(dir) + + (tag ? '&tag=' + encodeURIComponent(tag) : ''); + const xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/tickets.php' + q, true); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + statusEl.textContent = t('reports.invalid_response'); + return; + } + if (!data.ok) { + statusEl.textContent = data.hint || data.error || t('reports.load_failed'); + return; + } + renderRows(data.items || []); + renderPagination(data.total || 0, data.page || 1); + const from = data.total ? (data.page - 1) * data.per_page + 1 : 0; + const to = Math.min(data.page * data.per_page, data.total); + statusEl.textContent = t('reports.status_showing', { + from, + to, + total: data.total, + }); + }; + xhr.onerror = function () { + statusEl.textContent = t('reports.network_error'); + }; + xhr.send(); + } + + window.ticketsReloadRef = load; + + function renderRows(items) { + if (!items.length) { + tbody.innerHTML = + '' + escapeHtml(t('tickets.empty')) + ''; + return; + } + let html = ''; + items.forEach((row, i) => { + const briefId = 'tkt-brief-' + row.id + '-' + i; + const openUrl = basePath() + '/?view=ticket&id=' + row.id; + const issueTitle = escapeHtml(row.title || ''); + const issueBrief = row.brief + ? '
    ' + escapeHtml(row.brief) + '
    ' + : ''; + html += + ''; + html += + ''; + html += + '' + + issueTitle + + '' + + issueBrief + + ''; + html += '' + formatTime(row.opened_at_ms) + ''; + html += + '' + + escapeHtml(row.env_brief || '') + + '
    ' + + escapeHtml(row.device_model || '') + + ''; + html += '' + formatStars(row.rating) + ''; + const tagEditBtn = canTagEdit() + ? '' + : ''; + const tags = (row.tags || []).map(renderTagPill).join(''); + html += + '
    ' + + tags + + tagEditBtn + + '
    '; + html += ''; + html += + '

    ' + + escapeHtml(t('row.open_report')) + + '

      '; + [row.brief, row.env_brief, 'Owner: ' + (row.owner_username || '—')] + .filter(Boolean) + .forEach((line) => { + html += '
    • ' + escapeHtml(String(line)) + '
    • '; + }); + html += '
    '; + }); + tbody.innerHTML = html; + bindRows(); + } + + function applyTagFilter(tagId) { + tag = tagId; + if (tagFilter) { + let found = false; + tagFilter.querySelectorAll('option').forEach((opt) => { + if (opt.value === tagId) found = true; + }); + if (!found) { + const opt = document.createElement('option'); + opt.value = tagId; + opt.textContent = tagId; + tagFilter.appendChild(opt); + } + tagFilter.value = tagId; + } + page = 1; + load(); + } + + function activateTag(tagId, ev) { + if (!tagId) return; + ev.stopPropagation(); + ev.preventDefault(); + const xhr = new XMLHttpRequest(); + xhr.open( + 'GET', + basePath() + + '/api/tickets.php?page=1&per_page=2&tag=' + + encodeURIComponent(tagId), + true + ); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + return; + } + if (!data.ok) return; + const total = Number(data.total || 0); + if (total === 1 && data.items && data.items[0] && data.items[0].id) { + window.location.href = + basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.items[0].id)); + return; + } + applyTagFilter(tagId); + }; + xhr.send(); + } + + function bindRows() { + tbody.querySelectorAll('.report-tree-toggle').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + const controls = btn.getAttribute('aria-controls'); + const row = controls ? document.getElementById(controls) : null; + if (!row) return; + const open = row.hidden; + row.hidden = !open; + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + }); + tbody.querySelectorAll('[data-href]').forEach((el) => { + const href = el.getAttribute('data-href'); + if (!href) return; + const go = () => { + window.location.href = href; + }; + el.addEventListener('click', (e) => { + if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return; + go(); + }); + el.addEventListener('keydown', (e) => { + if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + go(); + } + }); + }); + tbody.querySelectorAll('.tag-edit-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + if (typeof window.openTagModal === 'function') { + window.openTagModal(Number(btn.getAttribute('data-ticket-id')), 'ticket'); + } + }); + }); + tbody.querySelectorAll('.report-tag--filter').forEach((btn) => { + btn.addEventListener('click', (e) => { + activateTag(btn.getAttribute('data-tag-id') || '', e); + }); + }); + } + + function renderPagination(total, currentPage) { + if (!pagination) return; + page = currentPage; + const pages = Math.max(1, Math.ceil(total / perPage)); + const prev = page > 1 ? page - 1 : null; + const next = page < pages ? page + 1 : null; + let html = '
    '; + html += + ''; + html += + 'Page ' + + page + + ' / ' + + pages + + ' · ' + + total + + ' tickets'; + html += + '
    '; + pagination.innerHTML = html; + pagination.querySelectorAll('.page-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const p = Number(btn.getAttribute('data-page')); + if (!p) return; + page = p; + load(); + }); + }); + } + + if (perPageSel) { + perPageSel.addEventListener('change', () => { + perPage = Number(perPageSel.value) || 50; + page = 1; + load(); + }); + } + if (tagFilter) { + tagFilter.addEventListener('change', () => { + tag = tagFilter.value || ''; + page = 1; + load(); + }); + } + if (thead) { + thead.querySelectorAll('.sortable').forEach((th) => { + th.addEventListener('click', () => { + const key = th.getAttribute('data-sort'); + if (!key) return; + if (sort === key) { + dir = dir === 'asc' ? 'desc' : 'asc'; + } else { + sort = key; + dir = 'desc'; + } + thead.querySelectorAll('.sortable').forEach((x) => { + x.classList.toggle('sortable--active', x === th); + }); + page = 1; + load(); + }); + }); + } + + window.__ticketsReload = function () { + page = 1; + load(); + }; + + load(); + } + + function onReady(fn) { + if (window.CrashI18n && window.CrashI18n.ready) { + window.CrashI18n.ready.then(fn); + } else { + fn(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => onReady(initTicketsApp)); + } else { + onReady(initTicketsApp); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/two_factor.js b/sim/cluster0/lab-seeds/backend/public/assets/js/two_factor.js new file mode 100644 index 0000000..3e5ec56 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/two_factor.js @@ -0,0 +1,66 @@ +(function () { + 'use strict'; + + function boot() { + var form = document.querySelector('form.login-card'); + var input = document.getElementById('twofa-code-input'); + if (!form || !input) { + return; + } + input.focus(); + input.select(); + + function digitsOnly(val) { + return String(val || '').replace(/\D/g, '').slice(0, 6); + } + + function maybeSubmit() { + if (input.value.length === 6) { + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(); + } else { + form.submit(); + } + } + } + + input.addEventListener('input', function () { + var next = digitsOnly(input.value); + if (next !== input.value) { + input.value = next; + } + maybeSubmit(); + }); + + input.addEventListener('paste', function (ev) { + ev.preventDefault(); + var text = (ev.clipboardData && ev.clipboardData.getData('text')) || ''; + input.value = digitsOnly(text); + maybeSubmit(); + }); + + document.addEventListener('keydown', function (ev) { + if (!/^[0-9]$/.test(ev.key)) { + return; + } + var ae = document.activeElement; + if (ae === input) { + return; + } + var tag = ae && ae.tagName ? ae.tagName.toLowerCase() : ''; + if (tag === 'input' || tag === 'textarea' || tag === 'select' || (ae && ae.isContentEditable)) { + return; + } + ev.preventDefault(); + input.focus(); + input.value = digitsOnly(input.value + ev.key); + maybeSubmit(); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/index.php b/sim/cluster0/lab-seeds/backend/public/index.php new file mode 100644 index 0000000..2f5cdfa --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/index.php @@ -0,0 +1,404 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 63 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 90d9b287d10572f80696355281ba308437d49936dba1d421037afbabc1dc135e + */ +declare(strict_types=1); +require_once __DIR__ . '/../src/bootstrap.php'; + +$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$base = Auth::basePath(); +$route = resolve_console_route($uri); + +if ($route === '/api/upload.php' || str_ends_with($route, '/api/upload.php')) { + require __DIR__ . '/api/upload.php'; + exit; +} + +if ($route === '/api/diag.php' || str_ends_with($route, '/api/diag.php')) { + require __DIR__ . '/api/diag.php'; + exit; +} + +if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) { + require __DIR__ . '/api/heartbeat.php'; + exit; +} + +if ($route === '/api/reports.php' || str_ends_with($route, '/api/reports.php')) { + require __DIR__ . '/api/reports.php'; + exit; +} + +if ($route === '/api/report_viewed.php' || str_ends_with($route, '/api/report_viewed.php')) { + require __DIR__ . '/api/report_viewed.php'; + exit; +} + +if ($route === '/api/report_tags.php' || str_ends_with($route, '/api/report_tags.php')) { + require __DIR__ . '/api/report_tags.php'; + exit; +} + +if ($route === '/api/ticket_upload.php' || str_ends_with($route, '/api/ticket_upload.php')) { + require __DIR__ . '/api/ticket_upload.php'; + exit; +} + +if ($route === '/api/ticket_create.php' || str_ends_with($route, '/api/ticket_create.php')) { + require __DIR__ . '/api/ticket_create.php'; + exit; +} + +if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) { + require __DIR__ . '/api/tickets.php'; + exit; +} + +if ($route === '/api/ticket_tags.php' || str_ends_with($route, '/api/ticket_tags.php')) { + require __DIR__ . '/api/ticket_tags.php'; + exit; +} + +if ($route === '/api/ticket_update.php' || str_ends_with($route, '/api/ticket_update.php')) { + require __DIR__ . '/api/ticket_update.php'; + exit; +} + +if ($route === '/api/ticket_comments.php' || str_ends_with($route, '/api/ticket_comments.php')) { + require __DIR__ . '/api/ticket_comments.php'; + exit; +} + +if ($route === '/api/users.php' || str_ends_with($route, '/api/users.php')) { + require __DIR__ . '/api/users.php'; + exit; +} + +if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/ticket_attachments.php')) { + require __DIR__ . '/api/ticket_attachments.php'; + exit; +} + +if ($route === '/api/graphs.php' || str_ends_with($route, '/api/graphs.php') || str_ends_with($route, '/graphs/api/graphs.php')) { + require __DIR__ . '/api/graphs.php'; + exit; +} + +if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_upload.php') || str_ends_with($route, '/graphs/api/graph_upload.php')) { + require __DIR__ . '/api/graph_upload.php'; + exit; +} + +if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_access.php')) { + require __DIR__ . '/api/remote_access.php'; + exit; +} + +if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php')) { + require __DIR__ . '/api/live_cast.php'; + exit; +} + +if ($route === '/api/live_cast_signaling.php' || str_ends_with($route, '/api/live_cast_signaling.php')) { + require __DIR__ . '/api/live_cast_signaling.php'; + exit; +} + +if ($route === '/live/join' || str_ends_with($route, '/live/join')) { + require __DIR__ . '/../views/live_join.php'; + exit; +} + +if ($route === '/live/education' || str_ends_with($route, '/live/education')) { + require __DIR__ . '/../views/live_education.php'; + exit; +} + +if ($route === '/api/short_links.php' || str_ends_with($route, '/api/short_links.php')) { + require __DIR__ . '/api/short_links.php'; + exit; +} + +if ($route === '/api/rbac.php' || str_ends_with($route, '/api/rbac.php')) { + require __DIR__ . '/api/rbac.php'; + exit; +} + +if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) { + require __DIR__ . '/api/ticket_attachment.php'; + exit; +} + +if ($route === '/api/hub_deploy_docs.php' || str_ends_with($route, '/api/hub_deploy_docs.php')) { + require __DIR__ . '/api/hub_deploy_docs.php'; + exit; +} + +if ($route === '/api/tag_catalog.php' || str_ends_with($route, '/api/tag_catalog.php')) { + require __DIR__ . '/api/tag_catalog.php'; + exit; +} + +if ($route === '/api/auth_register.php' || str_ends_with($route, '/api/auth_register.php')) { + require __DIR__ . '/api/auth_register.php'; + exit; +} + +if ($route === '/logout') { + Auth::logout(); + header('Location: ' . Auth::authUrl('/login')); + exit; +} + +if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $user = trim($_POST['username'] ?? ''); + $pass = $_POST['password'] ?? ''; + $result = Auth::login($user, $pass); + if ($result === true) { + header('Location: ' . $base . '/'); + exit; + } + if ($result === 'pending_2fa') { + header('Location: ' . Auth::authUrl('/two-factor')); + exit; + } + $loginError = 'Invalid credentials'; + $st = Database::pdo()->prepare('SELECT status FROM users WHERE username = ? LIMIT 1'); + $st->execute([$user]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if (is_array($row) && ($row['status'] ?? '') === 'pending') { + $loginError = 'Verify your email before signing in.'; + } elseif (AuthAttempts::isRateLimited($user)) { + $loginError = 'Too many attempts — try again later or ask an admin to clear lockouts.'; + } + require __DIR__ . '/../views/login.php'; + exit; +} + +if ($route === '/login') { + if (Auth::pending2faUserId() > 0) { + header('Location: ' . Auth::authUrl('/two-factor')); + exit; + } + require __DIR__ . '/../views/login.php'; + exit; +} + +if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $email = trim($_POST['email'] ?? ''); + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + $registerEmail = $email; + $registerUsername = $username; + $out = AuthRegistration::register($email, $password, $username); + if ($out['ok']) { + $registerSuccess = 'Check your email for a verification link.'; + require __DIR__ . '/../views/register.php'; + exit; + } + $registerError = match ($out['error'] ?? '') { + 'invalid_email' => 'Enter a valid email address.', + 'weak_password' => 'Password must be at least 10 characters.', + 'already_registered' => 'An account with this email or username already exists.', + 'rate_limited' => 'Too many attempts — try again later.', + default => 'Registration failed.', + }; + require __DIR__ . '/../views/register.php'; + exit; +} + +if ($route === '/register') { + require __DIR__ . '/../views/register.php'; + exit; +} + +if ($route === '/verify-email') { + $token = trim($_GET['token'] ?? ''); + $result = AuthRegistration::verifyEmailToken($token); + $verifyOk = $result['ok'] ?? false; + if (!$verifyOk) { + $verifyError = match ($result['error'] ?? '') { + 'expired_token' => 'This link has expired.', + 'invalid_token' => 'Invalid verification link.', + default => 'Verification failed.', + }; + } + require __DIR__ . '/../views/verify_email.php'; + exit; +} + +if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') { + if (Auth::pending2faUserId() <= 0) { + header('Location: ' . Auth::authUrl('/login')); + exit; + } + $code = trim($_POST['code'] ?? ''); + if (Auth::completeTotpLogin($code)) { + header('Location: ' . $base . '/'); + exit; + } + $twofaError = 'Invalid code'; + require __DIR__ . '/../views/two_factor_challenge.php'; + exit; +} + +if ($route === '/two-factor') { + if (Auth::pending2faUserId() <= 0) { + header('Location: ' . Auth::authUrl('/login')); + exit; + } + $twofaQr = AuthTwoFactorPage::qrPayload(); + require __DIR__ . '/../views/two_factor_challenge.php'; + exit; +} + +if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') { + Auth::check(); + $user = Auth::user(); + $uid = (int) ($user['id'] ?? 0); + $action = (string) ($_POST['action'] ?? ''); + if ($action === 'start_totp') { + if (AuthFactors::hasTotp($uid)) { + $securityError = 'Remove the current authenticator before enrolling a new one.'; + } else { + $secret = AuthTotp::generateSecret(); + $_SESSION['totp_enroll_secret'] = $secret; + $_SESSION['totp_enroll_exp'] = time() + 900; + } + } elseif ($action === 'confirm_totp') { + if (AuthFactors::hasTotp($uid)) { + unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']); + $securityError = 'Authenticator is already enrolled.'; + } else { + $secret = (string) ($_SESSION['totp_enroll_secret'] ?? ''); + $exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0); + $code = trim($_POST['code'] ?? ''); + if ($secret === '' || $exp < time()) { + unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']); + $securityError = 'Setup expired — start again.'; + } elseif (!AuthTotp::verify($secret, $code, 2)) { + $securityError = 'Invalid code — check device time and try again.'; + } else { + unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']); + AuthFactors::enrollTotp($uid, $secret); + $securitySuccess = 'Authenticator enrolled.'; + } + } + } elseif ($action === 'remove_totp') { + AuthFactors::removeTotp($uid); + $securitySuccess = 'Authenticator removed.'; + } + if (!empty($securityError)) { + $_SESSION['security_flash_error'] = $securityError; + } + if (!empty($securitySuccess)) { + $_SESSION['security_flash_success'] = $securitySuccess; + } + header('Location: ' . Auth::basePath() . '/account-security', true, 303); + exit; +} + +if ($route === '/account-security') { + Auth::check(); + $user = Auth::user(); + $uid = (int) ($user['id'] ?? 0); + $securityError = (string) ($_SESSION['security_flash_error'] ?? ''); + $securitySuccess = (string) ($_SESSION['security_flash_success'] ?? ''); + unset($_SESSION['security_flash_error'], $_SESSION['security_flash_success']); + $totpSecret = ''; + if (!AuthFactors::hasTotp($uid)) { + $exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0); + if ($exp >= time()) { + $totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? ''); + } + } + require __DIR__ . '/../views/account_security.php'; + exit; +} + +if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) { + $_GET['view'] = 'graphs'; + $route = '/'; +} + +Auth::check(); + +$grouped = isset($_GET['group']) && $_GET['group'] === '1'; +$view = $_GET['view'] ?? 'home'; + +if ($view === 'short_links' && !Rbac::can('short_links_view')) { + http_response_code(403); + echo 'Forbidden'; + exit; +} + +if ($view === 'report' && isset($_GET['id'])) { + try { + $report = ReportRepository::getById((int) $_GET['id']); + if (!$report) { + http_response_code(404); + echo 'Not found'; + exit; + } + $uid = (int) (Auth::user()['id'] ?? 0); + if ($uid > 0) { + try { + ReportRepository::markViewed((int) $report['id'], $uid); + } catch (Throwable $e) { + error_log('markViewed: ' . $e->getMessage()); + } + } + $pageTitle = 'Report'; + require __DIR__ . '/../views/layout.php'; + } catch (Throwable $e) { + error_log('report view: ' . $e->getMessage()); + http_response_code(500); + if (cfg('debug')) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'Report view failed: ' . $e->getMessage(); + } else { + echo 'Report view failed'; + } + } + exit; +} + +if ($view === 'ticket' && isset($_GET['id'])) { + try { + Database::requireTicketsTable(); + $ticket = TicketRepository::getById((int) $_GET['id']); + if (!$ticket) { + http_response_code(404); + echo 'Not found'; + exit; + } + $pageTitle = 'Ticket'; + require __DIR__ . '/../views/layout.php'; + } catch (Throwable $e) { + error_log('ticket view: ' . $e->getMessage()); + http_response_code(500); + echo cfg('debug') ? 'Ticket view failed: ' . $e->getMessage() : 'Ticket view failed'; + } + exit; +} + +$pageTitle = match ($view) { + 'home' => 'Home', + 'tickets' => 'Tickets', + 'graphs' => 'Analytics', + 'live_sessions' => 'Live sessions', + 'remote_access' => 'Remote access', + 'short_links' => 'Short links', + 'rbac' => 'Access control', + 'reports', 'report' => 'Issues', + default => 'Console', +}; +require __DIR__ . '/../views/layout.php'; diff --git a/sim/cluster0/lab-seeds/backend/scripts/apply-be-port80-nginx.sh b/sim/cluster0/lab-seeds/backend/scripts/apply-be-port80-nginx.sh new file mode 100755 index 0000000..7154446 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/apply-be-port80-nginx.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Replace BE apps.conf listen-80 block from repo fragment (run on machine with BE mount write access). +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +EXAMPLES="$REPO_ROOT/examples" +FRAG="$EXAMPLES/crash_reporter/backend/nginx.apps-port80.fragment" +APPS_CONF="${1:-$REPO_ROOT/tmp/BE_alpine/etc/nginx/conf.d/apps.conf}" +if [[ ! -f "$FRAG" || ! -f "$APPS_CONF" ]]; then + echo "Usage: $0 [path/to/apps.conf]" >&2 + exit 1 +fi +python3 - "$FRAG" "$APPS_CONF" << 'PY' +import sys +from pathlib import Path +frag, apps_path = map(Path, sys.argv[1:3]) +text = apps_path.read_text() +start = text.index("server {\n\tlisten 80;") +end = text.index("\nserver {\n\tlisten 443") +apps_path.write_text(text[:start] + frag.read_text().strip() + text[end:]) +print(f"Updated listen 80 in {apps_path}") +PY diff --git a/sim/cluster0/lab-seeds/backend/scripts/apply-build-nginx-fix.sh b/sim/cluster0/lab-seeds/backend/scripts/apply-build-nginx-fix.sh new file mode 100755 index 0000000..92886a1 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/apply-build-nginx-fix.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Fix builder nginx on sshfs mounts + optional reload on hosts. +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BE_NGINX="${BE_NGINX:-$REPO_ROOT/tmp/BE_alpine/etc/nginx/conf.d}" +FE_NGINX="${FE_NGINX:-$REPO_ROOT/tmp/FE_gentoo/etc/nginx}" +EXAMPLES="$REPO_ROOT/examples" + +install -D -m 0644 "$EXAMPLES/crash_reporter/backend/nginx.apps-builder.frag" \ + "$BE_NGINX/apps_builder.frag" +install -D -m 0644 "$EXAMPLES/crash_reporter/backend/nginx.fe-apps.conf.fragment" \ + "$FE_NGINX/apps.conf" + +if [[ "${1:-}" == "--port80" ]]; then + "$EXAMPLES/crash_reporter/backend/scripts/apply-be-port80-nginx.sh" \ + "$BE_NGINX/apps.conf" +fi + +echo "Installed:" +echo " $BE_NGINX/apps_builder.frag" +echo " $FE_NGINX/apps.conf" +echo +echo "Reload on hosts:" +echo " ssh alpine-be 'sudo nginx -t && sudo rc-service nginx reload'" +echo " # Gentoo FE: sudo nginx -t && sudo rc-service nginx reload" +echo +echo "Verify:" +echo " curl -sSI 'https://apps.f0xx.org/app/androidcast_project/build/' | grep -E 'HTTP|location|x-powered-by'" diff --git a/sim/cluster0/lab-seeds/backend/scripts/bulk_import_graph_sessions.php b/sim/cluster0/lab-seeds/backend/scripts/bulk_import_graph_sessions.php new file mode 100644 index 0000000..c8f3400 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/bulk_import_graph_sessions.php @@ -0,0 +1,42 @@ +#!/usr/bin/env php + $line) { + if ($limit > 0 && $i >= $limit) { + break; + } + $payload = json_decode($line, true); + if (!is_array($payload)) { + $fail++; + continue; + } + try { + GraphRepository::insertSession($payload); + $ok++; + } catch (Throwable $e) { + $fail++; + fwrite(STDERR, 'FAIL line ' . ($i + 1) . ': ' . $e->getMessage() . "\n"); + } +} + +echo "Imported ok=$ok fail=$fail total=" . count($lines) . "\n"; +exit($fail > 0 ? 1 : 0); diff --git a/sim/cluster0/lab-seeds/backend/scripts/deploy-to-be-mount.sh b/sim/cluster0/lab-seeds/backend/scripts/deploy-to-be-mount.sh new file mode 100755 index 0000000..ac3939f --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/deploy-to-be-mount.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Sync backend code to BE sshfs mount (skip data/ — permissions). +# Usage: ./scripts/deploy-to-be-mount.sh [DEST] +set -euo pipefail +SRC="$(cd "$(dirname "$0")/.." && pwd)" +REPO_EXAMPLES="$(cd "$SRC/../.." && pwd)" +BE_ROOT="${BE_ROOT:-$(cd "$REPO_EXAMPLES/../../tmp/BE_alpine/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast" 2>/dev/null && pwd || true)}" +DEST="${1:-$(cd "$SRC/../../../tmp/BE_alpine/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend" 2>/dev/null && pwd || true)}" +if [[ -z "$DEST" || ! -d "$DEST" ]]; then + echo "DEST not found. Pass BE mount path as first argument." >&2 + exit 1 +fi +rsync -a --no-group --no-owner \ + --exclude 'data/' --exclude 'config/config.php' \ + "$SRC/" "$DEST/" +if [[ -n "$BE_ROOT" && -d "$BE_ROOT" ]]; then + rsync -a --no-group --no-owner \ + "$REPO_EXAMPLES/platform/" "$BE_ROOT/examples/platform/" + echo "Synced platform/ to $BE_ROOT/examples/platform/" +fi +echo "Synced to $DEST (config.php and data/ untouched)." diff --git a/sim/cluster0/lab-seeds/backend/scripts/diagnose-build-proxy.sh b/sim/cluster0/lab-seeds/backend/scripts/diagnose-build-proxy.sh new file mode 100755 index 0000000..c403371 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/diagnose-build-proxy.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Run on Gentoo FE and Alpine BE to diagnose build proxy (paste output when asking for help). +set -u +echo "=== $(hostname) build proxy diagnostic ===" +echo + +if [[ -f /etc/nginx/nginx.conf ]]; then + echo "--- FE: androidcast locations in nginx.conf ---" + grep -n 'androidcast_project' /etc/nginx/nginx.conf | head -20 + echo +fi +if [[ -f /etc/nginx/apps.conf ]]; then + echo "--- FE: apps.conf ---" + cat /etc/nginx/apps.conf + echo +fi +if [[ -f /etc/nginx/conf.d/apps_builder.frag ]]; then + echo "--- BE: apps_builder.frag ---" + cat /etc/nginx/conf.d/apps_builder.frag + echo +fi +if [[ -f /etc/nginx/conf.d/apps.conf ]]; then + echo "--- BE: apps.conf listen 80 (first 25 lines) ---" + sed -n '1,25p' /etc/nginx/conf.d/apps.conf + echo +fi + +echo "--- DNS artc0 ---" +getent hosts artc0.intra.raptor.org 2>/dev/null || true + +echo "--- Local curls ---" +for url in \ + "http://127.0.0.1/app/androidcast_project/build/" \ + "http://artc0.intra.raptor.org/app/androidcast_project/build/" \ + "https://artc0.intra.raptor.org/app/androidcast_project/build/"; do + code=$(curl -sS -o /dev/null -w "%{http_code}" -H "Host: apps.f0xx.org" -k "$url" 2>/dev/null || echo "fail") + echo "$code $url" +done + +echo "--- PHP index exists? ---" +ls -la /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/index.php 2>&1 || true diff --git a/sim/cluster0/lab-seeds/backend/scripts/ensure_url_shortener_prod_config.php b/sim/cluster0/lab-seeds/backend/scripts/ensure_url_shortener_prod_config.php new file mode 100755 index 0000000..365bd52 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ensure_url_shortener_prod_config.php @@ -0,0 +1,104 @@ +#!/usr/bin/env php81 + $crash */ +$crash = require $crashConfigPath; +$m = $crash['db']['mysql'] ?? null; +if (!is_array($m) || ($m['password'] ?? '') === '') { + fail('crashes db.mysql.password not set in config.php'); +} + +$user = (string) ($m['username'] ?? 'androidcast'); +$pass = (string) $m['password']; +$socket = (string) ($m['socket'] ?? '/run/mysqld/mysqld.sock'); + +try { + $pdo = new PDO( + "mysql:unix_socket={$socket};dbname=url_shortener;charset=utf8mb4", + $user, + $pass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + $pdo->query('SELECT 1'); +} catch (Throwable $e) { + fail('url_shortener DB ping failed: ' . $e->getMessage()); +} + +$urlBlock = [ + 'db' => [ + 'driver' => 'mysql', + 'mysql' => [ + 'host' => '127.0.0.1', + 'port' => 3306, + 'socket' => $socket, + 'database' => 'url_shortener', + 'username' => $user, + 'password' => $pass, + 'charset' => 'utf8mb4', + ], + ], + 'public_base' => 'https://s.f0xx.org', +]; + +if (!is_dir($urlConfigDir)) { + fail("url-shortener config dir missing: $urlConfigDir"); +} + +file_put_contents($urlConfigPath, " true, + 'public_base' => 'https://s.f0xx.org', + 'db' => [ + 'mysql' => [ + 'host' => '127.0.0.1', + 'port' => 3306, + 'socket' => $socket, + 'database' => 'url_shortener', + 'username' => $user, + 'password' => $pass, + 'charset' => 'utf8mb4', + ], + ], +]; + +$crash['url_shortener'] = $crashUrl; +file_put_contents($crashConfigPath, "&2 + exit 1 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +cp "$FE_MIRROR/nginx.conf" "$WORK/nginx.conf.orig" +cp "$FE_MIRROR/apps.conf" "$WORK/apps.conf.orig" +cp "$TEMPLATE" "$WORK/apps.conf.new" + +python3 - "$WORK/nginx.conf.orig" "$WORK/nginx.conf.new" << 'PY' +import sys +from pathlib import Path + +src, dst = map(Path, sys.argv[1:3]) +lines = src.read_text().splitlines(keepends=True) + +start = None +for i, line in enumerate(lines): + if line.strip() == "## -------------------------------------------------------------------------------": + # Next non-empty should be server { listen 80 apps.f0xx.org + for j in range(i + 1, min(i + 5, len(lines))): + if "server_name apps.f0xx.org" in lines[j] or ( + j + 1 < len(lines) and "server_name apps.f0xx.org" in lines[j + 1] + ): + start = i + break + if start is not None: + break + +if start is None: + raise SystemExit("Could not find apps.f0xx.org section start in nginx.conf") + +# End at closing brace of 443 server: line that is only `\t}` or ` }` before blank line and http `}` +end = None +for i in range(len(lines) - 1, start, -1): + if lines[i].strip() == "}": + # http block ends with `}\n` then EOF; 443 server closes with `}\n` then `\n` then `}` + if i + 1 < len(lines) and lines[i + 1].strip() == "": + if i + 2 < len(lines) and lines[i + 2].strip() == "}": + end = i + break + +if end is None: + raise SystemExit("Could not find apps.f0xx.org section end in nginx.conf") + +replacement = ( + "## -------------------------------------------------------------------------------\n" + "## apps.f0xx.org — Android Cast (hub, crashes, graphs, build, OTA, git)\n" + "\tinclude /etc/nginx/apps.conf;\n" + "\n" +) +new_lines = lines[:start] + [replacement] + lines[end + 1 :] +dst.write_text("".join(new_lines)) +print(f"nginx.conf: replaced lines {start + 1}–{end + 1} with include") +PY + +mkdir -p "$(dirname "$PATCH_OUT")" +( + cd "$WORK" + diff -u apps.conf.orig apps.conf.new || true + diff -u nginx.conf.orig nginx.conf.new || true +) > "$PATCH_OUT" || true + +# Fix patch headers to use etc/nginx/ paths for clarity +sed -i 's|--- .*apps.conf.orig|--- a/etc/nginx/apps.conf|' "$PATCH_OUT" +sed -i 's|+++ .*apps.conf.new|+++ b/etc/nginx/apps.conf|' "$PATCH_OUT" +sed -i 's|--- .*nginx.conf.orig|--- a/etc/nginx/nginx.conf|' "$PATCH_OUT" +sed -i 's|+++ .*nginx.conf.new|+++ b/etc/nginx/nginx.conf|' "$PATCH_OUT" + +cat > "$REPO_ROOT/examples/crash_reporter/backend/patches/fe-nginx-APPLY.md" << EOF +# FE nginx consolidation — review & apply + +## What changes + +- **\`/etc/nginx/apps.conf\`** becomes the full \`apps.f0xx.org\` vhost (HTTP redirect + HTTPS server + all Android Cast locations). +- **\`/etc/nginx/nginx.conf\`** drops inline \`apps.f0xx.org\` server blocks; adds one \`include /etc/nginx/apps.conf;\` at \`http\` level. + +## New / consolidated paths in apps.conf + +| Path | Upstream | +|------|----------| +| \`/v0/ota/\` | BE :80 (OTA) | +| \`/app/androidcast_project/build/\` | BE :80 | +| \`/app/androidcast_project/crashes/\` | BE :80 | +| \`/app/androidcast_project/graphs/\` | BE :80 (explicit) | +| \`/app/androidcast_project/\` | BE :80 (hub) | +| \`/app/androidcast_project/git/\` | BE :3000 (Gitea) | +| \`/\` (default) | BE :80 | + +Canonical template: \`examples/crash_reporter/backend/nginx/fe/apps.conf\` + +## Apply (on f0xx-monstro) + +\`\`\`bash +cd /etc/nginx +cp -a apps.conf "apps.conf.bak.\$(date +%Y%m%d)" +cp -a nginx.conf "nginx.conf.bak.\$(date +%Y%m%d)" +# copy template from repo sync, or patch: +# patch -p1 < .../fe-nginx-apps-consolidation.patch +cp /path/to/android_cast/examples/crash_reporter/backend/nginx/fe/apps.conf apps.conf +# edit nginx.conf: replace apps.f0xx.org server {…} blocks with: +# include /etc/nginx/apps.conf; +nginx -t && rc-service nginx reload +\`\`\` + +## Verify + +\`\`\`bash +curl -sS -o /dev/null -w 'hub %{http_code}\\n' https://apps.f0xx.org/app/androidcast_project/ +curl -sS -o /dev/null -w 'ota %{http_code}\\n' https://apps.f0xx.org/v0/ota/channel/stable.json +curl -sS -o /dev/null -w 'build %{http_code}\\n' https://apps.f0xx.org/app/androidcast_project/build/ +\`\`\` +EOF + +echo "Wrote $PATCH_OUT" +echo "Wrote $REPO_ROOT/examples/crash_reporter/backend/patches/fe-nginx-APPLY.md" diff --git a/sim/cluster0/lab-seeds/backend/scripts/generate_fake_graph_sessions.php b/sim/cluster0/lab-seeds/backend/scripts/generate_fake_graph_sessions.php new file mode 100644 index 0000000..586a5cb --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/generate_fake_graph_sessions.php @@ -0,0 +1,67 @@ +#!/usr/bin/env php + 0 ? $devices * $perDevice : 180; +} +$days = max(1, (int) ($opts['days'] ?? 30)); +$out = $opts['out'] ?? '/tmp/fake_graph_sessions.ndjson'; +$companyId = (int) ($opts['company'] ?? 1); +$userId = (int) ($opts['user'] ?? 1); +if ($devices <= 0) { + $devices = max(1, min(40, (int) ceil($count / max(1, $perDevice)))); +} + +$nowMs = (int) round(microtime(true) * 1000); +$startFloorMs = $nowMs - ($days * 86400 * 1000); +$transports = ['wifi', 'cellular', 'ethernet']; +$installs = ['direct', 'play_store', 'mdm', 'partner']; +$ntp = ['device', 'backend', 'pool.ntp.org']; + +$fh = fopen($out, 'wb'); +if (!$fh) { + fwrite(STDERR, "Cannot write $out\n"); + exit(1); +} + +for ($i = 0; $i < $count; $i++) { + $start = random_int($startFloorMs, $nowMs - 120000); + $duration = random_int(120, 9000); + $end = $start + ($duration * 1000); + $payload = [ + 'schema_version' => 1, + 'ingest_kind' => 'graph_session', + 'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12), + 'company_id' => $companyId, + 'user_id' => $userId, + 'device_id' => 'grab-stats-dev-' . str_pad((string) ($i % $devices), 3, '0', STR_PAD_LEFT), + 'app_version' => '0.1.' . ($i % 5), + 'sdk_int' => 29 + ($i % 6), + 'started_at_epoch_ms' => $start, + 'ended_at_epoch_ms' => $end, + 'duration_s' => $duration, + 'completed' => ($i % 17 !== 0), + 'direction' => ($i % 3 === 0) ? 'receiver' : 'sender', + 'transport' => $transports[array_rand($transports)], + 'avg_kbps' => random_int(640, 12000), + 'recv_kbps' => random_int(200, 4000), + 'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2), + 'ntp_source' => $ntp[array_rand($ntp)], + 'ntp_correction_s' => random_int(0, 8), + 'install_source' => $installs[array_rand($installs)], + 'meta' => [ + 'fake' => true, + 'seed' => $i, + 'grab_session_stats' => true, + 'simulated' => 'bulk-100-devices', + ], + ]; + fwrite($fh, json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n"); +} +fclose($fh); +echo "Generated $count rows ($devices devices, ~" . (int) ceil($count / $devices) . " sessions/device, {$days}d window) into $out\n"; diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/README.md b/sim/cluster0/lab-seeds/backend/scripts/gitea/README.md new file mode 100644 index 0000000..2c58e91 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/README.md @@ -0,0 +1,526 @@ +# Gitea backup and remote attach (Alpine BE) + +Scripts for the BE Gitea instance at `https://apps.f0xx.org/app/androidcast_project/git/`. + +**Safety:** The main working tree under +`/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast` +is **never** modified. Only Gitea-owned paths from `/etc/gitea/app.ini` are backed up or reset. + +## Temp files (/tmp) + +Gitea scripts use `mktemp` work dirs for `git clone --mirror` fallbacks. Each `*.mirror.git` tree is **removed after every repo** (success or failure) and the work dir is removed on script exit. + +Stale dirs (e.g. after `kill -9`) are pruned automatically on the next run (default: older than **60 minutes**). Manual cleanup: + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_cleanup_tmp.sh +# immediate wipe of all gitea-* temps: +GITEA_TMP_MAX_AGE_MIN=0 sudo ./gitea_cleanup_tmp.sh +``` + +## Prerequisites (Alpine BE) + +- Gitea installed (`apk add gitea`), config at `/etc/gitea/app.ini` +- Run backup as **root** (service control + mysqldump) +- For attach: `git`, `curl`; **`GITEA_ADMIN_TOKEN`** (admin PAT from Gitea UI) + +## 1. Full backup + optional reset + +```bash +cd /path/to/android_cast/examples/crash_reporter/backend/scripts/gitea +chmod +x gitea_collect_backup.sh gitea_attach_remote.sh gitea_ini.sh + +# default output: /var/backups/gitea/gitea-collect-YYYYMMDD-HHMMSS/ +./gitea_collect_backup.sh +``` + +Collects: + +| Item | Source (from `app.ini`) | +|------|-------------------------| +| Config | `/etc/gitea/app.ini` | +| App data | `[server] APP_DATA_PATH` (caches, indexes, attachments) | +| Bare repos | `[repository] ROOT` | +| LFS | `[lfs] PATH` (if set) | +| Logs | `[log] ROOT_PATH` | +| DB | `mysqldump` / `pg_dump` / sqlite file copy | +| Built-in dump | `gitea dump` when available | + +At the end: + +```text +Would you like to reset Gitea repositories state to the default (empty)? [y/N] +``` + +- **`N`** (default): backup only +- **`y`**: stops Gitea, `gitea admin repo-delete` per repo, clears **only** `[repository] ROOT` (must be under `/var/lib/gitea/`), restarts Gitea + +## 2. Attach canonical remote as pull mirror + +After backup (and optional reset), point Gitea at the real remote: + +```bash +./gitea_attach_remote.sh git://10.7.0.10/android_cast +# or: GITEA_MIRROR_URL=git://f0xx.org/android_cast ./gitea_attach_remote.sh +``` + +### Do I need `GITEA_ADMIN_TOKEN`? + +**Usually no** when run **as root on the BE**. The script tries `gitea admin user create-repo` and `gitea admin user generate-access-token` before push. + +**Optional** if CLI fails — create in Gitea UI: **Avatar → Settings → Applications → Generate New Token** (scopes: All), then: + +```bash +export GITEA_ADMIN_TOKEN='gitea_…' +./gitea_attach_remote.sh git://10.7.0.10/android_cast +``` + +**Why auth?** Gitea rejects anonymous `git push`. The token (or CLI-generated token) proves write access to `foxx/android_cast`. + +**Prerequisite:** a **Gitea user** must exist (you have `admin`). Default owner is **`admin`** (override: `GITEA_OWNER=...`). + +**Run the script as root** (so it can `su` to `RUN_USER` from `app.ini`, usually `gitea`): + +```bash +sudo ./gitea_attach_remote.sh git://10.7.0.10/android_cast +``` + +Or run directly as the Gitea service user: + +```bash +sudo -u gitea ./gitea_attach_remote.sh git://10.7.0.10/android_cast +``` + +Running `gitea admin ...` as `foxx` fails with `Expect user 'gitea' but current user is: foxx` — that is normal. + +List users (on BE): + +```bash +sudo -u gitea gitea -c /etc/gitea/app.ini admin user list +``` + +**`remote: Not found`** = repo did not exist yet and nothing created it. Re-sync the updated script and re-run. + +Local push URL is `http://127.0.0.1:3000/foxx/android_cast.git` (no `/app/.../git/` — nginx strips subpath on :3000). + +Override: + +```bash +GITEA_MIRROR_URL='git://10.7.0.10/android_cast' \ +GITEA_OWNER='foxx' GITEA_REPO='android_cast' \ +./gitea_attach_remote.sh +``` + +**Method (no NFS/SSHFS):** + +1. **Preferred:** Gitea API `POST /api/v1/repos/migrate` with `"mirror": true` — UI updates on Gitea’s mirror interval (`10m` default). +2. **Fallback:** `git clone --mirror` from remote + `git push --mirror` to local Gitea HTTP. + +Ongoing sync: Gitea pull mirror or cron `gitea admin repo-sync-mirrors`. + +## Allow local git mirror (`git://10.7.0.10/...`) + +Gitea blocks private/local hosts by default (SSRF protection). For a LAN git daemon: + +Edit the **live** config (often `/var/lib/gitea/custom/conf/app.ini`, not only `/etc/gitea/app.ini`): + +```ini +[migrations] +ALLOW_LOCALNETWORKS = true +ALLOWED_DOMAINS = *,github.com,*.github.com,chromium.googlesource.com,git.zx2c4.com +BLOCKED_DOMAINS = +``` + +Notes: + +- Section name is **`[migrations]`** (plural) — `[migration]` is ignored. +- `10.7.0.10` is a private IP → **`ALLOW_LOCALNETWORKS = true`** is required. +- On some Gitea versions an empty `ALLOWED_DOMAINS` still blocks UI mirrors; use **`*`** if needed. +- Tighter allowlist (optional): `ALLOWED_DOMAINS = 10.7.0.10` plus `ALLOW_LOCALNETWORKS = true`. + +Restart Gitea, then re-sync the mirror: + +```bash +sudo rc-service gitea restart +``` + +In UI: repo → **Settings → Mirror settings** → set interval (e.g. `10m`) → **Sync now**. + +CLI (as `gitea` user): + +```bash +sudo -u gitea gitea -c /var/lib/gitea/custom/conf/app.ini admin repo-sync-mirrors +``` + +Ensure BE can reach the git daemon: + +```bash +git ls-remote git://10.7.0.10/android_cast HEAD +``` + +Optional faster default interval for all mirrors: + +```ini +[repository] +DEFAULT_MIRROR_INTERVAL = 10m +MIRROR_UPDATE_INTERVAL = 10m +``` + +## AndroidCast organization (team repos) + +Project mirrors live under the **`AndroidCast`** org (not personal `admin/`): + +`https://apps.f0xx.org/app/androidcast_project/git/AndroidCast` + +| Role | Who | Access | +|------|-----|--------| +| **Site admin** | Gitea user `admin` | Instance users/settings + org **Owners** | +| **Org Owners** | `admin`, `foxx` | Full control of all `AndroidCast/*` repos | +| **Future members** | Add to org teams in UI | Read/write per team (create e.g. Developers later) | + +**One-time org setup** (idempotent; org already created in UI is fine): + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_org_setup.sh +``` + +This will: + +- Ensure org **AndroidCast** exists (skip create if you made it in UI) +- Create Gitea user **`foxx`** if missing (`--must-change-password` — set password or SSH key in UI later) +- Add **`admin`** and **`foxx`** to org **Owners** team (org-admin on all repos) +- **Transfer** existing `admin/android_cast`, `admin/libvpx`, … → `AndroidCast/*` when present + +Then migrate mirrors under the org: + +```bash +cd /var/www/.../androidcast_project/android_cast +GITEA_OWNER=AndroidCast sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh . +``` + +Defaults: `GITEA_OWNER=AndroidCast`, `GITEA_ORG_OWNERS="admin foxx"`, `GITEA_TRANSFER_FROM=admin`. + +Clone URLs become: + +`https://apps.f0xx.org/app/androidcast_project/git/AndroidCast/android_cast.git` + +## Full project migration (main + external submodules) + +**`gitea_migrate_project.sh`** — one script for BE: + +1. Pull-mirror **`android_cast`** from FE git (`git://10.7.0.10/android_cast`) with a **short interval** (default `1m`). +2. Read **external** submodules from a checkout (`.gitmodules` + **pinned SHA** at `HEAD` / `--ref`). +3. Create read-only pull mirrors for each upstream (libvpx, opus, speex, …). +4. Verify the **same commit digest** the superproject pins is reachable on each Gitea mirror. + +Canonical `.gitmodules` stays on upstream URLs — no remote edit. + +```bash +cd /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast +chmod +x examples/crash_reporter/backend/scripts/gitea/*.sh + +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh . + +# Re-run submodules only (main mirror already exists): +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh . --skip-main + +# Pin check at a tag/branch: +GITEA_REF=next sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh . +``` + +### Troubleshooting: only `android_cast` visible (Repositories 1) + +`gitea_attach_remote.sh` mirrors **only the main repo**. Submodule repos need a separate step. + +```bash +# 1) See what BE would mirror / what exists in Gitea: +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_diagnose_mirrors.sh . + +# 2) Create submodule mirrors (main already exists): +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh . --skip-main +# or: +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_mirror_submodules.sh .gitmodules +``` + +Expect **4 repos** under **`AndroidCast`**: `android_cast`, `libvpx`, `opus`, `speex`. If migrate summary shows `submodules ok=0` and a WARN about empty discovery, `.gitmodules` is missing on the BE checkout — the updated migrate script falls back to `submodules.defaults.conf`. + +In Gitea UI, use **Explore** or **Site Administration → Repositories** if the profile counter hides mirror repos. + +### Fast sync for local `android_cast` mirror only + +Gitea **default minimum** pull interval is **10m**. To use **1m** for the FE git mirror: + +```ini +[mirror] +MIN_INTERVAL = 1m + +[cron.update_mirrors] +SCHEDULE = @every 1m +RUN_AT_START = true +``` + +Restart Gitea. The migrate script sets `admin/android_cast` mirror interval to `GITEA_MAIN_MIRROR_INTERVAL` (default `1m`). External submodule mirrors default to `24h` (`GITEA_SUBMODULE_MIRROR_INTERVAL`). + +**Near-immediate sync on each FE push** (better than polling): + +| Method | Command | +|--------|---------| +| BE cron every minute | `* * * * * root /path/to/gitea_sync_mirror.sh android_cast` | +| FE `post-receive` hook | Copy `fe-android_cast-post-receive.example` → FE bare repo `hooks/post-receive`; set `GITEA_SYNC_CMD` (ssh to BE or curl `mirror-sync` API) | +| Manual | `sudo ./gitea_sync_mirror.sh android_cast` | + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh android_cast +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh --all +``` + +There is no built-in “push mirror from FE git daemon → Gitea” in stock git; **pull mirror + trigger** (`mirror-sync` API or hook) is the usual integration when git lives on FE and Gitea on BE. + +## Submodule mirrors (libvpx, opus, speex, …) + +Gitea does not “nest” submodules inside one repo — each submodule is its **own pull mirror** (`admin/libvpx`, `admin/opus`, …). That works **without** changing committed `.gitmodules`. + +Prefer **`gitea_migrate_project.sh`** for the full flow; or submodule-only: + +**1. Allow upstream hosts** in `app.ini` (see `[migrations]` block above — includes GitHub, googlesource, zx2c4). + +**2. Run on BE (as root):** + +```bash +cd /path/to/android_cast +chmod +x examples/crash_reporter/backend/scripts/gitea/*.sh + +# From bundled list (libvpx, opus, speex, wireguard-android): +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_mirror_submodules.sh + +# Or parse live .gitmodules from a checkout: +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_mirror_submodules.sh \ + /var/www/.../android_cast/.gitmodules +``` + +**3. Keep canonical `.gitmodules` unchanged** (upstream GitHub / googlesource URLs). After clone, rewrite submodule fetch URLs **locally**: + +```bash +# Per checkout (.git/config only — does not touch .gitmodules): +./examples/crash_reporter/backend/scripts/gitea/gitea_submodule_local_rewrite.sh --apply + +# Or machine-wide (url.insteadOf in ~/.gitconfig): +./examples/crash_reporter/backend/scripts/gitea/gitea_submodule_local_rewrite.sh --apply --global + +git submodule update --init --recursive +``` + +On the BE working tree, run `--apply` once per checkout (e.g. after `git pull` in `androidcast_project/android_cast`). + +**Optional:** commit Gitea URLs in `.gitmodules` on `git://10.7.0.10/android_cast` so every clone defaults to Gitea without the rewrite script. Skip this if you want upstream URLs to stay canonical. + +**4. Add local submodules** — edit `submodules.defaults.conf` or pass a custom list: + +```text +# repo|upstream_url|submodule_path +mylib|git://10.7.0.10/mylib|third-party/mylib +``` + +```bash +GITEA_SUBMODULES_CONF=/root/my-submodules.list sudo ./gitea_mirror_submodules.sh +``` + +**5. Sync all mirrors periodically:** + +```bash +sudo -u gitea gitea -c /var/lib/gitea/custom/conf/app.ini admin repo-sync-mirrors +``` + +## Branch protection (`master`, `next` only) + +Server-side rules per [docs/GIT_FLOW.md](../../../../docs/GIT_FLOW.md) §7. **Two places:** + +| Where | Tool | +|-------|------| +| **Gitea** `AndroidCast/android_cast` | `gitea_protect_branches.sh` | +| **Canonical FE** `git://10.7.0.10/android_cast` | `examples/git/hooks/pre-receive.android_cast.example` | + +**Gitea (BE):** + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_protect_branches.sh +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_protect_branches.sh --list +``` + +- `master` and `next` only (no `*` wildcard — `feature/*` stays free) +- Force-push **disabled for everyone** +- Push/merge allowlist: **admin**, **foxx**, org team **Owners** +- Admins cannot bypass merge rules + +**FE bare repo** (primary enforcement for `git push origin`): + +```bash +# on FE, in bare repo hooks/ +cp examples/git/hooks/pre-receive.android_cast.example \ + /path/to/android_cast.git/hooks/pre-receive +chmod +x /path/to/android_cast.git/hooks/pre-receive +``` + +Rejects on `master` / `next`: branch delete, non-fast-forward (force-push). Other branches unchanged. + +`gitea_polish_project.sh` runs branch protection automatically unless `GITEA_SKIP_BRANCH_PROTECT=1`. + +Apply the same Gitea rules to other AndroidCast repos: + +```bash +GITEA_REPO=url-shortener GITEA_PROTECT_BRANCHES="next master" \ + sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_protect_branches.sh +``` + +(Branches that do not exist yet are skipped when using `gitea_attach_url_shortener.sh`.) + +## url-shortener (first-party submodule mirror) + +The url-shortener service lives in its **own** canonical repo +`git://f0xx.org/androidcast_project/url-shortener` (submodule `backend/url-shortener`). +It is **not** mirrored by `gitea_mirror_submodules.sh` (local `f0xx.org` URLs are skipped). + +**One command on BE** (org + pull mirror + default `next` + protect `master`/`next` when present): + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_attach_url_shortener.sh +``` + +This reuses `gitea_attach_remote.sh`, `gitea_set_default_branch.sh`, and `gitea_protect_branches.sh` — no duplicate mirror logic. + +| Step | What | +|------|------| +| Org | Ensures **AndroidCast** + **Owners** (`admin`, `foxx`) | +| Transfer | `admin/url-shortener` or `androidcast_project/url-shortener` → `AndroidCast/url-shortener` if present | +| Mirror | Pull from `git://f0xx.org/androidcast_project/url-shortener` (`GITEA_PRIVATE=false` — team-visible under public org) | +| Default branch | `next` | +| Protection | `next` and `master` only (skips branches not on canonical remote yet) | + +Clone URL: + +`https://apps.f0xx.org/app/androidcast_project/git/AndroidCast/url-shortener.git` + +**FE canonical hook** (primary enforcement for `git push` to `git://f0xx.org/...`): + +```bash +cp examples/git/hooks/pre-receive.android_cast.example \ + /path/to/url-shortener.git/hooks/pre-receive +chmod +x /path/to/url-shortener.git/hooks/pre-receive +``` + +Ensure `app.ini` `[migrations]` allows **`f0xx.org`** (in addition to `10.7.0.10` for the main repo). + +`gitea_polish_project.sh` calls `gitea_attach_url_shortener.sh` automatically when the mirror is missing. + +## Polish (finish line — safe BE run) + +```bash +cd /var/www/.../androidcast_project/android_cast +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_polish_project.sh . +``` + +Idempotent: **foxx** → AndroidCast **Owners**, transfer `admin/*` repos, **sync mirrors now**, create missing **libvpx/opus/speex**, default branch **`next`**. + +If `gitea_diagnose_mirror_sync.sh` shows `mirror=false` for `android_cast`: + +```bash +GITEA_RECREATE_MAIN_MIRROR=yes sudo ./gitea_polish_project.sh . +``` + +**foxx UI:** browse **`…/git/AndroidCast`** (org), not only profile “Your repositories”. + +**Email/2FA (deferred):** `REGISTER_EMAIL_CONFIRM = false` until SMTP; passwords via `gitea_user_admin.sh`. + +## Verify + +```bash +# Remote reachable from BE +git ls-remote git://f0xx.org/android_cast HEAD + +# Gitea UI +curl -sS -o /dev/null -w '%{http_code}\n' \ + 'https://apps.f0xx.org/app/androidcast_project/git/' + +# After attach +git ls-remote "https://apps.f0xx.org/app/androidcast_project/git/foxx/android_cast.git" HEAD +``` + +## Troubleshooting + +### 1) Mirror synced once, then nothing (1h+ stale) + +Common cause: repo was created via **`git push --mirror` fallback**, not API **pull mirror** — Gitea shows the repo but **`mirror: false`** and cron never pulls again. + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_diagnose_mirror_sync.sh +``` + +If `api mirror=false`: + +1. Delete `AndroidCast/android_cast` in Gitea (mirror copy only). +2. Re-attach with API migrate (must succeed): + +```bash +GITEA_OWNER=AndroidCast sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_attach_remote.sh git://10.7.0.10/android_cast +``` + +Ensure `app.ini`: + +```ini +[cron.update_mirrors] +ENABLED = true +SCHEDULE = @every 10m +RUN_AT_START = true + +[migrations] +ALLOW_LOCALNETWORKS = true +``` + +Manual sync / stuck queue: + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh android_cast +# Admin UI -> Monitor -> Queues -> Remove All (if log says "already in queue") +sudo rc-service gitea restart +``` + +### 2) Default branch `next` in UI + +Per-repo (Gitea UI): **Settings → Repository → Default Branch → `next`**. + +Or script (all repos under org): + +```bash +GITEA_OWNER=AndroidCast GITEA_DEFAULT_BRANCH=next \ + sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_set_default_branch.sh +``` + +Run after mirror has fetched `next` from upstream. Each developer can still pick another branch locally; this only changes the **web UI default**. + +### 3) Register says “user exists”, login fails (`foxx`) + +`gitea_migrate_org_setup.sh` **pre-creates** `foxx` — do **not** use **Register**. The account has an admin-set random password (`must-change-password`). + +**Site admin** sets password: + +```bash +sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_user_admin.sh reset-password foxx 'choose-a-password' +``` + +Or Gitea UI: **Site Administration → Users → foxx → Edit**. + +Then **Sign In** (not Register). Add SSH key under **Settings → SSH / GPG Keys**. + +If self-registration is intended instead: delete `foxx` in admin UI and use **Register** once, or set in `app.ini`: + +```ini +[service] +DISABLE_REGISTRATION = false +REGISTER_EMAIL_CONFIRM = false +``` + +## Related docs + +- [docs/INFRA.md](../../../../docs/INFRA.md) — `git://f0xx.org/android_cast`, disk layout +- [docs/TICKETS_ROADMAP.md](../../../../docs/TICKETS_ROADMAP.md) — Gitea URL / `ROOT_URL` +- [docs/GIT_FLOW.md](../../../../docs/GIT_FLOW.md) — branch flow after remote is wired diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/fe-android_cast-post-receive.example b/sim/cluster0/lab-seeds/backend/scripts/gitea/fe-android_cast-post-receive.example new file mode 100644 index 0000000..7833a8d --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/fe-android_cast-post-receive.example @@ -0,0 +1,32 @@ +#!/bin/sh +# FE bare repo hook: android_cast.git/hooks/post-receive +# After push to git://10.7.0.10/android_cast, trigger immediate Gitea pull sync on BE. +# +# Install on Gentoo FE (adjust paths): +# cp fe-android_cast-post-receive.example /var/git/android_cast.git/hooks/post-receive +# chmod +x /var/git/android_cast.git/hooks/post-receive +# +# Pick ONE trigger method below (uncomment). + +# --- A) SSH to BE (recommended if FE has alpine-be key) --- +# GITEA_SYNC_CMD='ssh alpine-be sudo /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh android_cast' + +# --- B) curl Gitea API on BE loopback via FE→BE nginx/internal URL --- +# Create a long-lived token in Gitea UI (admin, scope: write repository). +# GITEA_SYNC_CMD='curl -fsS -X POST -H "Authorization: token YOUR_TOKEN" http://artc0.intra.raptor.org:3000/api/v1/repos/AndroidCast/android_cast/mirror-sync' + +# --- C) curl public apps URL (only if you add a protected internal endpoint) --- +# GITEA_SYNC_CMD='curl -fsS -X POST -H "Authorization: Bearer ..." https://apps.f0xx.org/.../gitea_mirror_sync' + +: "${GITEA_SYNC_CMD:=}" +while read -r _old _new _ref; do + [ "$_new" = "0000000000000000000000000000000000000000" ] && continue + case "$_ref" in + refs/heads/next|refs/heads/master) ;; + *) continue ;; + esac + if [ -n "$GITEA_SYNC_CMD" ]; then + sh -c "$GITEA_SYNC_CMD" /dev/null 2>&1 & + fi +done +exit 0 diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_remote.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_remote.sh new file mode 100755 index 0000000..93447ae --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_remote.sh @@ -0,0 +1,316 @@ +#!/bin/ash +# +# Attach canonical git:// remote to Gitea as a pull mirror (no NFS/SSHFS shared folder). +# Run on Alpine BE after gitea_collect_backup.sh (optional reset) and Gitea is running. +# +# Usage: +# ./gitea_attach_remote.sh +# ./gitea_attach_remote.sh git://10.7.0.10/android_cast +# GITEA_MIRROR_URL=git://f0xx.org/android_cast ./gitea_attach_remote.sh +# +# GITEA_ADMIN_TOKEN is optional on BE when run as root: the script can create a repo +# and a short-lived push token via `gitea admin` CLI. Set a token only if CLI fails. +# +# Methods (auto): +# 1) Gitea API /repos/migrate with mirror=true (ongoing pull sync in UI) +# 2) gitea admin create-repo + git clone --mirror + git push --mirror +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" +# shellcheck source=gitea_org_lib.sh +. "$SCRIPT_DIR/gitea_org_lib.sh" + +GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://f0xx.org/android_cast}" +GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-ac}}" +GITEA_REPO="${GITEA_REPO:-android_cast}" +GITEA_MIRROR_INTERVAL="${GITEA_MIRROR_INTERVAL:-10m}" +GITEA_PRIVATE="${GITEA_PRIVATE:-false}" +WORK_DIR="${WORK_DIR:-}" + +# First positional argument = mirror URL (convenience). +if [ $# -ge 1 ] && [ -n "$1" ]; then + case "$1" in + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + *) + GITEA_MIRROR_URL="$1" + ;; + esac +fi + +log() { printf '[gitea-attach] %s\n' "$*"; } +warn() { printf '[gitea-attach] WARN: %s\n' "$*" >&2; } +die() { printf '[gitea-attach] ERROR: %s\n' "$*" >&2; exit 1; } + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing command: $1" +} + +# Public clone URL (via ROOT_URL + subpath). Local :3000 git/API omit subpath (nginx strips it). +gitea_public_repo_url() { + _root="$GITEA_ROOT_URL" + case "$_root" in + */) _root="${_root%/}" ;; + esac + printf '%s/%s/%s.git' "$_root" "$GITEA_OWNER" "$GITEA_REPO" +} + +gitea_local_git_url() { + printf 'http://127.0.0.1:%s/%s/%s.git' "${GITEA_HTTP_PORT:-3000}" "$GITEA_OWNER" "$GITEA_REPO" +} + +gitea_api_base() { + printf '%s' "${GITEA_LOCAL_API:-http://127.0.0.1:3000/api/v1}" +} + +test_remote_reachable() { + _url="$1" + log "probing remote: $_url" + if command -v git >/dev/null 2>&1; then + if git ls-remote "$_url" HEAD >/dev/null 2>&1; then + log "remote reachable (git ls-remote HEAD)" + return 0 + fi + if git ls-remote "$_url" refs/heads/next >/dev/null 2>&1; then + log "remote reachable (git ls-remote refs/heads/next)" + return 0 + fi + if git ls-remote "$_url" 2>/dev/null | grep -q refs/heads/; then + log "remote reachable (git ls-remote has branches)" + return 0 + fi + warn "git ls-remote failed for $_url" + fi + return 1 +} + +resolve_gitea_owner() { + if [ "${GITEA_SKIP_ORG_SETUP:-0}" -eq 0 ]; then + gitea_org_ensure_androidcast "$GITEA_ORG" 2>/dev/null || true + GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}" + fi + if gitea_owner_exists "$GITEA_OWNER"; then + log "Gitea repo owner: $GITEA_OWNER" + return 0 + fi + warn "Gitea has no owner '$GITEA_OWNER' (user or org — not Linux/FE login)" + log "Gitea users on this instance:" + gitea_list_usernames | while read -r _u; do + [ -n "$_u" ] && log " - $_u" + done + _fallback="$(gitea_pick_owner_username)" + if [ -z "$_fallback" ]; then + die "no Gitea users in DB — complete first-run at $GITEA_ROOT_URL or: sudo -u $GITEA_RUN_USER gitea -c $GITEA_APP_INI admin user create --username admin --password '...' --email admin@local --admin" + fi + warn "using Gitea user '$_fallback' as repo owner (override: GITEA_OWNER=...)" + GITEA_OWNER="$_fallback" +} + +resolve_push_token() { + gitea_resolve_push_token +} + +create_repo_via_cli() { + log "creating empty repo $GITEA_OWNER/$GITEA_REPO via Gitea API..." + if gitea_create_repo_cli "$GITEA_OWNER" "$GITEA_REPO" "$GITEA_PRIVATE"; then + log "repo created (API)" + return 0 + fi + warn "API create repo failed for $GITEA_OWNER/$GITEA_REPO" + return 1 +} + +create_repo_via_api() { + create_repo_via_cli +} + +gitea_owner_uid() { + gitea_entity_uid_for "$GITEA_OWNER" +} + +migrate_via_api() { + need_cmd curl + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(resolve_push_token)" || return 1 + + _uid="$(gitea_owner_uid)" || { + warn "API migrate skipped: no uid for owner $GITEA_OWNER" + return 1 + } + + _api="$(gitea_api_base)" + _payload=$(cat </dev/null; then + log "repo already exists — sync mirror instead of migrate" + gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" && return 0 + return 1 + fi + _resp="$(gitea_mktemp_file)" + _http="$(curl -sS -m "${GITEA_MIGRATE_TIMEOUT:-600}" -o "$_resp" -w '%{http_code}' \ + -X POST "$_api/repos/migrate" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "$_payload")" + + _rc=1 + if [ "$_http" = "201" ] || [ "$_http" = "200" ]; then + log "mirror created via API (HTTP $_http)" + cat "$_resp" + printf '\n' + _rc=0 + else + if [ "$_http" = "409" ] || [ "$_http" = "422" ]; then + warn "migrate rejected HTTP $_http — repo may already exist; use mirror settings or delete repo first" + fi + warn "API migrate failed HTTP $_http:" + cat "$_resp" >&2 + fi + gitea_rm_path "$_resp" + return "$_rc" +} + +ensure_gitea_repo_exists() { + create_repo_via_cli || create_repo_via_api || true +} + +mirror_via_git() { + need_cmd git + gitea_prune_stale_tmp + if [ -n "$WORK_DIR" ]; then + mkdir -p "$WORK_DIR" + else + WORK_DIR="$(gitea_make_work_dir gitea-attach)" + fi + trap 'gitea_rm_path "$WORK_DIR"' EXIT INT HUP TERM + + _mirror="$WORK_DIR/${GITEA_REPO}.mirror.git" + _rc=1 + + log "git clone --mirror $GITEA_MIRROR_URL" + gitea_rm_path "$_mirror" + if ! git clone --mirror "$GITEA_MIRROR_URL" "$_mirror"; then + gitea_rm_path "$WORK_DIR" + return 1 + fi + + ensure_gitea_repo_exists + + _push_base="$(gitea_local_git_url)" + if _token="$(resolve_push_token)"; then + _auth_url="${_push_base#http://}" + _push_url="http://${_token}@${_auth_url}" + log "git push --mirror -> $_push_base (localhost, no subpath)" + if git -C "$_mirror" push --mirror "$_push_url"; then + _rc=0 + log "one-time mirror push complete" + fi + else + die "need credentials: export GITEA_ADMIN_TOKEN=... or run as root with gitea CLI" + fi + + warn "for automatic updates, re-run with API migrate or enable pull mirror in Gitea UI" + gitea_rm_path "$WORK_DIR" + trap - EXIT INT HUP TERM + return "$_rc" +} + +enable_mirror_sync_cron_hint() { + cat </dev/null 2>&1; then + if ! rc-service gitea status 2>/dev/null | grep -qi started; then + warn "gitea service may not be running — starting..." + rc-service gitea start 2>/dev/null || true + fi +fi + +if migrate_via_api; then + enable_mirror_sync_cron_hint + log "done (API pull mirror)" + exit 0 +fi + +# Skip git push if repo already exists as mirror (stale curl migrate may have completed server-side). +if gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO" 2>/dev/null; then + log "repo $GITEA_OWNER/$GITEA_REPO already exists — syncing mirror" + gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" 2>/dev/null || true + enable_mirror_sync_cron_hint + log "done (existing mirror)" + exit 0 +fi + +log "falling back to git clone --mirror + push --mirror" +if ! mirror_via_git; then + print_token_help + exit 1 +fi +enable_mirror_sync_cron_hint +log "done (git mirror push)" diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_url_shortener.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_url_shortener.sh new file mode 100755 index 0000000..6cdfa51 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_url_shortener.sh @@ -0,0 +1,128 @@ +#!/bin/ash +# +# Import url-shortener into the AndroidCast Gitea org (pull mirror, team-visible, protected branches). +# Thin orchestrator — reuses gitea_attach_remote.sh, gitea_set_default_branch.sh, +# gitea_protect_branches.sh (same rules as android_cast). +# +# Canonical remote: git://f0xx.org/androidcast_project/url-shortener +# Gitea UI: …/git/AndroidCast/url-shortener +# +# Usage (Alpine BE as root): +# sudo ./gitea_attach_url_shortener.sh +# +# Override: +# GITEA_MIRROR_URL=git://f0xx.org/androidcast_project/url-shortener sudo ./gitea_attach_url_shortener.sh +# +# Prerequisites: app.ini [migrations] must allow f0xx.org (see scripts/gitea/README.md). +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" +# shellcheck source=gitea_org_lib.sh +. "$SCRIPT_DIR/gitea_org_lib.sh" + +GITEA_ORG="${GITEA_ORG:-AndroidCast}" +GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}" +GITEA_REPO="${GITEA_REPO:-url-shortener}" +GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://f0xx.org/androidcast_project/url-shortener}" +GITEA_DEFAULT_BRANCH="${GITEA_DEFAULT_BRANCH:-next}" +GITEA_MIRROR_INTERVAL="${GITEA_MIRROR_INTERVAL:-10m}" +GITEA_PRIVATE="${GITEA_PRIVATE:-false}" +# Personal or legacy owners to re-home under AndroidCast (space-separated). +GITEA_TRANSFER_SOURCES="${GITEA_TRANSFER_SOURCES:-admin androidcast_project}" + +log() { printf '[gitea-url-shortener] %s\n' "$*"; } +warn() { printf '[gitea-url-shortener] WARN: %s\n' "$*" >&2; } + +case "${1:-}" in + -h|--help|help) + sed -n '2,18p' "$0" + exit 0 + ;; +esac + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +log "=== $GITEA_REPO → $GITEA_OWNER/$GITEA_REPO ===" +log "mirror: $GITEA_MIRROR_URL" +log "public: ${GITEA_ROOT_URL%/}/$GITEA_OWNER/$GITEA_REPO" + +# 1) Org + Owners team (admin, foxx) — repos under the org are team-visible. +gitea_org_ensure_androidcast "$GITEA_ORG" || warn "org setup incomplete" + +# 2) Transfer if mirror was created under a personal/legacy account. +for _from in $GITEA_TRANSFER_SOURCES; do + [ "$_from" = "$GITEA_OWNER" ] && continue + if gitea_repo_exists_api "$_from" "$GITEA_REPO"; then + log "transfer $_from/$GITEA_REPO → $GITEA_OWNER/$GITEA_REPO" + gitea_transfer_repo_api "$_from" "$GITEA_REPO" "$GITEA_OWNER" \ + && log " transferred" \ + || warn " transfer failed" + fi +done + +# 3) Pull mirror (attach when missing). Resolve PAT once for all API calls this run. +_gitea_token="$(gitea_resolve_push_token)" || true +[ -n "$_gitea_token" ] && GITEA_ADMIN_TOKEN="$_gitea_token" && export GITEA_ADMIN_TOKEN + +if gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO"; then + log "repo exists — trigger mirror sync" + gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" || warn "mirror sync API failed — try gitea_sync_mirror.sh" +else + log "attach pull mirror (API migrate) ..." + if ! gitea_mirror_pull_repo "$GITEA_MIRROR_URL" "$GITEA_REPO" "$GITEA_OWNER" \ + "$GITEA_MIRROR_INTERVAL" ""; then + warn "API migrate failed — trying gitea_attach_remote.sh fallback ..." + GITEA_OWNER="$GITEA_OWNER" GITEA_REPO="$GITEA_REPO" \ + GITEA_MIRROR_URL="$GITEA_MIRROR_URL" \ + GITEA_MIRROR_INTERVAL="$GITEA_MIRROR_INTERVAL" \ + GITEA_PRIVATE="$GITEA_PRIVATE" \ + sh "$SCRIPT_DIR/gitea_attach_remote.sh" "$GITEA_MIRROR_URL" || exit 1 + fi + gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" || warn "initial sync pending — check [migrations] / git daemon" +fi + +# 4) Default branch next (integration; master when release branch exists). +GITEA_OWNER="$GITEA_OWNER" GITEA_REPO_NAMES="$GITEA_REPO" \ + GITEA_DEFAULT_BRANCH="$GITEA_DEFAULT_BRANCH" \ + sh "$SCRIPT_DIR/gitea_set_default_branch.sh" || warn "default branch set failed (sync mirror first)" + +# 5) Branch protection — master + next when present on canonical remote (GIT_FLOW §7). +_branches="" +for _b in next master; do + if gitea_git_remote_has_branch "$GITEA_MIRROR_URL" "$_b"; then + _branches="$_branches $_b" + else + log "skip Gitea protection for $_b (not on $GITEA_MIRROR_URL yet)" + fi +done +_branches="$(echo "$_branches" | sed 's/^ //')" +if [ -n "$_branches" ]; then + GITEA_OWNER="$GITEA_OWNER" GITEA_REPO="$GITEA_REPO" \ + GITEA_PROTECT_BRANCHES="$_branches" \ + sh "$SCRIPT_DIR/gitea_protect_branches.sh" || warn "branch protection failed — run manually" +else + warn "no branches to protect — mirror may be empty" +fi + +cat <&2; } +die() { printf '[gitea-collect] ERROR: %s\n' "$*" >&2; exit 1; } + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing command: $1" +} + +copy_tree() { + _src="$1" + _dst="$2" + if [ ! -e "$_src" ]; then + warn "skip missing: $_src" + return 0 + fi + mkdir -p "$(dirname "$_dst")" + if cp -a "$_src" "$_dst" 2>/dev/null; then + log "copied: $_src -> $_dst" + return 0 + fi + # BusyBox cp may lack -a; try tar pipe. + mkdir -p "$_dst" + (cd "$_src" && tar cf - .) | (cd "$_dst" && tar xf -) + log "archived (tar): $_src -> $_dst" +} + +dump_mysql() { + _out="$1" + need_cmd mysqldump + _args="" + if [ -n "$GITEA_DB_HOST" ]; then + case "$GITEA_DB_HOST" in + /*) _args="--socket=$GITEA_DB_HOST" ;; + *) _args="-h $GITEA_DB_HOST" ;; + esac + fi + # shellcheck disable=SC2086 + mysqldump $_args -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} \ + --single-transaction --routines --triggers "$GITEA_DB_NAME" > "$_out" + log "mysqldump -> $_out" +} + +dump_postgres() { + _out="$1" + need_cmd pg_dump + PGPASSWORD="$GITEA_DB_PASS" pg_dump -h "${GITEA_DB_HOST:-127.0.0.1}" -U "$GITEA_DB_USER" "$GITEA_DB_NAME" > "$_out" + log "pg_dump -> $_out" +} + +write_manifest() { + { + echo "Gitea collect backup" + echo "created_at=$STAMP" + echo "hostname=$(hostname 2>/dev/null || echo unknown)" + echo "gitea_ini=$GITEA_APP_INI" + echo "repo_root=$GITEA_REPO_ROOT" + echo "app_data=$GITEA_APP_DATA" + echo "root_url=$GITEA_ROOT_URL" + echo "db_type=$GITEA_DB_TYPE" + echo "db_name=$GITEA_DB_NAME" + echo "lfs_path=$GITEA_LFS_PATH" + echo "log_root=$GITEA_LOG_ROOT" + echo "protected_main_repo=$PROTECTED_MAIN_REPO" + echo "" + echo "--- gitea version ---" + gitea_cli "-v" 2>/dev/null || echo "gitea -v unavailable" + echo "" + echo "--- repo directories ---" + if [ -d "$GITEA_REPO_ROOT" ]; then + find "$GITEA_REPO_ROOT" -maxdepth 3 -type d -name '*.git' 2>/dev/null || true + else + echo "(repo root missing)" + fi + } > "$MANIFEST" +} + +list_gitea_repos() { + if [ ! -d "$GITEA_REPO_ROOT" ]; then + return 0 + fi + find "$GITEA_REPO_ROOT" -mindepth 2 -maxdepth 2 -type d -name '*.git' 2>/dev/null \ + | while read -r gitdir; do + owner="$(basename "$(dirname "$gitdir")")" + repo="$(basename "$gitdir" .git)" + printf '%s/%s\n' "$owner" "$repo" + done +} + +delete_gitea_repo_records() { + _owner="$1" + _repo="$2" + if gitea_cli "admin" "repo" "delete" "$_owner/$_repo" 2>/dev/null; then + log "gitea admin repo delete $_owner/$_repo" + return 0 + fi + if gitea_cli "admin" "repo-delete" "$_owner/$_repo" 2>/dev/null; then + log "gitea admin repo-delete $_owner/$_repo" + return 0 + fi + warn "could not delete $_owner/$_repo via gitea CLI (will still remove bare files)" + return 1 +} + +reset_gitea_repos_only() { + if ! gitea_path_is_safe_to_wipe "$GITEA_REPO_ROOT"; then + die "refusing reset: repo root not under safe prefix: $GITEA_REPO_ROOT" + fi + case "$PROTECTED_MAIN_REPO" in + "$GITEA_REPO_ROOT"|"$GITEA_REPO_ROOT"/*) + die "refusing reset: Gitea repo root overlaps protected main repo path" + ;; + esac + + log "stopping gitea (if running)..." + if command -v rc-service >/dev/null 2>&1; then + rc-service gitea stop 2>/dev/null || true + fi + + log "removing Gitea repo registrations via CLI..." + _repos="$(list_gitea_repos)" + if [ -n "$_repos" ]; then + echo "$_repos" | while read -r slug; do + [ -n "$slug" ] || continue + _o="${slug%%/*}" + _r="${slug#*/}" + delete_gitea_repo_records "$_o" "$_r" || true + done + fi + + if [ -d "$GITEA_REPO_ROOT" ]; then + log "clearing bare repos under $GITEA_REPO_ROOT (not touching $PROTECTED_MAIN_REPO)" + find "$GITEA_REPO_ROOT" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + + fi + mkdir -p "$GITEA_REPO_ROOT" + + log "starting gitea..." + if command -v rc-service >/dev/null 2>&1; then + rc-service gitea start 2>/dev/null || warn "rc-service gitea start failed — start manually" + fi + log "Gitea repository store reset to empty (Gitea DB may still need doctor if UI shows ghosts)" +} + +# --- main --- + +[ "$(id -u)" -eq 0 ] || die "run as root (needed for service stop, file reads, mysqldump)" + +gitea_load_config || exit 1 +need_cmd awk +need_cmd mkdir +need_cmd date + +mkdir -p "$OUT_DIR" +log "backup directory: $OUT_DIR" + +write_manifest + +log "copy app.ini" +mkdir -p "$OUT_DIR/config" +copy_tree "$GITEA_APP_INI" "$OUT_DIR/config/app.ini" + +log "copy APP_DATA_PATH (includes caches, indexes, attachments, custom/)" +copy_tree "$GITEA_APP_DATA" "$OUT_DIR/app_data" + +log "copy repository ROOT (bare repos)" +copy_tree "$GITEA_REPO_ROOT" "$OUT_DIR/gitea-repositories" + +if [ -n "$GITEA_LFS_PATH" ] && [ "$GITEA_LFS_PATH" != "$GITEA_APP_DATA/lfs" ]; then + log "copy LFS path" + copy_tree "$GITEA_LFS_PATH" "$OUT_DIR/lfs" +fi + +if [ -n "$GITEA_LOG_ROOT" ] && [ -d "$GITEA_LOG_ROOT" ]; then + log "copy log directory" + copy_tree "$GITEA_LOG_ROOT" "$OUT_DIR/logs" +fi + +log "database dump" +mkdir -p "$OUT_DIR/database" +case "$(echo "$GITEA_DB_TYPE" | tr '[:upper:]' '[:lower:]')" in + mysql) + dump_mysql "$OUT_DIR/database/gitea-mysql.sql" + ;; + postgres|postgresql) + dump_postgres "$OUT_DIR/database/gitea-postgres.sql" + ;; + sqlite3|sqlite) + if [ -n "$GITEA_DB_PATH" ] && [ -f "$GITEA_DB_PATH" ]; then + copy_tree "$GITEA_DB_PATH" "$OUT_DIR/database/gitea.sqlite3" + elif [ -f "$GITEA_APP_DATA/gitea.db" ]; then + copy_tree "$GITEA_APP_DATA/gitea.db" "$OUT_DIR/database/gitea.sqlite3" + else + warn "sqlite DB file not found (database PATH / gitea.db)" + fi + ;; + *) + warn "unknown DB_TYPE=$GITEA_DB_TYPE — copy app_data only" + ;; +esac + +if command -v gitea >/dev/null 2>&1; then + if gitea_cli "dump" "--file" "$OUT_DIR/gitea-built-in-dump.zip" 2>/dev/null; then + log "gitea built-in dump -> gitea-built-in-dump.zip" + else + warn "gitea dump subcommand unavailable or failed (backup copies above are still valid)" + fi +fi + +_bytes="$(du -sh "$OUT_DIR" 2>/dev/null | awk '{print $1}')" +log "done. size≈${_bytes:-?} manifest=$MANIFEST" +log "protected working tree (never touched): $PROTECTED_MAIN_REPO" + +printf '\nWould you like to reset Gitea repositories state to the default (empty)? [y/N] ' +read -r _reset_answer || _reset_answer="" +case "$(echo "$_reset_answer" | tr '[:upper:]' '[:lower:]')" in + y|yes) + log "reset confirmed" + reset_gitea_repos_only + ;; + *) + log "reset skipped" + ;; +esac + +log "backup complete: $OUT_DIR" diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirror_sync.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirror_sync.sh new file mode 100755 index 0000000..14d0be5 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirror_sync.sh @@ -0,0 +1,120 @@ +#!/bin/ash +# +# Why Gitea pull mirrors stopped syncing (one-time migrate vs cron vs queue). +# +# Usage: +# sudo ./gitea_diagnose_mirror_sync.sh +# GITEA_OWNER=AndroidCast GITEA_REPO=android_cast sudo ./gitea_diagnose_mirror_sync.sh +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" + +GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}" +GITEA_REPO="${GITEA_REPO:-android_cast}" +GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://10.7.0.10/android_cast}" + +log() { printf '[gitea-mirror-diag] %s\n' "$*"; } +warn() { printf '[gitea-mirror-diag] WARN: %s\n' "$*" >&2; } + +json_bool() { + _json="$1" + _key="$2" + printf '%s' "$_json" | tr -d '\n' | grep -o "\"$_key\":[^,}]*" | head -1 | sed 's/.*://;s/ //g' +} + +json_str() { + _json="$1" + _key="$2" + printf '%s' "$_json" | tr -d '\n' | grep -o "\"$_key\":\"[^\"]*\"" | head -1 | sed 's/.*:"//;s/"$//' +} + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +log "ini=$GITEA_APP_INI" +log "repo=$GITEA_OWNER/$GITEA_REPO upstream=$GITEA_MIRROR_URL" + +# --- upstream reachability --- +if git ls-remote "$GITEA_MIRROR_URL" HEAD >/dev/null 2>&1; then + log "upstream reachable from this host (git ls-remote OK)" +else + warn "upstream NOT reachable — scheduled pull mirrors will fail (check git daemon, ALLOW_LOCALNETWORKS)" +fi + +# --- repo mirror flags (API) --- +_meta="$(gitea_repo_get_api "$GITEA_OWNER" "$GITEA_REPO" 2>/dev/null)" || _meta="" +if [ -z "$_meta" ]; then + warn "could not read repo via API" +else + _mirror="$(json_bool "$_meta" "mirror")" + _interval="$(json_str "$_meta" "mirror_interval")" + _default="$(json_str "$_meta" "default_branch")" + _updated="$(json_str "$_meta" "updated_at")" + log "api mirror=$_mirror mirror_interval=$_interval default_branch=$_default updated_at=$_updated" + if [ "$_mirror" != "true" ]; then + warn "repo is NOT a pull mirror — only the initial push happened; auto-sync will never run" + warn "fix: delete $GITEA_OWNER/$GITEA_REPO in Gitea UI, re-run:" + warn " sudo ./gitea_attach_remote.sh $GITEA_MIRROR_URL" + warn " (API migrate must succeed — not git-push fallback)" + fi +fi + +# --- app.ini mirror cron hints --- +for _ini in "$GITEA_APP_INI" /etc/gitea/app.ini; do + [ -r "$_ini" ] || continue + log "config $_ini:" + awk ' + /^\[/ { sec = $0; gsub(/^\[|\]$/, "", sec) } + /^(ENABLED|SCHEDULE|MIN_INTERVAL|DEFAULT_INTERVAL|PULL_LIMIT|ALLOW_LOCALNETWORKS)/ { + print " " sec " " $0 + } + ' "$_ini" | grep -E 'cron\.update_mirrors|^\s*\[mirror\]|migrations' || true +done + +# --- sqlite mirror row (next_update) if available --- +if command -v sqlite3 >/dev/null 2>&1; then + _db="${GITEA_DB_PATH:-}" + [ -n "$_db" ] || _db="$GITEA_APP_DATA/gitea.db" + [ -r "$_db" ] || _db="/var/lib/gitea/db/gitea.db" + if [ -r "$_db" ]; then + log "mirror table (repo_id, interval, next_update_unix, updated_unix):" + sqlite3 -header -column "$_db" " + SELECT m.repo_id, m.interval, m.next_update_unix, m.updated_unix, r.name + FROM mirror m + JOIN repository r ON r.id = m.repo_id + WHERE r.lower_name = lower('$GITEA_REPO') + LIMIT 5; + " 2>/dev/null || warn "mirror SQL query failed" + fi +fi + +# --- gitea.log mirror errors --- +for _logf in "$GITEA_LOG_ROOT/gitea.log" /var/log/gitea/gitea.log; do + [ -r "$_logf" ] || continue + log "recent mirror errors in $_logf:" + grep -E 'mirror|already in queue|update_mirrors' "$_logf" 2>/dev/null | tail -n 8 || true + break +done + +cat < Monitor -> Queues -> Remove All (mirror) + 4) Ensure app.ini: + [cron.update_mirrors] + ENABLED = true + SCHEDULE = @every 10m + RUN_AT_START = true + then: sudo rc-service gitea restart + 5) FE push trigger: fe-android_cast-post-receive.example + +EOF diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirrors.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirrors.sh new file mode 100755 index 0000000..8345248 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirrors.sh @@ -0,0 +1,105 @@ +#!/bin/ash +# +# Debug why submodule mirrors are missing in Gitea UI. +# +# Usage: +# sudo ./gitea_diagnose_mirrors.sh +# sudo ./gitea_diagnose_mirrors.sh /var/www/.../android_cast +# sudo ./gitea_diagnose_mirrors.sh /var/www/.../android_cast/.gitmodules +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" +# shellcheck source=gitea_submodule_lib.sh +. "$SCRIPT_DIR/gitea_submodule_lib.sh" + +GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}" +GITEA_REF="${GITEA_REF:-HEAD}" +GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}" + +log() { printf '[gitea-diagnose] %s\n' "$*"; } +warn() { printf '[gitea-diagnose] WARN: %s\n' "$*" >&2; } + +_arg="${1:-.}" +_repo_dir="" +_gitmodules="" + +case "$_arg" in + *.gitmodules) + _gitmodules="$(cd "$(dirname "$_arg")" && pwd)/$(basename "$_arg")" + _parent="$(dirname "$_gitmodules")" + [ -d "$_parent/.git" ] || [ -f "$_parent/.git" ] && _repo_dir="$(cd "$_parent" && pwd)" || _repo_dir="$_parent" + ;; + *) + [ -d "$_arg" ] || { warn "not a directory: $_arg"; exit 1; } + _repo_dir="$(cd "$_arg" && pwd)" + _gitmodules="$_repo_dir/.gitmodules" + ;; +esac + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +log "ini=$GITEA_APP_INI owner=$GITEA_OWNER" +log "checkout=$_repo_dir" +log "gitmodules=$_gitmodules" + +if [ -r "$_gitmodules" ]; then + log ".gitmodules present ($(wc -l < "$_gitmodules" | tr -d ' ') lines)" +else + warn ".gitmodules MISSING on BE — submodule migrate cannot discover entries" +fi + +if command -v git >/dev/null 2>&1 && [ -d "$_repo_dir/.git" ] || [ -f "$_repo_dir/.git" ]; then + log "git branch: $(git -C "$_repo_dir" branch --show-current 2>/dev/null || echo '?')" + log "git HEAD: $(git -C "$_repo_dir" rev-parse HEAD 2>/dev/null || echo '?')" + for _p in third-party/libvpx third-party/opus third-party/speex; do + _sha="$(git -C "$_repo_dir" rev-parse "$GITEA_REF:$_p" 2>/dev/null)" || _sha="(no gitlink)" + log " $GITEA_REF:$_p -> $_sha" + done +else + warn "not a git checkout — only .gitmodules / defaults.conf can be used" +fi + +log "collect_entries (what migrate would mirror):" +gitea_submodule_collect_entries "$_repo_dir" "$GITEA_REF" "$_gitmodules" "$GITEA_SUBMODULES_CONF" \ + | while IFS= read -r _e; do + [ -n "$_e" ] && log " $_e" + done + +log "Gitea repos via API:" +if _names="$(gitea_list_repos_api "$GITEA_OWNER" 2>/dev/null)"; then + echo "$_names" | while read -r _r; do + [ -n "$_r" ] && log " $GITEA_OWNER/$_r" + done + log "count=$(echo "$_names" | grep -c . 2>/dev/null || echo 0)" +else + warn "API list failed" +fi + +log "on-disk bare repos:" +if [ -d "${GITEA_REPO_ROOT:-/var/lib/gitea/data/gitea-repositories}/$GITEA_OWNER" ]; then + ls -1 "${GITEA_REPO_ROOT}/$GITEA_OWNER" 2>/dev/null | while read -r _d; do + log " $GITEA_OWNER/$_d" + done +else + warn "no directory ${GITEA_REPO_ROOT}/$GITEA_OWNER" +fi + +cat < prints value (last PATH wins without section; use section) +ini_get() { + _ini_file="$1" + _ini_section="$2" + _ini_key="$3" + awk -F= -v sec="[$_ini_section]" -v key="$_ini_key" ' + function trim(s) { + sub(/^[ \t\r\n]+/, "", s) + sub(/[ \t\r\n]+$/, "", s) + return s + } + function unquote(s) { + s = trim(s) + if (s ~ /^".*"$/) { sub(/^"/, "", s); sub(/"$/, "", s) } + if (s ~ /^'\''.*'\''$/) { sub(/^'\''/, "", s); sub(/'\''$/, "", s) } + return s + } + /^[ \t]*#/ { next } + /^[ \t]*;/ { next } + /^\[/ { + cur = $0 + gsub(/^\[|\]$/, "", cur) + cur = trim(cur) + cur = tolower(cur) + next + } + { + k = trim($1) + if (k != key) next + sec_l = tolower(sec) + gsub(/^\[|\]$/, "", sec_l) + if (sec != "" && cur != sec_l) next + v = $0 + sub(/^[^=]*=/, "", v) + print unquote(v) + exit + } + ' "$_ini_file" +} + +gitea_load_config() { + gitea_ini_file="${GITEA_INI:-/etc/gitea/app.ini}" + if [ ! -r "$gitea_ini_file" ]; then + echo "ERROR: cannot read $gitea_ini_file (set GITEA_INI=...)" >&2 + return 1 + fi + + GITEA_APP_INI="$gitea_ini_file" + _load_ini_keys() { + _f="$1" + GITEA_REPO_ROOT="$(ini_get "$_f" repository ROOT)" + GITEA_APP_DATA="$(ini_get "$_f" server APP_DATA_PATH)" + GITEA_ROOT_URL="$(ini_get "$_f" server ROOT_URL)" + GITEA_HTTP_PORT="$(ini_get "$_f" server HTTP_PORT)" + GITEA_DOMAIN="$(ini_get "$_f" server DOMAIN)" + GITEA_DB_TYPE="$(ini_get "$_f" database DB_TYPE)" + GITEA_DB_HOST="$(ini_get "$_f" database HOST)" + GITEA_DB_NAME="$(ini_get "$_f" database NAME)" + GITEA_DB_USER="$(ini_get "$_f" database USER)" + GITEA_DB_PASS="$(ini_get "$_f" database PASSWD)" + GITEA_DB_PATH="$(ini_get "$_f" database PATH)" + GITEA_LFS_PATH="$(ini_get "$_f" lfs PATH)" + GITEA_LOG_ROOT="$(ini_get "$_f" log ROOT_PATH)" + } + _load_ini_keys "$gitea_ini_file" + + [ -n "$GITEA_APP_DATA" ] || GITEA_APP_DATA="/var/lib/gitea" + # Canonical config on Alpine is often under APP_DATA, not /etc stub. + if [ -r "$GITEA_APP_DATA/custom/conf/app.ini" ]; then + _load_ini_keys "$GITEA_APP_DATA/custom/conf/app.ini" + GITEA_APP_INI="$GITEA_APP_DATA/custom/conf/app.ini" + fi + + [ -n "$GITEA_REPO_ROOT" ] || GITEA_REPO_ROOT="$GITEA_APP_DATA/data/gitea-repositories" + [ -n "$GITEA_HTTP_PORT" ] || GITEA_HTTP_PORT="3000" + [ -n "$GITEA_DB_TYPE" ] || GITEA_DB_TYPE="sqlite3" + [ -n "$GITEA_ROOT_URL" ] || GITEA_ROOT_URL="${GITEA_ROOT_URL_DEFAULT:-https://apps.f0xx.org/app/androidcast_project/git/}" + + GITEA_RUN_USER="$(ini_get "$GITEA_APP_INI" "" RUN_USER)" + [ -n "$GITEA_RUN_USER" ] || GITEA_RUN_USER="gitea" + + export GITEA_APP_INI GITEA_REPO_ROOT GITEA_APP_DATA GITEA_ROOT_URL GITEA_HTTP_PORT + export GITEA_DOMAIN GITEA_DB_TYPE GITEA_DB_HOST GITEA_DB_NAME GITEA_DB_USER GITEA_DB_PASS + export GITEA_DB_PATH GITEA_LFS_PATH GITEA_LOG_ROOT GITEA_RUN_USER + + # External URL (ROOT_URL) includes nginx subpath; direct :3000 API/git do not. + GITEA_HTTP_SUBPATH="${GITEA_HTTP_SUBPATH:-}" + if [ -z "$GITEA_HTTP_SUBPATH" ] && [ -n "$GITEA_ROOT_URL" ]; then + _p="${GITEA_ROOT_URL#*://}" + _p="${_p#*/}" + case "$_p" in + */*) GITEA_HTTP_SUBPATH="/${_p%/}" ;; + esac + fi + GITEA_LOCAL_API="${GITEA_LOCAL_API:-http://127.0.0.1:${GITEA_HTTP_PORT}/api/v1}" + export GITEA_HTTP_SUBPATH GITEA_LOCAL_API +} + +# Alpine/BusyBox mktemp: no suffix after XXXXXX — use -p /tmp prefix.XXXXXX +gitea_mktemp_file() { + mktemp -p "${TMPDIR:-/tmp}" gitea.XXXXXX +} + +gitea_mktemp_dir() { + mktemp -d -p "${TMPDIR:-/tmp}" gitea.XXXXXX +} + +# Must be absolute, no .., and under allowed prefixes (Gitea-owned paths only). +gitea_path_is_safe_to_wipe() { + _target="$1" + case "$_target" in + ""|/*..*|*../*) return 1 ;; + esac + case "$_target" in + /var/lib/gitea/*|/var/lib/gitea) return 0 ;; + /data/gitea/*|/data/gitea) return 0 ;; + esac + return 1 +} + +# Gitea admin CLI must run as RUN_USER from app.ini (Alpine package: user "gitea", not "git"). +gitea_run_as_service_user() { + _run="${GITEA_RUN_USER:-gitea}" + _cur="$(gitea_current_user)" + if [ "$_cur" = "$_run" ]; then + sh -c "$*" + return $? + fi + if id "$_run" >/dev/null 2>&1; then + su -s /bin/sh "$_run" -c "$*" + return $? + fi + if id git >/dev/null 2>&1; then + su -s /bin/sh git -c "$*" + return $? + fi + echo "ERROR: cannot run gitea as '$_run' (set RUN_USER in app.ini)" >&2 + return 1 +} + +gitea_cli() { + if command -v gitea >/dev/null 2>&1; then + gitea_run_as_service_user "gitea -c '$GITEA_APP_INI' $*" + else + echo "WARN: gitea binary not in PATH" >&2 + return 1 + fi +} + +gitea_current_user() { + whoami 2>/dev/null || id -un 2>/dev/null || echo unknown +} + +# Caller must be root (to su) or already GITEA_RUN_USER. +gitea_ensure_cli_access() { + _run="${GITEA_RUN_USER:-gitea}" + _cur="$(gitea_current_user)" + if [ "$_cur" = "$_run" ]; then + return 0 + fi + if [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; then + return 0 + fi + if id "$_run" >/dev/null 2>&1 && su -s /bin/sh "$_run" -c "true" 2>/dev/null; then + return 0 + fi + echo "ERROR: Gitea admin CLI must run as OS user '$_run' (RUN_USER in app.ini)." >&2 + echo " You are '$_cur'. Re-run with:" >&2 + echo " sudo ./gitea_attach_remote.sh git://..." >&2 + echo " Or:" >&2 + echo " sudo -u $_run ./gitea_attach_remote.sh git://..." >&2 + return 1 +} + +gitea_db_list_usernames() { + case "$(echo "$GITEA_DB_TYPE" | tr '[:upper:]' '[:lower:]')" in + mysql) + command -v mysql >/dev/null 2>&1 || return 1 + _sock="${GITEA_DB_HOST:-/run/mysqld/mysqld.sock}" + case "$_sock" in + /*) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -S "$_sock" "$GITEA_DB_NAME" \ + -N -e "SELECT name FROM user ORDER BY id" 2>/dev/null ;; + *) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -h "$_sock" "$GITEA_DB_NAME" \ + -N -e "SELECT name FROM user ORDER BY id" 2>/dev/null ;; + esac + ;; + sqlite3|sqlite) + command -v sqlite3 >/dev/null 2>&1 || return 1 + _db="${GITEA_DB_PATH:-}" + [ -n "$_db" ] || _db="$GITEA_APP_DATA/gitea.db" + [ -r "$_db" ] || _db="/var/lib/gitea/db/gitea.db" + [ -r "$_db" ] || return 1 + sqlite3 "$_db" "SELECT name FROM user ORDER BY id" 2>/dev/null + ;; + *) + return 1 + ;; + esac +} + +gitea_db_pick_admin_username() { + case "$(echo "$GITEA_DB_TYPE" | tr '[:upper:]' '[:lower:]')" in + mysql) + command -v mysql >/dev/null 2>&1 || return 1 + _sock="${GITEA_DB_HOST:-/run/mysqld/mysqld.sock}" + case "$_sock" in + /*) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -S "$_sock" "$GITEA_DB_NAME" \ + -N -e "SELECT name FROM user WHERE is_admin=1 ORDER BY id LIMIT 1" 2>/dev/null ;; + *) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -h "$_sock" "$GITEA_DB_NAME" \ + -N -e "SELECT name FROM user WHERE is_admin=1 ORDER BY id LIMIT 1" 2>/dev/null ;; + esac + ;; + sqlite3|sqlite) + command -v sqlite3 >/dev/null 2>&1 || return 1 + _db="${GITEA_DB_PATH:-$GITEA_APP_DATA/gitea.db}" + [ -r "$_db" ] || _db="/var/lib/gitea/db/gitea.db" + [ -r "$_db" ] || return 1 + sqlite3 "$_db" "SELECT name FROM user WHERE is_admin=1 ORDER BY id LIMIT 1" 2>/dev/null + ;; + *) + return 1 + ;; + esac +} + +# One username per line (CLI as RUN_USER, else DB). +gitea_list_usernames() { + _cli="" + _cli="$(gitea_cli "admin" "user" "list" 2>/dev/null)" || true + if [ -n "$_cli" ]; then + echo "$_cli" | awk 'NR > 1 && NF >= 2 { print $2 }' + return 0 + fi + gitea_db_list_usernames +} + +gitea_user_exists() { + _u="$1" + gitea_list_usernames | grep -Fx "$_u" >/dev/null 2>&1 +} + +# First site-admin account, else first user. +gitea_pick_owner_username() { + _pick="" + _pick="$(gitea_cli "admin" "user" "list" 2>/dev/null \ + | awk 'NR > 1 && NF >= 2 && ($5 == "true" || $5 == "1") { print $2; exit }')" + if [ -n "$_pick" ]; then + printf '%s' "$_pick" + return 0 + fi + _pick="$(gitea_db_pick_admin_username)" + if [ -n "$_pick" ]; then + printf '%s' "$_pick" + return 0 + fi + gitea_list_usernames | head -n 1 +} + diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_migrate_org_setup.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_migrate_org_setup.sh new file mode 100755 index 0000000..0fbd5e6 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_migrate_org_setup.sh @@ -0,0 +1,41 @@ +#!/bin/ash +# +# Prepare AndroidCast Gitea org: team owners (admin + foxx), transfer legacy admin/* repos. +# Safe to re-run (idempotent). Run before or with gitea_migrate_project.sh. +# +# Usage (Alpine BE as root): +# sudo ./gitea_migrate_org_setup.sh +# GITEA_ORG=AndroidCast GITEA_ORG_OWNERS="admin foxx" sudo ./gitea_migrate_org_setup.sh +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" +# shellcheck source=gitea_org_lib.sh +. "$SCRIPT_DIR/gitea_org_lib.sh" + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +gitea_org_log "org=$GITEA_ORG owners=$GITEA_ORG_OWNERS transfer_from=$GITEA_TRANSFER_FROM" +gitea_org_log "public URL: ${GITEA_ROOT_URL%/}/$GITEA_ORG" + +gitea_org_ensure_androidcast "$GITEA_ORG" || exit 1 + +gitea_org_log "repos under $GITEA_ORG:" +gitea_list_repos_api "$GITEA_ORG" 2>/dev/null | while read -r _r; do + [ -n "$_r" ] && gitea_org_log " $GITEA_ORG/$_r" +done + +cat <&2; } +die() { printf '[gitea-migrate] ERROR: %s\n' "$*" >&2; exit 1; } + +# Sets GITEA_REPO_DIR and GITEA_GITMODULES_FILE. +resolve_repo_inputs() { + _arg="${1:-}" + GITEA_REPO_DIR="" + GITEA_GITMODULES_FILE="" + + if [ -n "$_arg" ]; then + case "$_arg" in + *.gitmodules) + [ -r "$_arg" ] || die "cannot read $_arg" + GITEA_GITMODULES_FILE="$(cd "$(dirname "$_arg")" && pwd)/$(basename "$_arg")" + _parent="$(dirname "$GITEA_GITMODULES_FILE")" + if [ -d "$_parent/.git" ] || [ -f "$_parent/.git" ]; then + GITEA_REPO_DIR="$(cd "$_parent" && pwd)" + else + GITEA_REPO_DIR="$_parent" + fi + return 0 + ;; + *) + [ -d "$_arg" ] || die "not a directory: $_arg" + GITEA_REPO_DIR="$(cd "$_arg" && pwd)" + GITEA_GITMODULES_FILE="$GITEA_REPO_DIR/.gitmodules" + return 0 + ;; + esac + fi + if [ -d "./.git" ] || [ -f "./.git" ]; then + GITEA_REPO_DIR="$(pwd)" + GITEA_GITMODULES_FILE="$GITEA_REPO_DIR/.gitmodules" + return 0 + fi + die "pass android_cast checkout path or .gitmodules (needs .gitmodules)" +} + +migrate_main_repo() { + log "main mirror: $GITEA_OWNER/$GITEA_REPO <- $GITEA_MIRROR_URL (interval $GITEA_MAIN_MIRROR_INTERVAL)" + if git ls-remote "$GITEA_MIRROR_URL" HEAD >/dev/null 2>&1; then + log " upstream reachable" + else + warn " upstream not reachable from BE — check FE git daemon / [migrations] ALLOW_LOCALNETWORKS" + fi + + if gitea_mirror_pull_repo "$GITEA_MIRROR_URL" "$GITEA_REPO" "$GITEA_OWNER" \ + "$GITEA_MAIN_MIRROR_INTERVAL" "$WORK_DIR"; then + gitea_mirror_set_interval_api "$GITEA_OWNER" "$GITEA_REPO" "$GITEA_MAIN_MIRROR_INTERVAL" \ + 2>/dev/null || true + gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" || true + log " OK -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$GITEA_REPO")" + return 0 + fi + warn " main mirror FAILED" + return 1 +} + +migrate_submodule() { + _line="$1" + _repo="${_line%%|*}" + _rest="${_line#*|}" + _upstream="${_rest%%|*}" + _rest2="${_rest#*|}" + _path="${_rest2%%|*}" + _rest3="${_rest2#*|}" + _sha="${_rest3%%|*}" + _branch="${_rest3#*|}" + case "$_branch" in + "$_sha") _branch="" ;; + esac + + log "submodule $_path ($GITEA_OWNER/$_repo)" + log " upstream: $_upstream" + if [ "$_sha" = "-" ]; then + log " pinned @ $GITEA_REF: (unknown — no gitlink in checkout; mirroring upstream anyway)" + else + log " pinned @ $GITEA_REF: $_sha" + if git ls-remote "$_upstream" "$_sha" >/dev/null 2>&1; then + log " commit on upstream" + else + warn " commit $_sha not on upstream (mirror may still fetch via branch tip)" + fi + fi + + if ! gitea_mirror_pull_repo "$_upstream" "$_repo" "$GITEA_OWNER" \ + "$GITEA_SUBMODULE_MIRROR_INTERVAL" "$WORK_DIR"; then + warn " mirror create/sync FAILED for $_repo" + return 1 + fi + + gitea_mirror_set_interval_api "$GITEA_OWNER" "$_repo" "$GITEA_SUBMODULE_MIRROR_INTERVAL" \ + 2>/dev/null || true + gitea_sync_mirror_api "$GITEA_OWNER" "$_repo" || true + + if [ "$_sha" = "-" ]; then + log " OK mirror -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")" + return 0 + fi + + if gitea_mirror_has_commit "$GITEA_OWNER" "$_repo" "$_sha"; then + log " OK pinned commit present -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")" + return 0 + fi + + warn " mirror ok but pinned $_sha not visible yet — retry: $SCRIPT_DIR/gitea_sync_mirror.sh $_repo" + return 2 +} + +print_gitea_repo_inventory() { + log "Gitea repos for $GITEA_OWNER (API):" + _names="$(gitea_list_repos_api "$GITEA_OWNER" 2>/dev/null)" || _names="" + if [ -z "$_names" ]; then + warn " could not list repos via API (token/CLI)" + return 1 + fi + _n=0 + echo "$_names" | while read -r _r; do + [ -n "$_r" ] || continue + log " - $GITEA_OWNER/$_r" + _n=$((_n + 1)) + done + _count="$(echo "$_names" | grep -c . 2>/dev/null || echo 0)" + log "total listed: $_count (expect android_cast + libvpx + opus + speex under $GITEA_ORG)" +} + +print_fast_sync_guide() { + _sync_sh="$SCRIPT_DIR/gitea_sync_mirror.sh" + _hook="$SCRIPT_DIR/fe-android_cast-post-receive.example" + cat </dev/null 2>&1; then + rc-service gitea status 2>/dev/null | grep -qi started || rc-service gitea start 2>/dev/null || true +fi + +gitea_prune_stale_tmp +if [ -n "$WORK_DIR" ]; then + mkdir -p "$WORK_DIR" +else + WORK_DIR="$(gitea_make_work_dir gitea-migrate)" +fi +trap 'gitea_rm_path "$WORK_DIR"' EXIT INT HUP TERM + +log "ini=$GITEA_APP_INI owner=$GITEA_OWNER ref=$GITEA_REF" + +_main_ok=0 +_main_fail=0 +_sub_ok=0 +_sub_warn=0 +_sub_fail=0 + +if [ "$GITEA_SKIP_MAIN" -eq 0 ]; then + if migrate_main_repo; then + _main_ok=1 + else + _main_fail=1 + fi +else + log "skipping main mirror (--skip-main)" +fi + +if [ "$GITEA_SKIP_SUBMODULES" -eq 0 ]; then + resolve_repo_inputs "$_repo_arg" + log "submodule inputs: repo=$GITEA_REPO_DIR gitmodules=$GITEA_GITMODULES_FILE ref=$GITEA_REF" + _sub_list="$WORK_DIR/submodules.list" + gitea_submodule_collect_entries "$GITEA_REPO_DIR" "$GITEA_REF" \ + "$GITEA_GITMODULES_FILE" "$GITEA_SUBMODULES_CONF" > "$_sub_list" || true + if [ ! -s "$_sub_list" ]; then + die "no external submodules found — check .gitmodules on BE or $GITEA_SUBMODULES_CONF" + fi + log "submodules to mirror:" + while IFS= read -r _preview; do + [ -n "$_preview" ] || continue + log " $_preview" + done < "$_sub_list" + while IFS= read -r _line; do + [ -n "$_line" ] || continue + gitea_submodule_entry_valid "$_line" || continue + _rc=0 + migrate_submodule "$_line" || _rc=$? + case "$_rc" in + 0) _sub_ok=$((_sub_ok + 1)) ;; + 2) _sub_warn=$((_sub_warn + 1)) ;; + *) _sub_fail=$((_sub_fail + 1)) ;; + esac + done < "$_sub_list" +else + log "skipping submodule mirrors (--skip-submodules)" +fi + +log "summary: main ok=$_main_ok fail=$_main_fail | submodules ok=$_sub_ok warn=$_sub_warn fail=$_sub_fail" +print_gitea_repo_inventory +print_fast_sync_guide + +_sub_migrated=$((_sub_ok + _sub_warn)) +[ "$_main_fail" -eq 0 ] && [ "$_sub_fail" -eq 0 ] \ + && { [ "$GITEA_SKIP_SUBMODULES" -eq 1 ] || [ "$_sub_migrated" -gt 0 ]; } diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_lib.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_lib.sh new file mode 100755 index 0000000..91d113c --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_lib.sh @@ -0,0 +1,377 @@ +#!/bin/ash +# Shared pull-mirror helpers for Gitea BE scripts. +# shellcheck shell=sh +# Requires: . "$SCRIPT_DIR/gitea_ini.sh" && gitea_load_config + +gitea_public_repo_url_for() { + _owner="$1" + _repo="$2" + _root="$GITEA_ROOT_URL" + case "$_root" in + */) _root="${_root%/}" ;; + esac + printf '%s/%s/%s.git' "$_root" "$_owner" "$_repo" +} + +gitea_local_git_url_for() { + printf 'http://127.0.0.1:%s/%s/%s.git' "${GITEA_HTTP_PORT:-3000}" "$1" "$2" +} + +gitea_api_base() { + printf '%s' "${GITEA_LOCAL_API:-http://127.0.0.1:3000/api/v1}" +} + +# Remove abandoned gitea-* temp dirs/files (e.g. after kill -9). Default: older than 60 min. +gitea_prune_stale_tmp() { + _min="${GITEA_TMP_MAX_AGE_MIN:-60}" + command -v find >/dev/null 2>&1 || return 0 + find /tmp -maxdepth 1 -type d \ + \( -name 'gitea-migrate-*' -o -name 'gitea-submodules-*' \ + -o -name 'gitea-attach-*' -o -name 'gitea-mirror-*' \) \ + -mmin "+$_min" -exec rm -rf {} + 2>/dev/null || true + find /tmp -maxdepth 1 -type f -name 'gitea-*.json' \ + -mmin "+$_min" -delete 2>/dev/null || true +} + +gitea_make_work_dir() { + _prefix="${1:-gitea-work}" + mktemp -d -p "${TMPDIR:-/tmp}" "${_prefix}.XXXXXX" +} + +gitea_rm_path() { + rm -rf "$1" 2>/dev/null || true +} + +# First numeric "id" from Gitea JSON (handles "id":2 and "id":"2"). +gitea_json_extract_id() { + printf '%s' "$1" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*' +} + +gitea_resolve_push_token() { + if [ -n "${GITEA_ADMIN_TOKEN:-}" ]; then + printf '%s' "$GITEA_ADMIN_TOKEN" + return 0 + fi + # One PAT per script run (re-generating with the same --token-name fails). + if [ -n "${_GITEA_RESOLVED_TOKEN:-}" ]; then + printf '%s' "$_GITEA_RESOLVED_TOKEN" + return 0 + fi + _admin="$(gitea_pick_owner_username)" + [ -n "$_admin" ] || return 1 + _token="" + _token="$(gitea_cli "admin" "user" "generate-access-token" \ + "--username" "$_admin" \ + "--token-name" "mirror-$$-$(date +%s)-${RANDOM:-0}" \ + "--scopes" "all" \ + "--raw" 2>/dev/null)" && [ -n "$_token" ] || return 1 + _GITEA_RESOLVED_TOKEN="$_token" + export _GITEA_RESOLVED_TOKEN GITEA_ADMIN_TOKEN="$_token" + printf '%s' "$_token" +} + +gitea_owner_uid_for() { + gitea_entity_uid_for "$1" +} + +# UID for migrate/API owner — user or organization (not the same as /users/{org}). +gitea_entity_uid_for() { + _owner="$1" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + _json="" + if curl -sS -o /dev/null -w '%{http_code}' "$_api/orgs/$_owner" \ + -H "Authorization: token $_token" | grep -q '^200'; then + _json="$(curl -sS "$_api/orgs/$_owner" -H "Authorization: token $_token")" + else + _json="$(curl -sS "$_api/users/$_owner" -H "Authorization: token $_token")" + fi + _uid="$(gitea_json_extract_id "$_json")" + [ -n "$_uid" ] || return 1 + printf '%s' "$_uid" +} + +gitea_owner_is_org() { + gitea_org_exists "$1" 2>/dev/null +} + +gitea_create_repo_cli() { + _owner="$1" + _repo="$2" + _private="${3:-false}" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + _resp="$(gitea_mktemp_file)" + if gitea_owner_is_org "$_owner"; then + _http="$(curl -sS -o "$_resp" -w '%{http_code}' \ + -X POST "$_api/orgs/$_owner/repos" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"$_repo\",\"private\":$_private}")" + else + _http="$(curl -sS -o "$_resp" -w '%{http_code}' \ + -X POST "$_api/admin/users/$_owner/repos" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"$_repo\",\"private\":$_private}")" + fi + _rc=1 + case "$_http" in + 200|201) _rc=0 ;; + 409) _rc=0 ;; + esac + gitea_rm_path "$_resp" + return "$_rc" +} + +gitea_migrate_mirror_api() { + _owner="$1" + _repo="$2" + _clone_url="$3" + _interval="${4:-10m}" + _private="${5:-false}" + _desc="${6:-Pull mirror of $_clone_url}" + + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _uid="$(gitea_entity_uid_for "$_owner")" || return 1 + + _api="$(gitea_api_base)" + _payload=$(cat <&2 + head -c 400 "$_resp" >&2 || true + printf '\n' >&2 + fi + gitea_rm_path "$_resp" + return 1 +} + +gitea_mirror_push_git() { + _owner="$1" + _repo="$2" + _clone_url="$3" + _work_dir="$4" + + command -v git >/dev/null 2>&1 || return 1 + _mirror="$_work_dir/${_repo}.mirror.git" + _rc=1 + + gitea_rm_path "$_mirror" + if ! git clone --mirror "$_clone_url" "$_mirror"; then + gitea_rm_path "$_mirror" + return 1 + fi + + gitea_create_repo_cli "$_owner" "$_repo" "${GITEA_PRIVATE:-false}" || true + _push_base="$(gitea_local_git_url_for "$_owner" "$_repo")" + if _token="$(gitea_resolve_push_token)"; then + _auth_url="${_push_base#http://}" + if git -C "$_mirror" push --mirror "http://${_token}@${_auth_url}"; then + _rc=0 + fi + fi + + gitea_rm_path "$_mirror" + return "$_rc" +} + +gitea_repo_exists_api() { + _owner="$1" + _repo="$2" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + _http="$(curl -sS -o /dev/null -w '%{http_code}' \ + "$_api/repos/$_owner/$_repo" \ + -H "Authorization: token $_token")" + [ "$_http" = "200" ] +} + +gitea_list_repos_api() { + _owner="$1" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + _list_url="$_api/users/$_owner/repos?limit=50" + _http="$(curl -sS -o /dev/null -w '%{http_code}' "$_api/orgs/$_owner" \ + -H "Authorization: token $_token")" + if [ "$_http" = "200" ]; then + _list_url="$_api/orgs/$_owner/repos?limit=50" + fi + curl -sS "$_list_url" \ + -H "Authorization: token $_token" \ + | awk -F'"' ' + /"name":/ && !/"owner":/ && !/"full_name":/ { + for (i = 1; i <= NF; i++) { + if ($i == "\"name\":" && $(i+1) ~ /^"/) { + gsub(/^"/, "", $(i+1)); gsub(/",?$/, "", $(i+1)) + if ($(i+1) != "" && $(i+1) != "null") print $(i+1) + break + } + } + } + ' +} + +gitea_repo_get_api() { + _owner="$1" + _repo="$2" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + curl -sS "$_api/repos/$_owner/$_repo" \ + -H "Authorization: token $_token" +} + +gitea_repo_set_default_branch_api() { + _owner="$1" + _repo="$2" + _branch="$3" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + _payload="{\"default_branch\":\"$_branch\"}" + _http="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X PATCH "$_api/repos/$_owner/$_repo" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "$_payload")" + case "$_http" in + 200|204) return 0 ;; + *) return 1 ;; + esac +} + +gitea_sync_mirror_api() { + _owner="$1" + _repo="$2" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "$_api/repos/$_owner/$_repo/mirror-sync" \ + -H "Authorization: token $_token" | grep -q '^20' +} + +gitea_mirror_set_interval_api() { + _owner="$1" + _repo="$2" + _interval="$3" + command -v curl >/dev/null 2>&1 || return 1 + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + _api="$(gitea_api_base)" + _payload="{\"mirror_interval\":\"$_interval\"}" + _resp="$(gitea_mktemp_file)" + _http="$(curl -sS -o "$_resp" -w '%{http_code}' \ + -X PATCH "$_api/repos/$_owner/$_repo" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "$_payload")" + gitea_rm_path "$_resp" + case "$_http" in + 200|204) return 0 ;; + *) return 1 ;; + esac +} + +# True if branch exists on any git remote URL (canonical FE git, upstream, etc.). +gitea_git_remote_has_branch() { + _url="$1" + _branch="$2" + command -v git >/dev/null 2>&1 || return 1 + git ls-remote "$_url" "refs/heads/$_branch" 2>/dev/null | grep -q . +} + +# True if commit SHA is reachable on the mirror (after sync). +gitea_mirror_has_commit() { + _owner="$1" + _repo="$2" + _sha="$3" + command -v git >/dev/null 2>&1 || return 1 + _url="$(gitea_local_git_url_for "$_owner" "$_repo")" + git ls-remote "$_url" "$_sha" 2>/dev/null | grep -q "$_sha" +} + +# Mirror one upstream into Gitea (API migrate, else git push). Returns 0 on success. +gitea_mirror_pull_repo() { + _clone_url="$1" + _repo="$2" + _owner="${3:-$GITEA_OWNER}" + _interval="${4:-${GITEA_MIRROR_INTERVAL:-10m}}" + _local_work="" + if [ $# -ge 5 ] && [ -n "$5" ]; then + _work_dir="$5" + else + _local_work="$(gitea_make_work_dir gitea-mirror)" + _work_dir="$_local_work" + fi + + _rc=0 + gitea_migrate_mirror_api "$_owner" "$_repo" "$_clone_url" "$_interval" "${GITEA_PRIVATE:-false}" \ + && return 0 + _api_rc=$? + if [ "$_api_rc" -eq 2 ]; then + if gitea_repo_exists_api "$_owner" "$_repo"; then + gitea_mirror_set_interval_api "$_owner" "$_repo" "$_interval" 2>/dev/null || true + gitea_sync_mirror_api "$_owner" "$_repo" && return 0 + return 0 + fi + printf '[gitea-mirror] migrate rejected for %s/%s but repo missing — trying git push\n' \ + "$_owner" "$_repo" >&2 + fi + if gitea_owner_is_org "$_owner"; then + printf '[gitea-mirror] API migrate failed for org %s/%s — git push fallback disabled (orgs cannot push-to-create)\n' \ + "$_owner" "$_repo" >&2 + printf '[gitea-mirror] Set GITEA_ADMIN_TOKEN or run as root; check [migrations] in app.ini\n' >&2 + [ -n "$_local_work" ] && gitea_rm_path "$_local_work" + return 1 + fi + mkdir -p "$_work_dir" + gitea_mirror_push_git "$_owner" "$_repo" "$_clone_url" "$_work_dir" || _rc=1 + gitea_rm_path "$_work_dir/${_repo}.mirror.git" + [ -n "$_local_work" ] && gitea_rm_path "$_local_work" + return "$_rc" +} diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_submodules.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_submodules.sh new file mode 100755 index 0000000..ef640c0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_submodules.sh @@ -0,0 +1,246 @@ +#!/bin/ash +# +# Mirror android_cast git submodules into Gitea as pull mirrors, then print a +# .gitmodules snippet pointing at Gitea URLs (commit that on canonical git). +# +# Usage (on Alpine BE as root or GITEA RUN_USER): +# sudo ./gitea_mirror_submodules.sh +# sudo ./gitea_mirror_submodules.sh /path/to/android_cast/.gitmodules +# GITEA_SUBMODULES_CONF=./extra.list sudo ./gitea_mirror_submodules.sh +# +# Requires [migrations] allowlist in app.ini for upstream hosts, e.g.: +# ALLOW_LOCALNETWORKS = true +# ALLOWED_DOMAINS = *,github.com,*.github.com,chromium.googlesource.com,git.zx2c4.com +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" +# shellcheck source=gitea_org_lib.sh +. "$SCRIPT_DIR/gitea_org_lib.sh" + +GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}" +GITEA_MIRROR_INTERVAL="${GITEA_MIRROR_INTERVAL:-24h}" +GITEA_PRIVATE="${GITEA_PRIVATE:-false}" +GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}" +WORK_DIR="${WORK_DIR:-}" +SNIPPET_OUT="${SNIPPET_OUT:-$WORK_DIR/gitmodules.gitea-snippet.ini}" + +log() { printf '[gitea-submodules] %s\n' "$*"; } +warn() { printf '[gitea-submodules] WARN: %s\n' "$*" >&2; } +die() { printf '[gitea-submodules] ERROR: %s\n' "$*" >&2; exit 1; } + +# Parse .gitmodules -> repo|url|path|branch lines (repo = basename of path). +parse_gitmodules_file() { + _file="$1" + [ -r "$_file" ] || return 1 + awk ' + function trim(s) { + sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s) + return s + } + /^[ \t]*path[ \t]*=/ { + p = $0; sub(/^[^=]*=/, "", p); path = trim(p) + n = path; sub(".*/", "", n) + next + } + /^[ \t]*url[ \t]*=/ { + u = $0; sub(/^[^=]*=/, "", u); url = trim(u) + next + } + /^[ \t]*branch[ \t]*=/ { + b = $0; sub(/^[^=]*=/, "", b); branch = trim(b) + next + } + /^\[/ { + if (path != "" && url != "" && n != "") { + if (branch != "") print n "|" url "|" path "|" branch + else print n "|" url "|" path + } + path = ""; url = ""; branch = ""; n = "" + } + END { + if (path != "" && url != "" && n != "") { + if (branch != "") print n "|" url "|" path "|" branch + else print n "|" url "|" path + } + } + ' "$_file" +} + +# repo|upstream|path[|branch] — stdout only; status lines go to stderr. +is_valid_submodule_entry() { + case "$1" in + *'|'*) ;; + *) return 1 ;; + esac + _repo="${1%%|*}" + _rest="${1#*|}" + _url="${_rest%%|*}" + _rest2="${_rest#*|}" + _path="${_rest2%%|*}" + [ -n "$_repo" ] && [ -n "$_url" ] && [ -n "$_path" ] || return 1 + case "$_repo" in + *' '*|*'['*|*']'*) return 1 ;; + esac + return 0 +} + +load_submodule_entries() { + _gitmodules="${1:-}" + if [ -n "$_gitmodules" ] && [ -r "$_gitmodules" ]; then + log "reading submodules from $_gitmodules" >&2 + parse_gitmodules_file "$_gitmodules" + return 0 + fi + if [ -r "$GITEA_SUBMODULES_CONF" ]; then + log "reading submodules from $GITEA_SUBMODULES_CONF" >&2 + grep -v '^[[:space:]]*#' "$GITEA_SUBMODULES_CONF" | grep -v '^[[:space:]]*$' + return 0 + fi + return 1 +} + +write_gitmodules_snippet() { + _out="$1" + mkdir -p "$(dirname "$_out")" + : > "$_out" + while read -r line; do + [ -n "$line" ] || continue + _repo="${line%%|*}" + _rest="${line#*|}" + _url="${_rest%%|*}" + _rest2="${_rest#*|}" + _path="${_rest2%%|*}" + _branch="${_rest2#*|}" + case "$_branch" in + "$_path") _branch="" ;; + esac + _gitea_url="$(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")" + { + printf '[submodule "%s"]\n' "$_path" + printf '\tpath = %s\n' "$_path" + printf '\turl = %s\n' "$_gitea_url" + [ -n "$_branch" ] && printf '\tbranch = %s\n' "$_branch" + printf '\n' + } >> "$_out" + done </dev/null || true +GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}" +if ! gitea_owner_exists "$GITEA_OWNER"; then + _fb="$(gitea_pick_owner_username)" + [ -n "$_fb" ] || die "no Gitea owner; set GITEA_OWNER=$GITEA_ORG" + warn "GITEA_OWNER=$GITEA_OWNER not found; using $_fb" + GITEA_OWNER="$_fb" +fi + +gitea_prune_stale_tmp +if [ -n "$WORK_DIR" ]; then + mkdir -p "$WORK_DIR" +else + WORK_DIR="$(gitea_make_work_dir gitea-submodules)" +fi +trap 'gitea_rm_path "$WORK_DIR"' EXIT INT HUP TERM +: > "$WORK_DIR/entries.list" +if ! load_submodule_entries "$_gitmodules_arg" | while IFS= read -r _line; do + [ -n "$_line" ] || continue + is_valid_submodule_entry "$_line" || { + warn "skipping invalid entry: $_line" + continue + } + printf '%s\n' "$_line" +done > "$WORK_DIR/entries.list"; then + die "no submodule list (pass .gitmodules path or use $GITEA_SUBMODULES_CONF)" +fi +[ -s "$WORK_DIR/entries.list" ] || die "no valid submodule entries parsed" + +if command -v rc-service >/dev/null 2>&1; then + rc-service gitea status 2>/dev/null | grep -qi started || rc-service gitea start 2>/dev/null || true +fi + +log "owner=$GITEA_OWNER interval=$GITEA_MIRROR_INTERVAL ini=$GITEA_APP_INI" +_ok=0 +_fail=0 + +while IFS= read -r line; do + [ -n "$line" ] || continue + _repo="${line%%|*}" + _rest="${line#*|}" + _upstream="${_rest%%|*}" + _path="${_rest#*|}" + _path="${_path%%|*}" + + log "mirror $_repo <- $_upstream" + if git ls-remote "$_upstream" HEAD >/dev/null 2>&1; then + log " upstream reachable" + else + warn " upstream not reachable from BE (mirror may still fail)" + fi + + if gitea_mirror_pull_repo "$_upstream" "$_repo" "$GITEA_OWNER" "$GITEA_MIRROR_INTERVAL" "$WORK_DIR"; then + log " OK -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")" + _ok=$((_ok + 1)) + else + warn " FAILED $_repo" + _fail=$((_fail + 1)) + fi +done < "$WORK_DIR/entries.list" + +write_gitmodules_snippet "$SNIPPET_OUT" + +log "mirrors ok=$_ok failed=$_fail" +log "Wrote .gitmodules snippet: $SNIPPET_OUT" +printf '\n' +cat "$SNIPPET_OUT" +printf '\n' + +_main_repo_url="$(gitea_public_repo_url_for "$GITEA_OWNER" "android_cast")" +_rewrite_sh="$SCRIPT_DIR/gitea_submodule_local_rewrite.sh" +cat <&2; } + +gitea_api_token() { + _token="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1 + printf '%s' "$_token" +} + +gitea_org_exists() { + _org="$1" + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + _http="$(curl -sS -o /dev/null -w '%{http_code}' \ + "$_api/orgs/$_org" \ + -H "Authorization: token $_token")" + [ "$_http" = "200" ] +} + +gitea_owner_exists() { + _name="$1" + gitea_org_exists "$_name" && return 0 + gitea_user_exists "$_name" +} + +gitea_org_create_api() { + _org="$1" + _full="${2:-$GITEA_ORG_FULL_NAME}" + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + _payload=$(cat </dev/null; then + return 0 + fi + if gitea_cli "admin" "user" "create" \ + "--username" "$_user" \ + "--email" "$_email" \ + "--random-password" \ + "--must-change-password" 2>/dev/null; then + return 0 + fi + gitea_org_warn "could not create user $_user via CLI — create in Gitea UI" + return 1 +} + +gitea_org_owners_team_id() { + _org="$1" + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + _json="$(curl -sS "$_api/orgs/$_org/teams" -H "Authorization: token $_token")" + _id="$(printf '%s' "$_json" | awk ' + /"name":"Owners"/ || /"name": "Owners"/ { + for (i = 1; i <= NF; i++) if ($i ~ /^"id":/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit } + } + ')" + if [ -z "$_id" ]; then + _id="$(printf '%s' "$_json" | tr ',' '\n' | awk ' + /"name":"Owners"/ { found=1 } + found && /"id":/ { gsub(/[^0-9]/, "", $0); print; exit } + ')" + fi + [ -n "$_id" ] || return 1 + printf '%s' "$_id" +} + +gitea_org_team_has_member() { + _org="$1" + _user="$2" + _team_id="$(gitea_org_owners_team_id "$_org")" || return 1 + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + curl -sS "$_api/teams/$_team_id/members" \ + -H "Authorization: token $_token" \ + | grep -q "\"login\":\"$_user\"" +} + +gitea_team_add_member_api() { + _team_id="$1" + _user="$2" + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + _http="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X PUT "$_api/teams/$_team_id/members/$_user" \ + -H "Authorization: token $_token")" + case "$_http" in + 204|200|201) return 0 ;; + 422) return 0 ;; + *) return 1 ;; + esac +} + +gitea_org_add_owners() { + _org="$1" + _team_id="$(gitea_org_owners_team_id "$_org")" || { + gitea_org_warn "Owners team not found for org $_org" + return 1 + } + for _u in $GITEA_ORG_OWNERS; do + [ -n "$_u" ] || continue + gitea_user_create_if_missing "$_u" || true + if gitea_user_exists "$_u"; then + if gitea_org_team_has_member "$_org" "$_u"; then + gitea_org_log "Owners team: $_u (already member)" + elif gitea_team_add_member_api "$_team_id" "$_u"; then + gitea_org_log "Owners team: $_u (added)" + else + gitea_org_warn "could not add $_u to Owners — add in UI: Org $_org -> Teams -> Owners" + fi + fi + done + return 0 +} + +gitea_transfer_repo_api() { + _from="$1" + _repo="$2" + _to="$3" + command -v curl >/dev/null 2>&1 || return 1 + gitea_repo_exists_api "$_from" "$_repo" || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + _payload="{\"new_owner\":\"$_to\",\"team_ids\":[]}" + _resp="$(gitea_mktemp_file)" + _http="$(curl -sS -o "$_resp" -w '%{http_code}' \ + -X POST "$_api/repos/$_from/$_repo/transfer" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "$_payload")" + _rc=1 + case "$_http" in + 200|201|202) _rc=0 ;; + 409|422) + gitea_repo_exists_api "$_to" "$_repo" && _rc=0 + ;; + esac + gitea_rm_path "$_resp" + return "$_rc" +} + +# Move known project mirrors from GITEA_TRANSFER_FROM → org (no-op if absent). +gitea_org_transfer_legacy_repos() { + _org="$1" + _from="${2:-$GITEA_TRANSFER_FROM}" + [ "$_from" = "$_org" ] && return 0 + for _repo in android_cast libvpx opus speex wireguard-android; do + if gitea_repo_exists_api "$_from" "$_repo"; then + gitea_org_log "transfer $_from/$_repo -> $_org/$_repo" + gitea_transfer_repo_api "$_from" "$_repo" "$_org" \ + && gitea_org_log " transferred" \ + || gitea_org_warn " transfer failed" + fi + done +} + +# Org + Owners team + foxx/admin. Idempotent. +gitea_org_ensure_androidcast() { + _org="${1:-$GITEA_ORG}" + + if gitea_org_exists "$_org"; then + gitea_org_log "org exists: $_org" + else + gitea_org_log "creating org $_org" + gitea_org_create_api "$_org" || { + gitea_org_warn "org create failed — create $_org in Gitea UI first" + return 1 + } + fi + + gitea_org_add_owners "$_org" + gitea_org_transfer_legacy_repos "$_org" "$GITEA_TRANSFER_FROM" + return 0 +} diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_polish_project.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_polish_project.sh new file mode 100755 index 0000000..9b84e3f --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_polish_project.sh @@ -0,0 +1,235 @@ +#!/bin/ash +# +# Safe idempotent Gitea polish: org access (foxx), repo inventory, mirror sync, +# submodule mirrors, default branch next. Does NOT delete repos unless +# GITEA_RECREATE_MAIN_MIRROR=yes (android_cast pull-mirror repair). +# +# Usage (BE as root): +# sudo ./gitea_polish_project.sh +# sudo ./gitea_polish_project.sh /var/www/.../androidcast_project/android_cast +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" +# shellcheck source=gitea_org_lib.sh +. "$SCRIPT_DIR/gitea_org_lib.sh" +# shellcheck source=gitea_submodule_lib.sh +. "$SCRIPT_DIR/gitea_submodule_lib.sh" + +GITEA_ORG="${GITEA_ORG:-AndroidCast}" +GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}" +GITEA_REPO="${GITEA_REPO:-android_cast}" +GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://10.7.0.10/android_cast}" +GITEA_DEFAULT_BRANCH="${GITEA_DEFAULT_BRANCH:-next}" +GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}" +GITEA_RECREATE_MAIN_MIRROR="${GITEA_RECREATE_MAIN_MIRROR:-}" + +log() { printf '[gitea-polish] %s\n' "$*"; } +warn() { printf '[gitea-polish] WARN: %s\n' "$*" >&2; } + +json_bool() { + printf '%s' "$1" | tr -d '\n' | grep -o "\"$2\":[^,}]*" | head -1 | sed 's/.*://;s/ //g' +} + +repo_mirror_status() { + _owner="$1" + _repo="$2" + gitea_repo_exists_api "$_owner" "$_repo" || return 1 + _meta="$(gitea_repo_get_api "$_owner" "$_repo")" + _mirror="$(json_bool "$_meta" "mirror")" + _updated="$(printf '%s' "$_meta" | tr -d '\n' | grep -o '"updated_at":"[^"]*"' | head -1 | sed 's/.*:"//;s/"$//')" + log " $_owner/$_repo mirror=$_mirror updated=$_updated" + [ "$_mirror" = "true" ] +} + +list_owner_repos() { + _owner="$1" + log "repos under $_owner:" + _names="$(gitea_list_repos_api "$_owner" 2>/dev/null)" || _names="" + if [ -z "$(echo "$_names" | tr -d ' ')" ]; then + warn " (none)" + return 1 + fi + echo "$_names" | while read -r _r; do + [ -n "$_r" ] && log " - $_owner/$_r" + done + return 0 +} + +gitea_org_team_members() { + _org="$1" + _team_id="$(gitea_org_owners_team_id "$_org")" || return 1 + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + curl -sS "$_api/teams/$_team_id/members" \ + -H "Authorization: token $_token" \ + | awk -F'"' '/"login":/ { print $4 }' +} + +verify_org_access() { + log "org $GITEA_ORG Owners team members:" + gitea_org_team_members "$GITEA_ORG" | while read -r _u; do + [ -n "$_u" ] && log " - $_u" + done + for _u in $GITEA_ORG_OWNERS; do + if gitea_org_team_members "$GITEA_ORG" | grep -Fx "$_u" >/dev/null 2>&1; then + log "ok: $_u is org Owner" + else + warn "missing: $_u NOT in Owners — re-run gitea_migrate_org_setup.sh" + fi + done +} + +sync_all_mirrors() { + log "trigger mirror sync (all repos)..." + if gitea_cli "admin" "repo-sync-mirrors"; then + log "repo-sync-mirrors OK" + return 0 + fi + warn "repo-sync-mirrors CLI failed — trying per-repo API" + for _owner in "$GITEA_ORG" admin; do + gitea_list_repos_api "$_owner" 2>/dev/null | while read -r _r; do + [ -n "$_r" ] || continue + gitea_sync_mirror_api "$_owner" "$_r" && log " synced $_owner/$_r" \ + || warn " sync failed $_owner/$_r" + done + done +} + +ensure_url_shortener_mirror() { + gitea_repo_exists_api "$GITEA_ORG" url-shortener && { + log "url-shortener mirror present under $GITEA_ORG" + return 0 + } + log "url-shortener missing — running gitea_attach_url_shortener.sh ..." + sh "$SCRIPT_DIR/gitea_attach_url_shortener.sh" \ + || warn "url-shortener attach failed — run: sudo ./gitea_attach_url_shortener.sh" +} + +mirror_submodules_if_missing() { + _checkout="${1:-}" + _repo_dir="" + _gitmodules="" + if [ -n "$_checkout" ] && [ -d "$_checkout" ]; then + _repo_dir="$(cd "$_checkout" && pwd)" + _gitmodules="$_repo_dir/.gitmodules" + fi + + _need=0 + for _sub in libvpx opus speex wireguard-android; do + gitea_repo_exists_api "$GITEA_ORG" "$_sub" && continue + _need=1 + break + done + [ "$_need" -eq 1 ] || { + log "submodule mirrors present under $GITEA_ORG" + return 0 + } + + log "creating missing submodule mirrors under $GITEA_ORG..." + _list="$(gitea_mktemp_file)" + gitea_submodule_collect_entries "$_repo_dir" HEAD "$_gitmodules" "$GITEA_SUBMODULES_CONF" > "$_list" + while IFS= read -r _line; do + [ -n "$_line" ] || continue + _name="${_line%%|*}" + _rest="${_line#*|}" + _url="${_rest%%|*}" + log " mirror $_name <- $_url" + gitea_mirror_pull_repo "$_url" "$_name" "$GITEA_ORG" "24h" "/tmp/gitea-polish-$$" \ + && gitea_sync_mirror_api "$GITEA_ORG" "$_name" || warn " failed $_name" + gitea_rm_path "/tmp/gitea-polish-$$/${_name}.mirror.git" + done < "$_list" + gitea_rm_path "$_list" +} + +recreate_main_pull_mirror_if_asked() { + if [ "$GITEA_RECREATE_MAIN_MIRROR" != "yes" ]; then + return 0 + fi + warn "GITEA_RECREATE_MAIN_MIRROR=yes — delete $GITEA_ORG/$GITEA_REPO and re-attach" + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + curl -sS -o /dev/null -X DELETE "$_api/repos/$GITEA_ORG/$GITEA_REPO" \ + -H "Authorization: token $_token" || true + GITEA_OWNER="$GITEA_ORG" "$SCRIPT_DIR/gitea_attach_remote.sh" "$GITEA_MIRROR_URL" +} + +# --- main --- + +_checkout="${1:-}" +if [ -d "./.git" ] || [ -f "./.git" ]; then + _checkout="${_checkout:-$(pwd)}" +fi + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +log "=== Gitea polish (safe) ===" +log "ini=$GITEA_APP_INI org=$GITEA_ORG" + +# 1) Org + foxx Owners +gitea_org_ensure_androidcast "$GITEA_ORG" || warn "org setup incomplete" +verify_org_access + +# 2) Inventory +list_owner_repos "$GITEA_ORG" || true +list_owner_repos admin || true + +# 3) Main mirror health +log "main repo mirror status:" +_main_ok=0 +if repo_mirror_status "$GITEA_ORG" "$GITEA_REPO"; then + _main_ok=1 +elif repo_mirror_status "admin" "$GITEA_REPO"; then + warn "android_cast still under admin — transfer to org:" + warn " sudo ./gitea_migrate_org_setup.sh" + _main_ok=1 +else + warn "no pull mirror for $GITEA_REPO — auto-sync disabled" + warn "fix (destructive): GITEA_RECREATE_MAIN_MIRROR=yes sudo ./gitea_polish_project.sh" + warn " or delete $GITEA_ORG/$GITEA_REPO in UI then: sudo ./gitea_attach_remote.sh $GITEA_MIRROR_URL" +fi + +recreate_main_pull_mirror_if_asked + +# 4) Manual sync now +sync_all_mirrors + +# 5) Submodules +mirror_submodules_if_missing "$_checkout" + +# 5b) First-party submodule url-shortener (local git:// — not gitea_mirror_submodules) +ensure_url_shortener_mirror + +# 6) Default branch +if command -v "$SCRIPT_DIR/gitea_set_default_branch.sh" >/dev/null 2>&1; then + GITEA_OWNER="$GITEA_ORG" GITEA_DEFAULT_BRANCH="$GITEA_DEFAULT_BRANCH" \ + sh "$SCRIPT_DIR/gitea_set_default_branch.sh" || warn "default branch set failed (sync first)" +fi + +# 7) Branch protection (master, next) +if [ "${GITEA_SKIP_BRANCH_PROTECT:-0}" -eq 0 ]; then + sh "$SCRIPT_DIR/gitea_protect_branches.sh" || warn "branch protection failed — run manually" +fi + +# 8) Summary +log "=== done ===" +cat <&2; } +die() { printf '[gitea-protect] ERROR: %s\n' "$*" >&2; exit 1; } + +gitea_api_token() { + _t="${GITEA_ADMIN_TOKEN:-}" + [ -n "$_t" ] || _t="$(gitea_resolve_push_token)" || return 1 + printf '%s' "$_t" +} + +gitea_list_branch_protections() { + command -v curl >/dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + curl -sS "$_api/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections" \ + -H "Authorization: token $_token" +} + +gitea_protection_exists() { + _branch="$1" + gitea_list_branch_protections 2>/dev/null | grep -q "\"branch_name\":\"$_branch\"" +} + +gitea_protect_payload() { + _branch="$1" + _prio="$2" + _users_json="" + for _u in $GITEA_PROTECT_PUSH_USERS; do + [ -n "$_u" ] || continue + _users_json="$_users_json\"$_u\"," + done + _users_json="${_users_json%,}" + + cat </dev/null 2>&1 || return 1 + _token="$(gitea_api_token)" || return 1 + _api="$(gitea_api_base)" + _payload="$(gitea_protect_payload "$_branch" "$_prio")" + _resp="$(gitea_mktemp_file)" + + if gitea_protection_exists "$_branch"; then + log "update protection: $GITEA_OWNER/$GITEA_REPO::$_branch" + _http="$(curl -sS -o "$_resp" -w '%{http_code}' \ + -X PATCH "$_api/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections/$_branch" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "$_payload")" + else + log "create protection: $GITEA_OWNER/$GITEA_REPO::$_branch" + _http="$(curl -sS -o "$_resp" -w '%{http_code}' \ + -X POST "$_api/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections" \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -d "$_payload")" + fi + + case "$_http" in + 200|201|204) gitea_rm_path "$_resp"; return 0 ;; + *) + warn "HTTP $_http for $_branch:" + head -c 500 "$_resp" >&2 2>/dev/null || true + printf '\n' >&2 + gitea_rm_path "$_resp" + return 1 + ;; + esac +} + +list_protections() { + gitea_load_config || exit 1 + gitea_ensure_cli_access || exit 1 + log "branch protections: $GITEA_OWNER/$GITEA_REPO" + gitea_list_branch_protections | tr ',' '\n' | grep -E 'branch_name|enable_force_push|enable_push' || true +} + +# --- main --- + +case "${1:-}" in + -h|--help|help) + sed -n '2,16p' "$0" + exit 0 + ;; + --list|list) + list_protections + exit 0 + ;; +esac + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +if ! gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO"; then + die "repo $GITEA_OWNER/$GITEA_REPO not found — run gitea_polish_project.sh first" +fi + +log "repo=$GITEA_OWNER/$GITEA_REPO branches=$GITEA_PROTECT_BRANCHES" +log "push allowlist: users=$GITEA_PROTECT_PUSH_USERS team=$GITEA_PROTECT_PUSH_TEAM" +log "force-push: disabled for all | side branches: unprotected" + +_prio=1 +_fail=0 +for _b in $GITEA_PROTECT_BRANCHES; do + gitea_apply_protection "$_b" "$_prio" || _fail=1 + _prio=$((_prio + 1)) +done + +cat <&2; } + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +_repos="" +if [ -n "$GITEA_REPO_NAMES" ]; then + _repos="$GITEA_REPO_NAMES" +else + _repos="$(gitea_list_repos_api "$GITEA_OWNER" 2>/dev/null)" || _repos="" + if [ -z "$(echo "$_repos" | tr -d ' ')" ] && command -v sqlite3 >/dev/null 2>&1; then + _repos="$(sqlite3 "${GITEA_APP_DATA:-/var/lib/gitea}/gitea.db" \ + "SELECT r.lower_name FROM repository r JOIN user u ON r.owner_id=u.id WHERE u.lower_name='$(printf '%s' "$GITEA_OWNER" | tr '[:upper:]' '[:lower:]')' ORDER BY r.lower_name;" 2>/dev/null)" + fi +fi + +if [ -z "$(echo "$_repos" | tr -d ' ')" ]; then + warn "no repos under $GITEA_OWNER" + exit 1 +fi + +_ok=0 +_fail=0 +for _repo in $_repos; do + [ -n "$_repo" ] || continue + if gitea_repo_set_default_branch_api "$GITEA_OWNER" "$_repo" "$GITEA_DEFAULT_BRANCH"; then + log "$GITEA_OWNER/$_repo -> default branch $GITEA_DEFAULT_BRANCH" + _ok=$((_ok + 1)) + else + warn "failed $GITEA_OWNER/$_repo (branch $GITEA_DEFAULT_BRANCH may not exist yet — sync mirror first)" + _fail=$((_fail + 1)) + fi +done + +log "done ok=$_ok fail=$_fail" +[ "$_fail" -eq 0 ] diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_lib.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_lib.sh new file mode 100644 index 0000000..a2b5136 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_lib.sh @@ -0,0 +1,197 @@ +#!/bin/ash +# Discover git submodules (upstream URL + pinned SHA) from a checkout. +# shellcheck shell=sh + +# Space-separated host/prefix patterns treated as "ours" (skipped for external mirrors). +GITEA_LOCAL_URL_PATTERNS="${GITEA_LOCAL_URL_PATTERNS:-10.7.0.10 10.7.16. apps.f0xx.org f0xx.org 127.0.0.1 localhost}" + +gitea_submodule_is_local_url() { + _url="$1" + case "$_url" in + /*|./*|../*) return 0 ;; + esac + for _pat in $GITEA_LOCAL_URL_PATTERNS; do + case "$_url" in + *"$_pat"*) return 0 ;; + esac + done + return 1 +} + +gitea_submodule_is_external_url() { + gitea_submodule_is_local_url "$1" && return 1 + return 0 +} + +# .gitmodules -> repo|url|path|branch (repo = basename of path). stdout only. +gitea_submodule_parse_gitmodules() { + _file="$1" + [ -r "$_file" ] || return 1 + awk ' + function trim(s) { + sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s) + return s + } + /^[ \t]*path[ \t]*=/ { + p = $0; sub(/^[^=]*=/, "", p); path = trim(p) + n = path; sub(".*/", "", n) + next + } + /^[ \t]*url[ \t]*=/ { + u = $0; sub(/^[^=]*=/, "", u); url = trim(u) + next + } + /^[ \t]*branch[ \t]*=/ { + b = $0; sub(/^[^=]*=/, "", b); branch = trim(b) + next + } + /^\[/ { + if (path != "" && url != "" && n != "") { + if (branch != "") print n "|" url "|" path "|" branch + else print n "|" url "|" path + } + path = ""; url = ""; branch = ""; n = "" + } + END { + if (path != "" && url != "" && n != "") { + if (branch != "") print n "|" url "|" path "|" branch + else print n "|" url "|" path + } + } + ' "$_file" +} + +# repo|url|path|sha[|branch] — external entries from .gitmodules (sha may be "-" if unknown). +gitea_submodule_discover_from_gitmodules() { + _gitmodules="$1" + _repo_dir="${2:-}" + _ref="${3:-HEAD}" + [ -r "$_gitmodules" ] || return 1 + + gitea_submodule_parse_gitmodules "$_gitmodules" | while IFS= read -r _line; do + [ -n "$_line" ] || continue + _repo="${_line%%|*}" + _rest="${_line#*|}" + _url="${_rest%%|*}" + _rest2="${_rest#*|}" + _path="${_rest2%%|*}" + _branch="${_rest2#*|}" + case "$_branch" in + "$_path") _branch="" ;; + esac + + gitea_submodule_is_external_url "$_url" || continue + + _sha="-" + if [ -n "$_repo_dir" ] && command -v git >/dev/null 2>&1 \ + && git -C "$_repo_dir" rev-parse --verify "$_ref" >/dev/null 2>&1; then + _got="$(git -C "$_repo_dir" rev-parse "$_ref:$_path" 2>/dev/null)" || _got="" + case "$_got" in + ??000000000000000000000000000000000000) _sha="-" ;; + ????????????????????????????????????????*) _sha="$_got" ;; + esac + fi + + if [ -n "$_branch" ]; then + printf '%s|%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha" "$_branch" + else + printf '%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha" + fi + done +} + +# repo|url|path|sha[|branch] for each external submodule with a gitlink at REF. +gitea_submodule_discover_external() { + _repo_dir="$1" + _ref="${2:-HEAD}" + _gitmodules="$_repo_dir/.gitmodules" + + command -v git >/dev/null 2>&1 || return 1 + git -C "$_repo_dir" rev-parse --verify "$_ref" >/dev/null 2>&1 || return 1 + [ -r "$_gitmodules" ] || return 1 + + gitea_submodule_discover_from_gitmodules "$_gitmodules" "$_repo_dir" "$_ref" \ + | while IFS= read -r _line; do + _sha="${_line##*|}" + case "$_sha" in + -) continue ;; + esac + printf '%s\n' "$_line" + done +} + +# repo|url|path|sha[|branch] from submodules.defaults.conf (sha resolved when possible). +gitea_submodule_load_defaults() { + _conf="$1" + _repo_dir="${2:-}" + _ref="${3:-HEAD}" + [ -r "$_conf" ] || return 1 + + grep -v '^[[:space:]]*#' "$_conf" | grep -v '^[[:space:]]*$' | while IFS= read -r _line; do + [ -n "$_line" ] || continue + _repo="${_line%%|*}" + _rest="${_line#*|}" + _url="${_rest%%|*}" + _rest2="${_rest#*|}" + _path="${_rest2%%|*}" + _branch="${_rest2#*|}" + case "$_branch" in + "$_path") _branch="" ;; + esac + gitea_submodule_is_external_url "$_url" || continue + _sha="-" + if [ -n "$_repo_dir" ] && command -v git >/dev/null 2>&1 \ + && git -C "$_repo_dir" rev-parse --verify "$_ref" >/dev/null 2>&1; then + _got="$(git -C "$_repo_dir" rev-parse "$_ref:$_path" 2>/dev/null)" || _got="" + case "$_got" in + ????????????????????????????????????????*) _sha="$_got" ;; + esac + fi + if [ -n "$_branch" ]; then + printf '%s|%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha" "$_branch" + else + printf '%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha" + fi + done +} + +# Merge discovery sources; first win per repo name. stdout: repo|url|path|sha[|branch] +gitea_submodule_collect_entries() { + _repo_dir="${1:-}" + _ref="${2:-HEAD}" + _gitmodules="${3:-}" + _defaults="${4:-}" + + _work="$(gitea_mktemp_file)" + + if [ -n "$_gitmodules" ] && [ -r "$_gitmodules" ]; then + gitea_submodule_discover_from_gitmodules "$_gitmodules" "$_repo_dir" "$_ref" >> "$_work" 2>/dev/null || true + elif [ -n "$_repo_dir" ] && [ -r "$_repo_dir/.gitmodules" ]; then + gitea_submodule_discover_from_gitmodules "$_repo_dir/.gitmodules" "$_repo_dir" "$_ref" >> "$_work" 2>/dev/null || true + fi + + # Use defaults only when .gitmodules produced nothing (never merge extras on top). + if [ ! -s "$_work" ] && [ -n "$_defaults" ] && [ -r "$_defaults" ]; then + gitea_submodule_load_defaults "$_defaults" "$_repo_dir" "$_ref" >> "$_work" 2>/dev/null || true + fi + + awk -F'|' '!seen[$1]++' "$_work" + rm -f "$_work" +} + +gitea_submodule_entry_valid() { + case "$1" in + *'|'*) ;; + *) return 1 ;; + esac + _repo="${1%%|*}" + _rest="${1#*|}" + _url="${_rest%%|*}" + _rest2="${_rest#*|}" + _path="${_rest2%%|*}" + [ -n "$_repo" ] && [ -n "$_url" ] && [ -n "$_path" ] || return 1 + case "$_repo" in + *' '*|*'['*|*']'*) return 1 ;; + esac + return 0 +} diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_local_rewrite.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_local_rewrite.sh new file mode 100755 index 0000000..2946186 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_local_rewrite.sh @@ -0,0 +1,156 @@ +#!/bin/ash +# +# Point submodule fetches at Gitea mirrors without changing committed .gitmodules. +# Rewrites are stored in .git/config (per clone) or optional --global insteadOf rules. +# +# Usage (inside android_cast checkout, or pass repo path): +# ./gitea_submodule_local_rewrite.sh # print commands +# ./gitea_submodule_local_rewrite.sh --apply # write .git/config +# ./gitea_submodule_local_rewrite.sh --apply --global # url.insteadOf in ~/.gitconfig +# ./gitea_submodule_local_rewrite.sh .gitmodules # read paths/URLs from file +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GITEA_OWNER="${GITEA_OWNER:-AndroidCast}" +GITEA_ROOT_URL="${GITEA_ROOT_URL:-https://apps.f0xx.org/app/androidcast_project/git}" +GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}" + +log() { printf '[gitea-submodule-rewrite] %s\n' "$*"; } +die() { printf '[gitea-submodule-rewrite] ERROR: %s\n' "$*" >&2; exit 1; } + +gitea_public_repo_url_for() { + _owner="$1" + _repo="$2" + _root="$GITEA_ROOT_URL" + case "$_root" in + */) _root="${_root%/}" ;; + esac + printf '%s/%s/%s.git' "$_root" "$_owner" "$_repo" +} + +parse_gitmodules_file() { + _file="$1" + [ -r "$_file" ] || return 1 + awk ' + function trim(s) { + sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s) + return s + } + /^[ \t]*path[ \t]*=/ { + p = $0; sub(/^[^=]*=/, "", p); path = trim(p) + n = path; sub(".*/", "", n) + next + } + /^[ \t]*url[ \t]*=/ { + u = $0; sub(/^[^=]*=/, "", u); url = trim(u) + next + } + /^\[/ { + if (path != "" && url != "" && n != "") print n "|" url "|" path + path = ""; url = ""; n = "" + } + END { + if (path != "" && url != "" && n != "") print n "|" url "|" path + } + ' "$_file" +} + +load_entries() { + _gitmodules="${1:-}" + if [ -n "$_gitmodules" ] && [ -r "$_gitmodules" ]; then + log "reading $_gitmodules" >&2 + parse_gitmodules_file "$_gitmodules" + return 0 + fi + if [ -r "$GITEA_SUBMODULES_CONF" ]; then + log "reading $GITEA_SUBMODULES_CONF" >&2 + grep -v '^[[:space:]]*#' "$GITEA_SUBMODULES_CONF" | grep -v '^[[:space:]]*$' \ + | awk -F'|' 'NF >= 3 { print $1 "|" $2 "|" $3 }' + return 0 + fi + return 1 +} + +_apply=0 +_global=0 +_gitmodules_arg="" +_repo_dir="" + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + sed -n '2,14p' "$0" + exit 0 + ;; + --apply) _apply=1; shift ;; + --global) _global=1; shift ;; + -*) + die "unknown option: $1" + ;; + *) + if [ -r "$1" ] || [ -f "$1" ]; then + _gitmodules_arg="$1" + else + _repo_dir="$1" + fi + shift + ;; + esac +done + +if [ -z "$_repo_dir" ]; then + if git rev-parse --show-toplevel >/dev/null 2>&1; then + _repo_dir="$(git rev-parse --show-toplevel)" + else + _repo_dir="." + fi +fi + +if [ -z "$_gitmodules_arg" ] && [ -r "$_repo_dir/.gitmodules" ]; then + _gitmodules_arg="$_repo_dir/.gitmodules" +fi + +_entries="$(gitea_mktemp_file)" +trap 'rm -f "$_entries"' EXIT INT HUP TERM +load_entries "$_gitmodules_arg" > "$_entries" || die "no submodule list" + +if [ ! -s "$_entries" ]; then + die "no submodule entries parsed" +fi + +log "Gitea base: $GITEA_ROOT_URL owner=$GITEA_OWNER repo=$_repo_dir" +printf '\n' + +while IFS= read -r line; do + [ -n "$line" ] || continue + _repo="${line%%|*}" + _rest="${line#*|}" + _upstream="${_rest%%|*}" + _path="${_rest#*|}" + _gitea_url="$(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")" + + if [ "$_global" -eq 1 ]; then + _cmd="git config --global url.$_gitea_url.insteadOf $_upstream" + printf '%s\n' "$_cmd" + if [ "$_apply" -eq 1 ]; then + git config --global "url.$_gitea_url.insteadOf" "$_upstream" + fi + else + _cmd="git -C \"$_repo_dir\" config submodule.$_path.url $_gitea_url" + printf '%s\n' "$_cmd" + if [ "$_apply" -eq 1 ]; then + git -C "$_repo_dir" config "submodule.$_path.url" "$_gitea_url" + fi + fi +done < "$_entries" + +if [ "$_apply" -eq 1 ] && [ "$_global" -eq 0 ]; then + git -C "$_repo_dir" submodule sync --recursive + log "applied; run: git -C $_repo_dir submodule update --init --recursive" +else + printf '\n' + log "To apply in this clone: $0 --apply" + log "Or machine-wide URL rewrite: $0 --apply --global" + log "Then: git submodule update --init --recursive" +fi diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_sync_mirror.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_sync_mirror.sh new file mode 100755 index 0000000..cbf9f32 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_sync_mirror.sh @@ -0,0 +1,63 @@ +#!/bin/ash +# +# Trigger Gitea pull-mirror sync (one repo or all). For BE cron / FE post-receive hook. +# +# Usage: +# sudo ./gitea_sync_mirror.sh android_cast +# sudo ./gitea_sync_mirror.sh android_cast libvpx opus +# sudo ./gitea_sync_mirror.sh --all +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" +# shellcheck source=gitea_mirror_lib.sh +. "$SCRIPT_DIR/gitea_mirror_lib.sh" + +GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}" + +log() { printf '[gitea-sync] %s\n' "$*"; } +warn() { printf '[gitea-sync] WARN: %s\n' "$*" >&2; } +die() { printf '[gitea-sync] ERROR: %s\n' "$*" >&2; exit 1; } + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +_sync_all=0 +_repos="" + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + sed -n '2,10p' "$0" + exit 0 + ;; + --all) _sync_all=1; shift ;; + -*) die "unknown option: $1" ;; + *) _repos="$_repos $1"; shift ;; + esac +done + +if [ "$_sync_all" -eq 1 ]; then + log "sync all mirrors via gitea admin repo-sync-mirrors" + gitea_cli "admin" "repo-sync-mirrors" + exit $? +fi + +if [ -z "$(echo "$_repos" | tr -d ' ')" ]; then + _repos="android_cast" +fi + +_fail=0 +for _repo in $_repos; do + log "mirror-sync $GITEA_OWNER/$_repo" + if gitea_sync_mirror_api "$GITEA_OWNER" "$_repo"; then + log " OK" + else + warn " FAILED" + _fail=1 + fi +done + +exit "$_fail" diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_user_admin.sh b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_user_admin.sh new file mode 100755 index 0000000..3b1c859 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_user_admin.sh @@ -0,0 +1,49 @@ +#!/bin/ash +# +# Gitea user admin helpers (foxx pre-created by migrate — not self-registration). +# +# Usage: +# sudo ./gitea_user_admin.sh list +# sudo ./gitea_user_admin.sh reset-password foxx 'NewSecurePassword' +# sudo ./gitea_user_admin.sh must-change-password foxx +# +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=gitea_ini.sh +. "$SCRIPT_DIR/gitea_ini.sh" + +log() { printf '[gitea-user] %s\n' "$*"; } +die() { printf '[gitea-user] ERROR: %s\n' "$*" >&2; exit 1; } + +gitea_load_config || exit 1 +gitea_ensure_cli_access || exit 1 + +_cmd="${1:-}" +_user="${2:-}" +_pass="${3:-}" + +case "$_cmd" in + list) + gitea_cli "admin" "user" "list" + ;; + reset-password) + [ -n "$_user" ] && [ -n "$_pass" ] || die "usage: $0 reset-password USER PASSWORD" + gitea_cli "admin" "user" "change-password" \ + "--username" "$_user" \ + "--password" "$_pass" + log "password set for $_user (use Sign In, not Register)" + ;; + must-change-password) + [ -n "$_user" ] || die "usage: $0 must-change-password USER" + gitea_cli "admin" "user" "must-change-password" \ + "--username" "$_user" + log "$_user must change password on next login" + ;; + -h|--help|help) + sed -n '2,12p' "$0" + ;; + *) + die "unknown command: $_cmd (try --help)" + ;; +esac diff --git a/sim/cluster0/lab-seeds/backend/scripts/gitea/submodules.defaults.conf b/sim/cluster0/lab-seeds/backend/scripts/gitea/submodules.defaults.conf new file mode 100644 index 0000000..a1cefbe --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/gitea/submodules.defaults.conf @@ -0,0 +1,12 @@ +# Gitea pull mirrors for android_cast git submodules. +# Format: repo_name|upstream_clone_url|submodule_path[|branch] +# Lines starting with # are ignored. Add local git:// entries at the bottom. + +# Fallback only when .gitmodules is missing on BE. Canonical list: .gitmodules in repo. +libvpx|https://chromium.googlesource.com/webm/libvpx|third-party/libvpx|main +opus|https://github.com/xiph/opus|third-party/opus +speex|https://github.com/xiph/speex/|third-party/speex +wireguard-android|https://git.zx2c4.com/wireguard-android|third-party/wireguard-android + +# Example local submodule (uncomment and set your git daemon URL): +# mylib|git://10.7.0.10/mylib|third-party/mylib diff --git a/sim/cluster0/lab-seeds/backend/scripts/ingest_alpha_smoke_tickets.php b/sim/cluster0/lab-seeds/backend/scripts/ingest_alpha_smoke_tickets.php new file mode 100644 index 0000000..70dd4f1 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ingest_alpha_smoke_tickets.php @@ -0,0 +1,162 @@ +#!/usr/bin/env php +/tmp/alpha_smoke_round1.txt (project root, not examples/). + */ +declare(strict_types=1); + +// scripts → backend → crash_reporter → examples → repo root +$repoRoot = dirname(__DIR__, 4); +$defaultFile = $repoRoot . '/tmp/alpha_smoke_round1.txt'; +$opts = getopt('', ['file::', 'url::', 'owner::', 'dry-run', 'device::', 'app-version::']); + +$file = $opts['file'] ?? $defaultFile; +$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/crashes', '/'); +$owner = $opts['owner'] ?? 'admin'; +$dryRun = isset($opts['dry-run']); +$uploadUrl = $base . '/api/ticket_upload.php'; + +if (!is_readable($file)) { + fwrite(STDERR, "File not found: $file\n"); + exit(1); +} + +$lines = file($file, FILE_IGNORE_NEW_LINES); +$rows = []; +foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, 'block |')) { + continue; + } + $parts = array_map('trim', explode('|', $line, 3)); + if (count($parts) < 3) { + continue; + } + [$block, $expected, $actual] = $parts; + $rows[] = compact('block', 'expected', 'actual'); +} + +$device = [ + 'manufacturer' => 'DOOGEE', + 'brand' => 'DOOGEE', + 'model' => $opts['device'] ?? 'BL6000 Pro', + 'sdk_int' => 29, + 'release' => '10', + 'abis' => ['arm64-v8a', 'armeabi-v7a'], +]; +$app = [ + 'package' => 'com.foxx.androidcast', + 'version_name' => $opts['app-version'] ?? '0.1.0-alpha', + 'version_code' => 1, +]; + +function classify_row(array $row): array { + $actual = $row['actual']; + $pass = (bool) preg_match('/^\s*pass\b/i', $actual) && !preg_match('/\bNOK\b/i', $actual); + $needsTicket = !$pass + || preg_match('/needs review|remark|poor|low-res|frame drop|lifecycle|VU-meter|laggy|wrong/i', $actual); + if (!$needsTicket) { + return ['skip' => true]; + } + $status = 'open'; + $rating = 3; + if (preg_match('/\bNOK\b/i', $actual)) { + $status = 'urgent'; + $rating = 4; + } elseif ($pass) { + $status = 'confirmed'; + $rating = 2; + } + if (preg_match('/needs review/i', $actual)) { + $status = 'in-progress'; + } + return ['skip' => false, 'status' => $status, 'rating' => $rating]; +} + +function ticket_tags(string $status): array { + $map = [ + 'open' => ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'], + 'in-progress' => ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3b82f6'], + 'confirmed' => ['id' => 'confirmed', 'label' => 'confirmed', 'bg' => '#047857'], + 'urgent' => ['id' => 'urgent', 'label' => 'urgent', 'bg' => '#dc2626'], + 'closed' => ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280'], + ]; + $tags = [ + ['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'], + ['id' => 'alpha-smoke', 'label' => 'alpha-smoke', 'bg' => '#8b5cf6'], + $map[$status] ?? $map['open'], + ]; + return $tags; +} + +$now = (int) round(microtime(true) * 1000); +$ok = 0; +$skip = 0; +$fail = 0; + +foreach ($rows as $row) { + $class = classify_row($row); + if (!empty($class['skip'])) { + $skip++; + continue; + } + $block = $row['block']; + $title = 'Alpha smoke ' . $block . ': ' . mb_substr($row['expected'], 0, 80); + $brief = mb_substr($row['actual'], 0, 240); + $body = "Block: {$block}\n\nExpected:\n{$row['expected']}\n\nActual:\n{$row['actual']}\n\nSource: alpha_smoke_round1.txt"; + $payload = [ + 'schema_version' => 1, + 'ingest_kind' => 'ticket', + 'ticket_id' => 'alpha-smoke-' . strtolower($block) . '-' . substr(sha1($body), 0, 12), + 'title' => $title, + 'brief' => $brief, + 'body' => $body, + 'opened_at_epoch_ms' => $now, + 'rating' => $class['rating'], + 'owner' => $owner, + 'verified_by' => '', + 'tags' => ticket_tags($class['status']), + 'device' => $device, + 'app' => $app, + 'alpha_block' => $block, + 'source_file' => basename($file), + 'source_excerpt' => $body, + ]; + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($dryRun) { + echo "DRY $block → {$payload['ticket_id']} [{$class['status']}]\n"; + $ok++; + continue; + } + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $json, + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents($uploadUrl, false, $ctx); + $code = 0; + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $code = (int) $m[1]; + } + if ($code === 200) { + echo "OK $block\n"; + $ok++; + } elseif ($code === 409) { + echo "DUP $block\n"; + $ok++; + } else { + echo "FAIL $block HTTP $code — $resp\n"; + $fail++; + } +} + +echo "Done: uploaded=$ok skipped=$skip failed=$fail\n"; +exit($fail > 0 ? 1 : 0); diff --git a/sim/cluster0/lab-seeds/backend/scripts/ingest_priorities_20260608.php b/sim/cluster0/lab-seeds/backend/scripts/ingest_priorities_20260608.php new file mode 100644 index 0000000..17e673f --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ingest_priorities_20260608.php @@ -0,0 +1,104 @@ +#!/usr/bin/env php + 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'], + ['id' => '20260608', 'label' => '20260608', 'bg' => '#0ea5e9'], + ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'], + ['id' => $ownerTag, 'label' => $ownerTag, 'bg' => $ownerTag === 'dev' ? '#f59e0b' : '#3b82f6'], + ]; +} + +$tasks = [ + ['key' => '0.1', 'owner' => 'agent', 'text' => 'P0 FR: Stream dump while casting — zip + meta.json + raw A/V; see docs/20260608_STREAM_DUMP_DEBUG.md. Cyclic with 6.6.'], + ['key' => '1', 'owner' => 'agent', 'text' => 'P1 Graphs dashboard parent — fixes + FRs.'], + ['key' => '1.1', 'owner' => 'agent', 'text' => 'P1.1 Graphs: all pending fixes (drill-down, perf, permalinks).'], + ['key' => '1.2', 'owner' => 'agent', 'text' => 'P1.2 Graphs: all pending FRs (columns 1/2/4, full-width grid).'], + ['key' => '2.1', 'owner' => 'dev', 'text' => 'P2.1 DEV: DNS MX/SPF/DKIM/DMARC for apps.f0xx.org — tmp/20260608_help_request.md'], + ['key' => '2.2', 'owner' => 'dev', 'text' => 'P2.2 DEV: Email forward info@/admin@/root@ → Gmail.'], + ['key' => '2.3', 'owner' => 'dev', 'text' => 'P2.3 DEV: Verify inbound mail to info@apps.f0xx.org.'], + ['key' => '2.4', 'owner' => 'dev', 'text' => 'P2.4 DEV: Gmail filters / reply-as optional.'], + ['key' => '2.5', 'owner' => 'agent', 'text' => 'P2.5 AGENT: BE outbound SMTP for verification emails (blocked on 2.1).'], + ['key' => '3', 'owner' => 'agent', 'text' => 'P3 AGENT: Auth register + verify + TOTP + migration 008 — docs/20260607-2FA-email-mobile-auth-flow.md'], + ['key' => '4', 'owner' => 'agent', 'text' => 'P4 AGENT: Reverse SSH alpha — replace WG for prod; mobile + BE; hidden no VPN.'], + ['key' => '5.1', 'owner' => 'shared', 'text' => 'P5.1 OTA channel + artifacts — 50% dev / 50% agent.'], + ['key' => '5.2', 'owner' => 'shared', 'text' => 'P5.2 Device crash/OTA URLs — 50% dev / 50% agent.'], + ['key' => '5.3', 'owner' => 'agent', 'text' => 'P5.3 RSSH BE validation — depends on 4.x.'], + ['key' => '5.4', 'owner' => 'agent', 'text' => 'P5.4 auth-2fa validation ALPHA G2–G6.'], + ['key' => '5.5', 'owner' => 'agent', 'text' => 'P5.5 RBAC validation.'], + ['key' => '6.1', 'owner' => 'dev', 'text' => 'P6.1 Opus/Speex E2E — dev + agent JNI.'], + ['key' => '6.2', 'owner' => 'dev', 'text' => 'P6.2 LAN soak A/B/C.'], + ['key' => '6.3', 'owner' => 'dev', 'text' => 'P6.3 App lifecycle notifications/recents.'], + ['key' => '6.4', 'owner' => 'dev', 'text' => 'P6.4 Rotation + preview crash — cyclic with 6.3.'], + ['key' => '6.5', 'owner' => 'dev', 'text' => 'P6.5 Audio lag — use 0.1 dumps for analysis.'], + ['key' => '6.6', 'owner' => 'dev', 'text' => 'P6.6 Stream debug analysis — same as 0.1.'], + ['key' => '9', 'owner' => 'agent', 'text' => 'P9 Landing hub — LOW priority.'], + ['key' => '9.1', 'owner' => 'agent', 'text' => 'P9.1 Hub cards graphs/builder/RA.'], + ['key' => '9.2', 'owner' => 'agent', 'text' => 'P9.2 Globe animation branch cleanup.'], + ['key' => '9.3', 'owner' => 'agent', 'text' => 'P9.3 Mobile chrome validation LOW.'], +]; + +$now = (int) round(microtime(true) * 1000); +$ok = 0; +$fail = 0; + +foreach ($tasks as $task) { + $text = $task['text']; + $ownerTag = $task['owner']; + $payload = [ + 'schema_version' => 1, + 'ingest_kind' => 'ticket', + 'ticket_id' => 'priority-20260608-' . preg_replace('/[^a-z0-9.-]+/i', '-', $task['key']), + 'title' => mb_substr($text, 0, 256), + 'brief' => mb_substr($text, 0, 512), + 'body' => $text . "\n\nRef: docs/20260608_ALPHA_PRIORITIES.md", + 'opened_at_epoch_ms' => $now, + 'rating' => 3, + 'owner' => $owner, + 'tags' => priority_tags($ownerTag), + 'device' => ['manufacturer' => 'roadmap', 'model' => 'priorities', 'sdk_int' => 34, 'release' => '14'], + 'app' => ['package' => 'com.foxx.androidcast', 'version_name' => 'roadmap', 'version_code' => 1], + ]; + if ($dryRun) { + echo "DRY {$task['key']} [{$ownerTag}]\n"; + $ok++; + continue; + } + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents($uploadUrl, false, $ctx); + $code = 0; + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $code = (int) $m[1]; + } + if ($code === 200 || $code === 409) { + echo ($code === 409 ? 'DUP ' : 'OK ') . "{$task['key']}\n"; + $ok++; + } else { + echo "FAIL {$task['key']} HTTP {$code} — {$resp}\n"; + $fail++; + } +} + +echo "Done: ok={$ok} fail={$fail}\n"; +exit($fail > 0 ? 1 : 0); diff --git a/sim/cluster0/lab-seeds/backend/scripts/ingest_roadmap_tickets_20260604.php b/sim/cluster0/lab-seeds/backend/scripts/ingest_roadmap_tickets_20260604.php new file mode 100755 index 0000000..4a35499 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ingest_roadmap_tickets_20260604.php @@ -0,0 +1,113 @@ +#!/usr/bin/env php + */ +function roadmap_tags(string $status): array { + $statusMap = [ + 'open' => ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'], + 'in-progress' => ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3b82f6'], + 'closed' => ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280'], + ]; + return [ + ['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'], + ['id' => '20260604', 'label' => '20260604', 'bg' => '#0ea5e9'], + $statusMap[$status] ?? $statusMap['open'], + ]; +} + +/** title + body are the same description text */ +$tasks = [ + ['key' => 'bug-ticket-detail-500', 'status' => 'closed', 'text' => 'ISSUE: Ticket detail page HTTP 500 (?view=ticket&id=N). Fix recursion in comment/attachment loaders when rendering detail.'], + ['key' => '8', 'status' => 'closed', 'text' => 'COMPLETED: Orchestration branch feature/20260527_orchestration — merged to next; work lives under orchestration/; close/delete branch.'], + ['key' => '1', 'status' => 'open', 'text' => 'BE/builder deployment phases — dedicated services on BE; WIP verifying (Docker, out/builds, runner_script).'], + ['key' => '2', 'status' => 'open', 'text' => 'OTA checks — part of builder/deploy path; verify artifacts and device OTA flow.'], + ['key' => '3', 'status' => 'open', 'text' => 'Opus/Speex validation — E2E, encryption, FEC, NACK, negotiations; user and developer controls.'], + ['key' => '4', 'status' => 'open', 'text' => 'Alpha: RSSH remote access (prod/hidden, no VPN) — BE bastion + Android reverse SSH client; WG v1 lab only. Opus/Speex JNI sinks TODO.'], + ['key' => 'auth-2fa', 'status' => 'open', 'text' => 'Alpha: registration + email verify + TOTP 2FA — mobile-friendly; depends on email DNS/SMTP. See docs/20260607-2FA-email-mobile-auth-flow.md.'], + ['key' => 'email-dns', 'status' => 'open', 'text' => 'Alpha: email infra — info@/admin@/root@ @ apps.f0xx.org forward to Gmail; SPF/DKIM/DMARC. Blocks auth mail.'], + ['key' => '5', 'status' => 'open', 'text' => 'Data visualization / graphs — WIP; issues found on drill-down and layout.'], + ['key' => '5.1', 'status' => 'open', 'text' => 'ISSUE 5.1: Graph brick click must open full-browser detailed chart (axis, legend, hover per point like Java visualizer).'], + ['key' => '5.2', 'status' => 'open', 'text' => 'REQUIREMENT 5.2: All graph preview bricks clickable; Columns combo (1/2/4) after Window default 2; grid uses full width.'], + ['key' => '5.3', 'status' => 'closed', 'text' => 'ISSUE 5.3: Remove Direct DB metrics / graph_upload / graphs.php footnote from graphs page.'], + ['key' => '6', 'status' => 'open', 'text' => 'Landing page checks, fixes, improvements — hub, styling, integration with dashboards.'], + ['key' => '6.1', 'status' => 'open', 'text' => 'Landing 6.1: Hub dashboards for graphs, builder, remote access — partially done.'], + ['key' => '6.1.1', 'status' => 'closed', 'text' => 'ISSUE 6.1.1: Remote access / graphs quick links — shell nav icons and 8px spacing.'], + ['key' => '6.1.2', 'status' => 'closed', 'text' => 'ISSUE 6.1.2: Remove device poll / heartbeat dev footnote from Remote access page.'], + ['key' => '6.2', 'status' => 'open', 'text' => 'Landing 6.2: Globe/earth animations — choose base on next; branch cleanup (3d_earth vs 20260531_globe_animation); clock/footer styling.'], + ['key' => '6.3', 'status' => 'open', 'text' => 'Landing 6.3: Mobile device validations (mobile chrome) — LOW priority.'], + ['key' => '7', 'status' => 'open', 'text' => 'BE roles RBAC — admin/root, slug admins, privilege sets; finish after related pages stable.'], + ['key' => 'net-slowness', 'status' => 'open', 'text' => 'ISSUE: Sporadic FE/BE slowness (SSH, sshfs, web) — WAN/modem queueing suspected; investigate bufferbloat and paths.'], +]; + +$device = [ + 'manufacturer' => 'roadmap', + 'model' => 'planning', + 'sdk_int' => 34, + 'release' => '14', +]; +$app = ['package' => 'com.foxx.androidcast', 'version_name' => 'roadmap', 'version_code' => 1]; +$now = (int) round(microtime(true) * 1000); +$ok = 0; +$fail = 0; + +foreach ($tasks as $task) { + $text = trim($task['text']); + $key = $task['key']; + $status = $task['status']; + $payload = [ + 'schema_version' => 1, + 'ingest_kind' => 'ticket', + 'ticket_id' => 'roadmap-20260604-' . preg_replace('/[^a-z0-9.-]+/i', '-', $key), + 'title' => mb_substr($text, 0, 256), + 'brief' => mb_substr($text, 0, 512), + 'body' => $text, + 'opened_at_epoch_ms' => $now, + 'rating' => $status === 'closed' ? 1 : 3, + 'owner' => $owner, + 'tags' => roadmap_tags($status), + 'device' => $device, + 'app' => $app, + ]; + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($dryRun) { + echo "DRY {$key} [{$status}]\n"; + $ok++; + continue; + } + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $json, + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents($uploadUrl, false, $ctx); + $code = 0; + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $code = (int) $m[1]; + } + if ($code === 200 || $code === 409) { + echo ($code === 409 ? 'DUP ' : 'OK ') . "{$key}\n"; + $ok++; + } else { + echo "FAIL {$key} HTTP {$code} — {$resp}\n"; + $fail++; + } +} + +echo "Done: ok={$ok} fail={$fail}\n"; +exit($fail > 0 ? 1 : 0); diff --git a/sim/cluster0/lab-seeds/backend/scripts/ingest_validation_findings_20260608.php b/sim/cluster0/lab-seeds/backend/scripts/ingest_validation_findings_20260608.php new file mode 100644 index 0000000..f8e4fe6 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ingest_validation_findings_20260608.php @@ -0,0 +1,117 @@ +#!/usr/bin/env php + 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'], + ['id' => '20260608-validation', 'label' => '20260608-validation', 'bg' => '#f59e0b'], + ['id' => 'infra', 'label' => 'infra', 'bg' => '#0ea5e9'], + ]; + if ($kind === 'issue') { + $base[] = ['id' => 'issue', 'label' => 'issue', 'bg' => '#94a3b8']; + } + return $base; +} + +$tasks = [ + [ + 'key' => 'SVC-OTA', + 'kind' => 'ticket', + 'text' => 'ISSUE SVC-OTA: OTA channel HTTP 400 on apps.f0xx.org — wire /v0/ota/ on apps vhost or align device ota.base_url. See docs/20260608_BE_SERVICES_and_infra.md', + ], + [ + 'key' => 'SVC-URL', + 'kind' => 'ticket', + 'text' => 'ISSUE SVC-URL: Legacy f0xx.org crash upload URL 404 — production base should be apps.f0xx.org. Update settings.json / dev defaults.', + ], + [ + 'key' => 'SVC-DIAG', + 'kind' => 'issue', + 'text' => 'ISSUE SVC-DIAG: diag.php returns 403 at public edge — acceptable; devices use upload.php + heartbeat.php. Documented in BE services map.', + ], + [ + 'key' => 'SFU-SPEC', + 'kind' => 'issue', + 'text' => 'PLANNING SFU-SPEC: Janus/mediasoup relay post-alpha — branch feature/sfu-relay (hidden). Await owner spec. Gray section in docs/20260608_BE_SERVICES_and_infra.md', + ], + [ + 'key' => 'graphs-5.1', + 'kind' => 'ticket', + 'status' => 'closed', + 'text' => 'CLOSED 5.1: Graph brick drill-down — full overlay, axes, legend, hover implemented in graphs.js (2026-06-08).', + ], + [ + 'key' => 'graphs-5.2', + 'kind' => 'ticket', + 'status' => 'closed', + 'text' => 'CLOSED 5.2: Graph columns 1/2/4, clickable bricks, full-width shell--graphs-full CSS (2026-06-08).', + ], + [ + 'key' => 'browser-js', + 'kind' => 'ticket', + 'status' => 'closed', + 'text' => 'CLOSED: Browser SyntaxError on console JS — deployed assets pass node --check; validate_be_services.sh added.', + ], +]; + +$device = ['manufacturer' => 'validation', 'model' => 'infra', 'sdk_int' => 34, 'release' => '14']; +$app = ['package' => 'com.foxx.androidcast', 'version_name' => 'validation', 'version_code' => 1]; +$now = (int) round(microtime(true) * 1000); + +foreach ($tasks as $task) { + $text = trim($task['text']); + $key = $task['key']; + $status = $task['status'] ?? 'open'; + $tags = val_tags($task['kind']); + if ($status === 'closed') { + $tags[] = ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280']; + } else { + $tags[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']; + } + $payload = [ + 'schema_version' => 1, + 'ingest_kind' => 'ticket', + 'ticket_id' => 'validation-20260608-' . preg_replace('/[^a-z0-9.-]+/i', '-', $key), + 'title' => mb_substr($text, 0, 256), + 'brief' => mb_substr($text, 0, 512), + 'body' => $text, + 'opened_at_epoch_ms' => $now, + 'rating' => $status === 'closed' ? 1 : 3, + 'owner' => 'admin', + 'tags' => $tags, + 'device' => $device, + 'app' => $app, + ]; + if ($dryRun) { + echo "DRY {$key}\n"; + continue; + } + $ch = curl_init($uploadUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + CURLOPT_TIMEOUT => 30, + ]); + $resp = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300) { + echo "OK {$key} HTTP {$code}\n"; + } else { + echo "FAIL {$key} HTTP {$code} {$resp}\n"; + } +} diff --git a/sim/cluster0/lab-seeds/backend/scripts/init-mariadb.sh b/sim/cluster0/lab-seeds/backend/scripts/init-mariadb.sh new file mode 100755 index 0000000..35b1401 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/init-mariadb.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Fresh MariaDB setup for crash reporter (run on BE as root or with mysql admin). +# Usage: +# ./scripts/init-mariadb.sh +# CRASH_DB_PASS='secret' ./scripts/init-mariadb.sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCHEMA="$ROOT/sql/schema.mariadb.sql" +MIG002="$ROOT/sql/migrations/002_reports_company_columns.sql" +MIG003="$ROOT/sql/migrations/003_tickets.sql" +DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}" +DB_USER="${CRASH_DB_USER:-androidcast}" +DB_PASS="${CRASH_DB_PASS:-}" + +if [ ! -f "$SCHEMA" ]; then + echo "Missing $SCHEMA" >&2 + exit 1 +fi + +echo "Applying schema from $SCHEMA ..." +mysql -u root -p < "$SCHEMA" + +if [ -f "$MIG002" ]; then + echo "Applying RBAC report columns (002) ..." + mysql -u root -p "$DB_NAME" < "$MIG002" +fi + +if [ -f "$MIG003" ]; then + echo "Applying tickets table (003) ..." + mysql -u root -p "$DB_NAME" < "$MIG003" +fi + +if [ -n "$DB_PASS" ]; then + echo "Creating app user ${DB_USER}@localhost and @127.0.0.1 ..." + mysql -u root -p <&2 + exit 1 +fi + +cat > "$BUILDER_FRAG" << EOF +location = /app/androidcast_project/build { + return 301 /app/androidcast_project/build/; +} + +location ^~ /app/androidcast_project/build/assets/ { + alias $BUILD_PUBLIC/assets/; +} + +location ^~ /app/androidcast_project/build/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME $BUILD_PUBLIC/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/build/index.php; + fastcgi_param REQUEST_URI \$request_uri; + client_max_body_size 512m; + fastcgi_read_timeout 1800s; +} +EOF + +echo "Wrote $BUILDER_FRAG" +grep SCRIPT_FILENAME "$BUILDER_FRAG" + +if ! grep -q 'include /etc/nginx/conf.d/apps_builder.frag' "$APPS_CONF"; then + echo "WARN: $APPS_CONF does not include apps_builder.frag inside listen 443 server block." >&2 + echo "Add: include /etc/nginx/conf.d/apps_builder.frag; before the closing } of the 443 server." >&2 +fi + +nginx -t +rc-service nginx reload + +echo +echo "=== effective build locations ===" +nginx -T 2>/dev/null | grep -A6 'androidcast_project/build/' | head -40 + +echo +echo "=== curl BE :443 ===" +curl -sSI -H 'Host: apps.f0xx.org' 'https://127.0.0.1/app/androidcast_project/build/' -k \ + | grep -E 'HTTP|location|x-powered-by' || true + +echo +echo "If still 404, check error log:" +echo " tail -20 /var/log/nginx/apps.intra.raptor.org.error_log" diff --git a/sim/cluster0/lab-seeds/backend/scripts/install-fe-builder-nginx.sh b/sim/cluster0/lab-seeds/backend/scripts/install-fe-builder-nginx.sh new file mode 100755 index 0000000..388108b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/install-fe-builder-nginx.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Run on Gentoo FE as root — wire build proxy like hub/crashes (BE :443). +set -euo pipefail + +NGINX_CONF="/etc/nginx/nginx.conf" +APPS_CONF="/etc/nginx/apps.conf" +SNIPPET=' location /app/androidcast_project/build/ { + proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/build/; + } +' + +if grep -q 'androidcast_project/build/' "$NGINX_CONF"; then + echo "nginx.conf already has androidcast_project/build/ location" +else + if ! grep -q 'androidcast_project/crashes/' "$NGINX_CONF"; then + echo "Cannot find crashes block in $NGINX_CONF — add build block manually after crashes." >&2 + exit 1 + fi + python3 - "$NGINX_CONF" "$SNIPPET" << 'PY' +import sys +from pathlib import Path +path, snippet = Path(sys.argv[1]), sys.argv[2] +text = path.read_text() +needle = "\t\tlocation /app/androidcast_project/crashes/ {\n" +idx = text.find(needle) +if idx < 0: + raise SystemExit("crashes location not found") +end = text.find("\n\t\t}", idx) +if end < 0: + raise SystemExit("crashes block end not found") +end = text.find("\n", end + 1) +path.write_text(text[:end] + "\n" + snippet + text[end:]) +print(f"Patched {path}") +PY +fi + +if [[ -f "$APPS_CONF" ]]; then + if grep -q 'proxy_pass http://artc0.intra.raptor.org:80' "$APPS_CONF" 2>/dev/null; then + cp -a "$APPS_CONF" "${APPS_CONF}.bak.$(date +%Y%m%d%H%M%S)" + sed -i 's|proxy_pass http://artc0.intra.raptor.org:80;|# DISABLED — use nginx.conf :443 upstream\n\t\t # proxy_pass http://artc0.intra.raptor.org:80;|' "$APPS_CONF" || true + echo "Commented stale :80 build proxy in $APPS_CONF (backup created)" + fi +fi + +nginx -t +rc-service nginx reload + +echo +echo "=== FE localhost curl ===" +curl -sSI -H 'Host: apps.f0xx.org' 'http://127.0.0.1/app/androidcast_project/build/' -k 2>/dev/null \ + | grep -E 'HTTP|location|x-powered-by' || \ +curl -sSI -H 'Host: apps.f0xx.org' 'https://127.0.0.1/app/androidcast_project/build/' -k \ + | grep -E 'HTTP|location|x-powered-by' + +echo +echo "Public check:" +echo " curl -sSI 'https://apps.f0xx.org/app/androidcast_project/build/' | grep -E 'HTTP|location|x-powered-by'" diff --git a/sim/cluster0/lab-seeds/backend/scripts/install-fe-ota-nginx.sh b/sim/cluster0/lab-seeds/backend/scripts/install-fe-ota-nginx.sh new file mode 100755 index 0000000..e5e67f2 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/install-fe-ota-nginx.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Run on Gentoo FE as root — proxy /v0/ota/ to BE listen 80 (static OTA tree). +# Superseded by consolidated apps.conf (nginx/fe/apps.conf + fe-nginx-apps-consolidation.patch). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +SNIPPET_FILE="$REPO_ROOT/examples/crash_reporter/backend/nginx.fe-ota.snippet" +NGINX_CONF="${1:-/etc/nginx/nginx.conf}" + +if [[ ! -f "$NGINX_CONF" ]]; then + echo "Usage: $0 [path/to/nginx.conf]" >&2 + exit 1 +fi + +if grep -q 'location /v0/ota/' "$NGINX_CONF"; then + echo "$NGINX_CONF already has location /v0/ota/" + nginx -t + rc-service nginx reload + exit 0 +fi + +python3 - "$NGINX_CONF" "$SNIPPET_FILE" << 'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +snippet_path = Path(sys.argv[2]) +snippet = snippet_path.read_text() +# Strip comment header lines; keep tab-indented location block only. +lines = [] +for line in snippet.splitlines(): + if line.startswith("#") or not line.strip(): + continue + lines.append(line) +block = "\n".join(lines) + "\n" +text = path.read_text() +needles = [ + "\t\tlocation /app/androidcast_project/ {\n", + "\t\tlocation /app/androidcast_project/crashes/ {\n", +] +idx = -1 +for needle in needles: + idx = text.find(needle) + if idx >= 0: + break +if idx < 0: + raise SystemExit("Could not find androidcast location block in nginx.conf — add nginx.fe-ota.snippet manually") +path.write_text(text[:idx] + block + text[idx:]) +print(f"Patched {path} with /v0/ota/ proxy → BE :80") +PY + +nginx -t +rc-service nginx reload + +echo +echo "=== Verify ===" +curl -sS -o /dev/null -w 'public OTA HTTP %{http_code}\n' \ + 'https://apps.f0xx.org/v0/ota/channel/stable.json' || true diff --git a/sim/cluster0/lab-seeds/backend/scripts/migrate-003-tickets.sh b/sim/cluster0/lab-seeds/backend/scripts/migrate-003-tickets.sh new file mode 100755 index 0000000..ac9d7ac --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/migrate-003-tickets.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Apply tickets table migration (003) on existing MariaDB installs. +# Requires MySQL root (app user androidcast cannot CREATE TABLE). +# +# Usage (from backend/): +# ./scripts/migrate-003-tickets.sh +# CRASH_DB_NAME=my_db ./scripts/migrate-003-tickets.sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SQL="$ROOT/sql/migrations/003_tickets.sql" +DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}" + +if [ ! -f "$SQL" ]; then + echo "Missing $SQL" >&2 + exit 1 +fi + +echo "Applying tickets migration to database: $DB_NAME" +echo "File: $SQL" +echo "(you will be prompted for MySQL root password)" +mysql -u root -p "$DB_NAME" < "$SQL" + +echo "Verifying tickets table..." +mysql -u root -p "$DB_NAME" -e "SHOW TABLES LIKE 'tickets'; DESCRIBE tickets;" | head -20 + +echo "Done. Restart php-fpm if needed, then reload Tickets in the console." diff --git a/sim/cluster0/lab-seeds/backend/scripts/ndjson_to_graph_sql.php b/sim/cluster0/lab-seeds/backend/scripts/ndjson_to_graph_sql.php new file mode 100644 index 0000000..0d901d0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ndjson_to_graph_sql.php @@ -0,0 +1,96 @@ +#!/usr/bin/env php += $batchSize) { + fwrite($fh, "INSERT INTO graph_sessions\n"); + fwrite($fh, " (session_id, company_id, user_id, device_id, app_version, sdk_int,\n"); + fwrite($fh, " started_at_ms, ended_at_ms, duration_s, completed, direction, transport,\n"); + fwrite($fh, " avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)\n"); + fwrite($fh, "VALUES\n " . implode(",\n ", $batch) . "\n"); + fwrite($fh, "ON DUPLICATE KEY UPDATE\n"); + fwrite($fh, " ended_at_ms=VALUES(ended_at_ms), duration_s=VALUES(duration_s), completed=VALUES(completed),\n"); + fwrite($fh, " avg_kbps=VALUES(avg_kbps), recv_kbps=VALUES(recv_kbps), packet_loss_pct=VALUES(packet_loss_pct),\n"); + fwrite($fh, " meta_json=VALUES(meta_json);\n"); + $batch = []; + } +} + +if ($batch) { + fwrite($fh, "INSERT INTO graph_sessions\n"); + fwrite($fh, " (session_id, company_id, user_id, device_id, app_version, sdk_int,\n"); + fwrite($fh, " started_at_ms, ended_at_ms, duration_s, completed, direction, transport,\n"); + fwrite($fh, " avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)\n"); + fwrite($fh, "VALUES\n " . implode(",\n ", $batch) . "\n"); + fwrite($fh, "ON DUPLICATE KEY UPDATE\n"); + fwrite($fh, " ended_at_ms=VALUES(ended_at_ms), duration_s=VALUES(duration_s), completed=VALUES(completed),\n"); + fwrite($fh, " avg_kbps=VALUES(avg_kbps), recv_kbps=VALUES(recv_kbps), packet_loss_pct=VALUES(packet_loss_pct),\n"); + fwrite($fh, " meta_json=VALUES(meta_json);\n"); +} + +fwrite($fh, "COMMIT;\n"); +fclose($fh); +echo "Wrote $total rows to $out\n"; diff --git a/sim/cluster0/lab-seeds/backend/scripts/prepare-be-ota-mount.sh b/sim/cluster0/lab-seeds/backend/scripts/prepare-be-ota-mount.sh new file mode 100755 index 0000000..458a711 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/prepare-be-ota-mount.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Create BE OTA artifact directory for nginx location /v0/ota/ (run on alpine-be). +# +# Usage (on BE as root): +# sudo ./prepare-be-ota-mount.sh +# sudo ./prepare-be-ota-mount.sh /custom/path/to/ota-artifacts +# +# After mkdir, publish artifacts: +# rsync -av ./out/ota/v0/ /var/www/.../ota-artifacts/v0/ +# Or enable builder config build.ota_mount + auto_deploy. +set -euo pipefail + +OTA_ROOT="${1:-/var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts}" +V0="${OTA_ROOT}/v0" + +mkdir -p "${V0}/ota/channel" +chown -R nginx:nginx "${OTA_ROOT}" +chmod -R 775 "${OTA_ROOT}" + +echo "OTA mount ready: ${OTA_ROOT}" +echo "nginx alias target: ${V0}/ota/" +echo "Channel URL: https://apps.f0xx.org/v0/ota/channel/stable.json" +echo +echo "Publish example:" +echo " rsync -av out/ota/v0/ ${V0}/" diff --git a/sim/cluster0/lab-seeds/backend/scripts/purge_remote_access.php b/sim/cluster0/lab-seeds/backend/scripts/purge_remote_access.php new file mode 100644 index 0000000..7f84e08 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/purge_remote_access.php @@ -0,0 +1,30 @@ +#!/usr/bin/env php +&2; exit 2 ;; + esac +done + +load_random() { + if [[ -f "$RANDOM_FILE" ]]; then + cat "$RANDOM_FILE" + else + openssl rand -hex 16 2>/dev/null || date +%s + fi +} + +save_random() { + local r="$1" + echo "$r" >"$RANDOM_FILE" +} + +usage() { + cat <<'EOF' +AndroidCast remote-access device simulator (heartbeat type: ra) + +Commands: + disable POST status:disable (like app "Disabled" mode) + enable POST status:enable once + poll Poll enable; use --until connect|wait|disabled|deny + show-random Print persisted device random (like AppPreferences) + +Options: + --base URL CRASHES_BASE (default local orchestration crashes URL) + --device-id ID Stable device_id (default ra-cli-) + --random-file F Persist random across polls (like app prefs) + --app-version V Sent as app_version on heartbeat + --apply-wg On connect: write wg-quick conf and run wg-quick up (needs root) + --once Single poll (default for enable/poll) + --until ACTION Stop polling when heartbeat.action matches (poll mode) + +Examples: + ./scripts/ra_device_sim.sh disable + ./scripts/ra_device_sim.sh enable --once + ./scripts/ra_device_sim.sh poll --until connect + CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes \ + ./scripts/ra_device_sim.sh poll --until connect --apply-wg +EOF +} + +cmd_disable() { + local rnd + rnd="$(load_random)" + save_random "$rnd" + echo "== ra disable device_id=$DEVICE_ID random=$rnd" + local resp action + resp="$(ra_ra_post disable "$DEVICE_ID" "$rnd" "$APP_VERSION")" + action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')" + echo "action=$action" + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" +} + +cmd_enable_once() { + local rnd + rnd="$(load_random)" + save_random "$rnd" + echo "== ra enable device_id=$DEVICE_ID random=$rnd" + local resp action + resp="$(ra_ra_post enable "$DEVICE_ID" "$rnd" "$APP_VERSION")" + action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')" + echo "action=$action" + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" +} + +cmd_poll() { + local until="${POLL_UNTIL:-connect}" + local rnd + rnd="$(load_random)" + save_random "$rnd" + echo "== ra poll until action=$until (device_id=$DEVICE_ID)" + while true; do + local resp action + resp="$(ra_ra_post enable "$DEVICE_ID" "$rnd" "$APP_VERSION")" + action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')" + echo "$(date -Is) action=$action" + if [[ "$action" == "$until" ]]; then + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" + if [[ "$action" == "connect" && "$APPLY_WG" -eq 1 ]]; then + apply_wg "$resp" + fi + return 0 + fi + if [[ "$ONCE" -eq 1 ]]; then + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" + return 0 + fi + sleep "${RA_POLL_INTERVAL_S:-3}" + done +} + +apply_wg() { + local resp="$1" + if ! command -v wg-quick >/dev/null 2>&1; then + echo "WARN: wg-quick not installed; skipping tunnel bring-up" >&2 + ra_wg_quick_from_connect "$resp" >"/tmp/ra-${DEVICE_ID}.conf" + echo "Wrote /tmp/ra-${DEVICE_ID}.conf" + return 0 + fi + local conf="/tmp/ra-${DEVICE_ID}.conf" + ra_wg_quick_from_connect "$resp" >"$conf" + echo "== wg-quick up $conf (sudo)" + sudo wg-quick up "$conf" || { + echo "FAIL: wg-quick up (run as root or check endpoint UDP)" >&2 + return 1 + } + wg show 2>/dev/null | head -20 || true +} + +case "$CMD" in + disable) cmd_disable ;; + enable) ONCE=1; cmd_enable_once ;; + poll) cmd_poll ;; + show-random) load_random; echo ;; + help|-h|--help) usage ;; + *) + echo "Unknown command: $CMD" >&2 + usage + exit 2 + ;; +esac diff --git a/sim/cluster0/lab-seeds/backend/scripts/ra_e2e_cli.sh b/sim/cluster0/lab-seeds/backend/scripts/ra_e2e_cli.sh new file mode 100755 index 0000000..57cf61b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ra_e2e_cli.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# End-to-end remote-access validation: operator + device (CLI simulates Android). +# +# Flow (matches app + dashboard): +# 1. device disable (clean slate) +# 2. device enable → wait (not whitelisted) +# 3. operator whitelist + open session +# 4. device poll → connect +# 5. operator close session +# 6. device disable +# +# Usage: +# ./scripts/ra_e2e_cli.sh +# CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh +# ./scripts/ra_e2e_cli.sh --apply-wg +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=ra_lib.sh +source "$ROOT/scripts/ra_lib.sh" + +APPLY_WG=0 +DEVICE_ID="ra-e2e-$(date +%s)" +while [[ $# -gt 0 ]]; do + case "$1" in + --apply-wg) APPLY_WG=1; shift ;; + --base) export CRASHES_BASE="$2"; shift 2 ;; + --device-id) DEVICE_ID="$2"; shift 2 ;; + -h|--help) + echo "Usage: $0 [--base URL] [--device-id ID] [--apply-wg]" + exit 0 + ;; + *) echo "Unknown: $1" >&2; exit 2 ;; + esac +done + +export RA_DEVICE_ID="$DEVICE_ID" +export RA_RANDOM_FILE="/tmp/androidcast-ra-${DEVICE_ID}.random" +COOKIE_JAR="$(mktemp)" +trap 'rm -f "$COOKIE_JAR"' EXIT + +echo "== Remote access E2E CLI ==" +echo "BASE=$(ra_base)" +echo "DEVICE_ID=$DEVICE_ID" +echo + +echo "== 1. device disable ==" +bash "$ROOT/scripts/ra_device_sim.sh" disable --device-id "$DEVICE_ID" --base "$(ra_base)" +echo + +echo "== 2. device enable (expect wait) ==" +bash "$ROOT/scripts/ra_device_sim.sh" enable --once --device-id "$DEVICE_ID" --base "$(ra_base)" | tee /tmp/ra-e2e-enable.out +grep -q 'action=wait' /tmp/ra-e2e-enable.out || { + echo "FAIL: expected wait before whitelist" >&2 + exit 1 +} +echo OK +echo + +echo "== 3. operator login + whitelist ==" +ra_admin_login "$COOKIE_JAR" +ra_admin_post "$COOKIE_JAR" whitelist "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" | ra_json '.' +echo OK whitelist +echo + +echo "== 4. operator open session ==" +ra_admin_post "$COOKIE_JAR" open_session "{\"device_id\":\"$DEVICE_ID\"}" | ra_json '.' +echo OK open_session +echo + +echo "== 5. device poll until connect ==" +WG_ARGS=() +[[ "$APPLY_WG" -eq 1 ]] && WG_ARGS+=(--apply-wg) +bash "$ROOT/scripts/ra_device_sim.sh" poll --until connect --device-id "$DEVICE_ID" --base "$(ra_base)" "${WG_ARGS[@]}" +echo OK connect +echo + +echo "== 6. operator dashboard ==" +ra_admin_post "$COOKIE_JAR" dashboard '{}' 2>/dev/null || \ + curl -sf -b "$COOKIE_JAR" "$(ra_admin_url dashboard)" | ra_json '{ok, permissions}' +echo + +echo "== 7. operator close session ==" +SESSION_ID="$(curl -sf -b "$COOKIE_JAR" "$(ra_admin_url sessions)" | ra_json -r '.sessions[] | select(.device_id=="'"$DEVICE_ID"'") | .session_id' | head -1)" +if [[ -z "$SESSION_ID" ]]; then + SESSION_ID="$(curl -sf -b "$COOKIE_JAR" "$(ra_admin_url dashboard)" | ra_json -r '.active_sessions[0].session_id // empty')" +fi +if [[ -n "$SESSION_ID" ]]; then + ra_admin_post "$COOKIE_JAR" close_session "{\"session_id\":\"$SESSION_ID\",\"device_id\":\"$DEVICE_ID\"}" | ra_json '.' + echo OK close_session "$SESSION_ID" +else + echo "WARN: no session_id to close" +fi +echo + +echo "== 8. device disable ==" +bash "$ROOT/scripts/ra_device_sim.sh" disable --device-id "$DEVICE_ID" --base "$(ra_base)" +echo + +echo "== E2E CLI passed ==" diff --git a/sim/cluster0/lab-seeds/backend/scripts/ra_lib.sh b/sim/cluster0/lab-seeds/backend/scripts/ra_lib.sh new file mode 100755 index 0000000..7563d9a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/ra_lib.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Shared helpers for remote-access CLI validation (device + operator). +# Mirrors Android RemoteAccessService heartbeat payloads. +set -euo pipefail + +RA_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ra_json() { + if command -v jq >/dev/null 2>&1; then + jq -r "$@" + else + echo "jq required for: $*" >&2 + return 1 + fi +} + +ra_base() { + echo "${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +} + +ra_heartbeat_url() { + echo "$(ra_base)/api/heartbeat.php" +} + +ra_admin_url() { + local action="${1:-}" + echo "$(ra_base)/api/remote_access.php?action=${action}" +} + +# POST heartbeat type:ra — prints full JSON response to stdout. +# Usage: ra_ra_post enable|disable DEVICE_ID RANDOM [app_version] +ra_ra_post() { + local status="$1" + local device_id="$2" + local random="${3:-}" + local app_version="${4:-cli-sim}" + local mode="${RA_TUNNEL_MODE:-wireguard}" + local caps + if [[ "$mode" == "rssh" ]]; then + caps='["rssh","shell"]' + else + caps='["wireguard","files_app_home"]' + fi + local body + body=$(printf '{"heartbeat":{"type":"ra","status":"%s","device_id":"%s","random":"%s","app_version":"%s","tunnel_mode":"%s","capabilities":%s}}' \ + "$status" "$device_id" "$random" "$app_version" "$mode" "$caps") + local url body_tmp code + url="$(ra_heartbeat_url)" + body_tmp="$(mktemp)" + code=$(curl -sS -o "$body_tmp" -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url") + if [[ "$code" != "200" ]]; then + echo "heartbeat HTTP $code from $url" >&2 + cat "$body_tmp" >&2 + rm -f "$body_tmp" + return 1 + fi + cat "$body_tmp" + rm -f "$body_tmp" +} + +ra_ra_action() { + ra_ra_post "$@" | ra_json -r '.heartbeat.action // empty' +} + +ra_admin_login() { + local jar="${1:?cookie jar}" + local user="${ADMIN_USER:-admin}" + local pass="${ADMIN_PASS:-admin}" + curl -sf -c "$jar" -b "$jar" -X POST \ + -d "username=${user}&password=${pass}" \ + "$(ra_base)/login" >/dev/null +} + +ra_admin_post() { + local jar="$1" + local action="$2" + local json_body="$3" + curl -sf -b "$jar" -c "$jar" -X POST -H 'Content-Type: application/json' \ + -d "$json_body" "$(ra_admin_url "$action")" +} + +# Build wireguard-quick config from connect JSON (same fields as WireGuardConfigBuilder.java). +ra_wg_quick_from_connect() { + local connect_json="$1" + if ! command -v jq >/dev/null 2>&1; then + return 1 + fi + local priv addr peer_pub endpoint allowed + priv=$(echo "$connect_json" | jq -r '.heartbeat.credentials.interface_private_key // empty') + addr=$(echo "$connect_json" | jq -r '.heartbeat.credentials.interface_address // "172.200.2.10/32"') + peer_pub=$(echo "$connect_json" | jq -r '.heartbeat.credentials.peer_public_key // empty') + endpoint=$(echo "$connect_json" | jq -r '.heartbeat.endpoint // .heartbeat.credentials.peer_endpoint // empty') + allowed=$(echo "$connect_json" | jq -r '.heartbeat.credentials.peer_allowed_ips // "0.0.0.0/0"') + if [[ -z "$priv" || -z "$peer_pub" ]]; then + return 1 + fi + cat <&2; usage; exit 2 ;; + esac +done + +export RA_DEVICE_ID="$DEVICE_ID" +export RA_RANDOM_FILE="/tmp/androidcast-ra-${DEVICE_ID}.random" + +note() { echo "== $*"; } +ok() { echo "OK $*"; } +warn() { echo "WARN $*"; } +fail() { echo "FAIL $*"; exit 1; } + +note "RA UDP debug — device_id=$DEVICE_ID" +note "BASE=$(ra_base)" +note "WG endpoint ${WG_HOST}:${WG_PORT}" + +note "1. DNS (CNAME chain)" +APPS_IP="$(dig +short apps.f0xx.org A 2>/dev/null | head -1)" +RA_IP="$(dig +short "$WG_HOST" A 2>/dev/null | head -1)" +echo "apps.f0xx.org A: ${APPS_IP:-?}" +echo "${WG_HOST} A: ${RA_IP:-?}" +if [[ -n "$APPS_IP" && -n "$RA_IP" && "$APPS_IP" == "$RA_IP" ]]; then + ok "ra host resolves same as apps (CNAME OK)" +else + warn "A records differ or missing — check CNAME/DNS" +fi + +note "2. UDP reachability" +if command -v nc >/dev/null 2>&1; then + if nc -zvu -w 3 "$WG_HOST" "$WG_PORT" 2>&1 | grep -qiE 'succeeded|open'; then + ok "UDP ${WG_PORT} appears open (nc)" + else + warn "UDP ${WG_PORT} not confirmed open — FE DNAT / firewall?" + fi +else + warn "nc not found — skip UDP probe" +fi + +if [[ "$SKIP_HTTP" -eq 0 ]]; then + note "3. HTTP control plane (quick)" + if CRASHES_BASE="$(ra_base)" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then + ok "test_remote_access_api.sh" + else + warn "API smoke failed — fix HTTP before UDP" + fi +else + note "3. HTTP skipped" +fi + +note "4. Operator: whitelist + open session (if not already)" +COOKIE_JAR="$(mktemp)" +trap 'rm -f "$COOKIE_JAR"; sudo wg-quick down "$CONF" 2>/dev/null || true' EXIT +ra_admin_login "$COOKIE_JAR" +ra_admin_post "$COOKIE_JAR" whitelist "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" >/dev/null +ra_admin_post "$COOKIE_JAR" open_session "{\"device_id\":\"$DEVICE_ID\"}" >/dev/null +ok "whitelist + open_session" + +note "5. Device poll → connect (may wait min_poll_interval_s)" +export RA_DEVICE_ID="$DEVICE_ID" +bash "$ROOT/scripts/ra_device_sim.sh" poll --until connect --device-id "$DEVICE_ID" --base "$(ra_base)" > /tmp/ra-udp-connect.json +grep -q '"action":"connect"' /tmp/ra-udp-connect.json || fail "no connect in response" +ok "heartbeat connect" + +note "6. Build wg-quick config" +RESP="$(cat /tmp/ra-udp-connect.json)" +ra_wg_quick_from_connect "$RESP" >"$CONF" +echo "--- $CONF ---" +sed 's/PrivateKey = .*/PrivateKey = /' "$CONF" +ok "wrote $CONF" + +if [[ "$APPLY" -eq 1 ]]; then + command -v wg-quick >/dev/null 2>&1 || fail "wg-quick not installed" + note "7. Bring up tunnel" + sudo wg-quick up "$CONF" + sudo wg show + note "8. Ping BE wg (${BE_WG_IP})" + if ping -c 3 -W 2 "$BE_WG_IP"; then + ok "ping ${BE_WG_IP}" + else + warn "ping failed — routing or AllowedIPs (expected 172.200.2.1/32)" + fi + note "9. Teardown" + sudo wg-quick down "$CONF" + ok "wg-quick down" +else + note "7–9. Skipped tunnel (pass --apply or RA_UDP_APPLY=1 to test handshake)" + echo " sudo wg-quick up $CONF && ping -c3 $BE_WG_IP && sudo wg-quick down $CONF" +fi + +note "UDP debug done" diff --git a/sim/cluster0/lab-seeds/backend/scripts/rssh_bastion_user.sh b/sim/cluster0/lab-seeds/backend/scripts/rssh_bastion_user.sh new file mode 100755 index 0000000..7ad5e1d --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/rssh_bastion_user.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Ephemeral RSSH bastion users (Match User ra-* in sshd_config.d/ra.conf). +# Usage: rssh_bastion_user.sh add|remove USERNAME [PASSWORD] +set -eu + +ACTION="${1:-}" +USER="${2:-}" +PASS="${3:-}" + +die() { + echo "rssh_bastion_user: $*" >&2 + exit 1 +} + +validate_user() { + case "$USER" in + ra-*) + return 0 + ;; + *) + die "invalid username (expected ra-* prefix)" + ;; + esac +} + +cmd_add() { + validate_user + [ -n "$PASS" ] || die "password required for add" + if id "$USER" >/dev/null 2>&1; then + echo "$USER:$PASS" | chpasswd + exit 0 + fi + if command -v adduser >/dev/null 2>&1; then + adduser -D -h /dev/null -s /bin/sh "$USER" + elif command -v useradd >/dev/null 2>&1; then + useradd -M -s /bin/sh "$USER" + else + die "no adduser/useradd" + fi + echo "$USER:$PASS" | chpasswd +} + +cmd_remove() { + validate_user + if ! id "$USER" >/dev/null 2>&1; then + exit 0 + fi + if command -v deluser >/dev/null 2>&1; then + deluser "$USER" 2>/dev/null || true + elif command -v userdel >/dev/null 2>&1; then + userdel "$USER" 2>/dev/null || true + fi +} + +case "$ACTION" in + add) cmd_add ;; + remove) cmd_remove ;; + *) die "usage: $0 add|remove USERNAME [PASSWORD]" ;; +esac diff --git a/sim/cluster0/lab-seeds/backend/scripts/sync_wg_peers.php b/sim/cluster0/lab-seeds/backend/scripts/sync_wg_peers.php new file mode 100755 index 0000000..7314c86 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/sync_wg_peers.php @@ -0,0 +1,88 @@ +#!/usr/bin/env php + 12 ? substr($pub, 0, 8) . '…' : $pub; + fwrite( + STDOUT, + sprintf( + " reconcile %s allowed_ips=%s ok=%s\n", + $short, + (string) ($row['client_address'] ?? ''), + ($row['ok'] ?? false) ? 'true' : 'false' + ) + ); +} + +foreach ($livePeers as $row) { + $pub = (string) ($row['public_key'] ?? ''); + $allowed = trim((string) ($row['allowed_ips'] ?? '')); + $short = strlen($pub) > 12 ? substr($pub, 0, 8) . '…' : $pub; + $broken = ($allowed === '' || $allowed === '(none)'); + $orphan = !in_array($pub, $keep, true); + if (!$broken && !$orphan) { + continue; + } + fwrite( + STDOUT, + sprintf( + " live %s allowed_ips=%s broken=%s orphan=%s\n", + $short, + $allowed === '' ? '(empty)' : $allowed, + $broken ? 'true' : 'false', + $orphan ? 'true' : 'false' + ) + ); +} + +foreach ($pruned as $row) { + $pub = $row['public_key'] ?? ''; + $short = strlen($pub) > 12 ? substr($pub, 0, 8) . '…' : $pub; + fwrite( + STDOUT, + sprintf( + " pruned %s reason=%s allowed_ips=%s\n", + $short, + (string) ($row['reason'] ?? ''), + (string) ($row['allowed_ips'] ?? '') + ) + ); +} diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_graphs_api.sh b/sim/cluster0/lab-seeds/backend/scripts/test_graphs_api.sh new file mode 100755 index 0000000..c10ed34 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_graphs_api.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Smoke-test graph session ingest + dashboard API. +# +# ./scripts/test_graphs_api.sh +# GRAPHS_BASE=http://localhost:8080/app/androidcast_project/graphs CRASH_USER=admin CRASH_PASS=admin ./scripts/test_graphs_api.sh +# COOKIE='ac_crash_sess=…' GRAPHS_BASE=https://apps.f0xx.org/app/androidcast_project/graphs ./scripts/test_graphs_api.sh +set -euo pipefail + +CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +GRAPHS_BASE="${GRAPHS_BASE:-${CRASH_BASE/\/crashes\//\/graphs\/}}" +UPLOAD_URL="${GRAPHS_BASE}/api/graph_upload.php" +API_URL="${GRAPHS_BASE}/api/graphs.php" +COOKIE_JAR="${TMPDIR:-/tmp}/graph_api_cookies_$$.txt" +trap 'rm -f "$COOKIE_JAR"' EXIT + +CURL_AUTH=() +if [[ -n "${COOKIE:-}" ]]; then + CURL_AUTH=(-H "Cookie: ${COOKIE}") +else + user="${CRASH_USER:-admin}" + pass="${CRASH_PASS:-admin}" + curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ + -d "username=${user}&password=${pass}" \ + "${CRASH_BASE}/login" -o /dev/null + CURL_AUTH=(-b "$COOKIE_JAR") +fi + +now_ms="$(date +%s)000" +start_ms=$((now_ms - 600000)) +sid="smoke-graph-$(date +%s)" + +payload="$(cat <&2 + exit 1 +fi +if [[ "${graphs_code}" != "200" ]]; then + echo "FAIL: graphs API expected 200 (got ${graphs_code})" >&2 + exit 1 +fi +if ! grep -q '"ok":true' /tmp/graphs_resp.json; then + echo "FAIL: graphs API body missing ok:true" >&2 + exit 1 +fi +echo "OK" diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_gzip_upload.sh b/sim/cluster0/lab-seeds/backend/scripts/test_gzip_upload.sh new file mode 100755 index 0000000..836aa73 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_gzip_upload.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Smoke-test gzip crash upload (plain JSON still accepted). +# Usage: BASE=http://127.0.0.1:8080/app/androidcast_project/crashes ./scripts/test_gzip_upload.sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SAMPLE="${ROOT}/../sample_crash_java.json" +BASE="${BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +URL="${BASE}/api/upload.php" +RID="gzip-test-$(date +%s)" + +if [ ! -f "$SAMPLE" ]; then + echo "Missing $SAMPLE" >&2 + exit 1 +fi + +BODY="$(mktemp)" +jq --arg rid "$RID" '.report_id = $rid' "$SAMPLE" > "$BODY" +GZIP="$(mktemp)" +gzip -c -f "$BODY" > "$GZIP" + +echo "POST plain JSON..." +curl -sS -o /dev/null -w "plain:%{http_code}\n" -X POST "$URL" \ + -H 'Content-Type: application/json' \ + --data-binary @"$BODY" + +echo "POST gzip (Content-Encoding: gzip)..." +curl -sS -w "\n%{http_code}\n" -X POST "$URL" \ + -H 'Content-Type: application/json' \ + -H 'Content-Encoding: gzip' \ + --data-binary @"$GZIP" + +rm -f "$BODY" "$GZIP" diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_live_cast_api.sh b/sim/cluster0/lab-seeds/backend/scripts/test_live_cast_api.sh new file mode 100755 index 0000000..43590b3 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_live_cast_api.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Smoke-test live cast API (public join + authenticated list). +# +# ./scripts/test_live_cast_api.sh +# COOKIE='ac_crash_sess=…' CRASH_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_live_cast_api.sh +set -euo pipefail + +CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +API_URL="${CRASH_BASE}/api/live_cast.php" +COOKIE_JAR="${TMPDIR:-/tmp}/live_cast_cookies_$$.txt" +trap 'rm -f "$COOKIE_JAR"' EXIT + +CURL_AUTH=() +if [[ -n "${COOKIE:-}" ]]; then + CURL_AUTH=(-H "Cookie: ${COOKIE}") +else + user="${CRASH_USER:-admin}" + pass="${CRASH_PASS:-admin}" + curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ + -d "username=${user}&password=${pass}" \ + "${CRASH_BASE}/login" -o /dev/null + CURL_AUTH=(-b "$COOKIE_JAR") +fi + +sid="smoke-live-$(date +%s)" +echo "POST create_intent (${sid})" +create_code="$(curl -sS -o /tmp/live_create.json -w '%{http_code}' \ + "${CURL_AUTH[@]}" \ + -H 'Content-Type: application/json' \ + -d "{\"action\":\"create_intent\",\"platform\":\"android\",\"device_id\":\"smoke-device\",\"max_duration_s\":120}" \ + "${API_URL}")" +echo "create HTTP ${create_code}" +cat /tmp/live_create.json +echo + +session_id="$(python3 - <<'PY' +import json +try: + d=json.load(open("/tmp/live_create.json")) + print(d.get("session",{}).get("session_id","")) +except Exception: + print("") +PY +)" +if [[ -z "${session_id}" ]]; then + echo "FAIL: no session_id in create response" >&2 + exit 1 +fi + +echo "GET public session" +pub_code="$(curl -sS -o /tmp/live_session.json -w '%{http_code}' \ + "${API_URL}?action=session&session_id=${session_id}")" +echo "session HTTP ${pub_code}" +head -c 300 /tmp/live_session.json +echo + +echo "POST public join_event" +join_code="$(curl -sS -o /tmp/live_join.json -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -d "{\"action\":\"join_event\",\"session_id\":\"${session_id}\",\"event_type\":\"open\",\"platform\":\"chrome\"}" \ + "${API_URL}")" +echo "join HTTP ${join_code}" +cat /tmp/live_join.json +echo + +echo "GET list (auth)" +list_code="$(curl -sS -o /tmp/live_list.json -w '%{http_code}' \ + "${CURL_AUTH[@]}" \ + "${API_URL}?action=list&days=7")" +echo "list HTTP ${list_code}" +head -c 300 /tmp/live_list.json +echo + +for code in "${create_code}" "${pub_code}" "${join_code}" "${list_code}"; do + if [[ "${code}" != "200" ]]; then + echo "FAIL: expected HTTP 200" >&2 + exit 1 + fi +done +echo "OK" diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_rbac_api.sh b/sim/cluster0/lab-seeds/backend/scripts/test_rbac_api.sh new file mode 100755 index 0000000..dc0d2ed --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_rbac_api.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Smoke test RBAC admin API (local orchestration or deployed crashes console). +set -euo pipefail +BASE="${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +COOKIE_JAR="$(mktemp)" +trap 'rm -f "$COOKIE_JAR"' EXIT + +curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ + -d "username=${ADMIN_USER:-admin}&password=${ADMIN_PASS:-admin}" \ + "$BASE/login" >/dev/null + +echo "== rbac panel ==" +curl -sf -b "$COOKIE_JAR" "$BASE/api/rbac.php?action=panel" | grep -q '"ok":true' && echo OK +curl -sf -b "$COOKIE_JAR" "$BASE/api/rbac.php?action=panel" | grep -q 'privilege_sets' && echo OK sets + +echo "All RBAC API checks passed." diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_remote_access_api.sh b/sim/cluster0/lab-seeds/backend/scripts/test_remote_access_api.sh new file mode 100755 index 0000000..861e482 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_remote_access_api.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Smoke test remote access API (SQLite dev or deployed BE). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE="${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +DEVICE_ID="test-ra-device-$(date +%s)" +COOKIE_JAR="$(mktemp)" +trap 'rm -f "$COOKIE_JAR"' EXIT + +login() { + curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ + -d "username=${ADMIN_USER:-admin}&password=${ADMIN_PASS:-admin}" \ + "$BASE/login" >/dev/null +} + +echo "== heartbeat ra disable ==" +curl -sf -X POST -H 'Content-Type: application/json' \ + -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"disable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r1\"}}" \ + "$BASE/api/heartbeat.php" | grep -q '"action":"disabled"' && echo OK + +echo "== heartbeat ra enable (not whitelisted → wait) ==" +curl -sf -X POST -H 'Content-Type: application/json' \ + -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r2\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \ + "$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK + +login +echo "== whitelist device ==" +curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \ + -d "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" \ + "$BASE/api/remote_access.php?action=whitelist" | grep -q '"ok":true' && echo OK + +echo "== poll enable (whitelisted, no session → wait) ==" +curl -sf -X POST -H 'Content-Type: application/json' \ + -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r3\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \ + "$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK + +echo "== open session ==" +curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \ + -d "{\"device_id\":\"$DEVICE_ID\"}" \ + "$BASE/api/remote_access.php?action=open_session" | grep -q '"ok":true' && echo OK + +echo "== poll → connect wireguard ==" +RESP="$(curl -sf -X POST -H 'Content-Type: application/json' \ + -d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r4\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \ + "$BASE/api/heartbeat.php")" +echo "$RESP" | grep -q '"action":"connect"' && echo OK +echo "$RESP" | grep -q 'interface_private_key' && echo OK credentials + +echo "== dashboard API ==" +curl -sf -b "$COOKIE_JAR" "$BASE/api/remote_access.php?action=dashboard" | grep -q '"ok":true' && echo OK + +echo "All remote access API checks passed." diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_rssh_api.sh b/sim/cluster0/lab-seeds/backend/scripts/test_rssh_api.sh new file mode 100755 index 0000000..20088c2 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_rssh_api.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# RSSH control-plane smoke: whitelist → open session → connect payload with bastion fields. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=ra_lib.sh +source "$ROOT/scripts/ra_lib.sh" + +BASE="$(ra_base)" +DEVICE_ID="${RA_DEVICE_ID:-rssh-smoke-$(date +%s)}" +COOKIE_JAR="$(mktemp)" +trap 'rm -f "$COOKIE_JAR"' EXIT + +ra_admin_login "$COOKIE_JAR" + +body=$(printf '{"heartbeat":{"type":"ra","status":"enable","device_id":"%s","random":"r0","tunnel_mode":"rssh","capabilities":["rssh","shell"]}}' "$DEVICE_ID") +code=$(curl -sS -o /tmp/rssh-hb.json -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$(ra_heartbeat_url)") +[[ "$code" == "200" ]] || { echo "FAIL heartbeat enable HTTP $code"; cat /tmp/rssh-hb.json; exit 1; } +grep -q '"action":"wait"' /tmp/rssh-hb.json && echo OK wait + +ra_admin_post "$COOKIE_JAR" whitelist "$(printf '{"device_id":"%s","whitelisted":true}' "$DEVICE_ID")" >/dev/null +ra_admin_post "$COOKIE_JAR" open_session "$(printf '{"device_id":"%s"}' "$DEVICE_ID")" >/dev/null + +body=$(printf '{"heartbeat":{"type":"ra","status":"enable","device_id":"%s","random":"r1","tunnel_mode":"rssh","capabilities":["rssh","shell"]}}' "$DEVICE_ID") +curl -sf -X POST -H 'Content-Type: application/json' -d "$body" "$(ra_heartbeat_url)" > /tmp/rssh-connect.json +grep -q '"action":"connect"' /tmp/rssh-connect.json && echo OK connect +grep -q 'bastion_host' /tmp/rssh-connect.json && echo OK bastion fields +grep -q 'remote_bind_port' /tmp/rssh-connect.json && echo OK remote_bind_port + +echo "All RSSH API checks passed." diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_rssh_unit.sh b/sim/cluster0/lab-seeds/backend/scripts/test_rssh_unit.sh new file mode 100755 index 0000000..591a42b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_rssh_unit.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Unit checks for RsshSessionProvisioner + RsshBastionProvisioner (no DB). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export CRASHES_CONFIG="${CRASHES_CONFIG:-$ROOT/config/config.example.php}" + +php -r ' +require "'"$ROOT"'/src/bootstrap.php"; +$alloc = RsshSessionProvisioner::allocate("test-session-abc"); +assert($alloc["username"] !== ""); +assert($alloc["remote_bind_port"] >= 18000); +assert($alloc["local_forward_port"] === 8022); +$cmds = RsshBastionProvisioner::operatorCommands([ + "rssh_username" => $alloc["username"], + "rssh_remote_port" => $alloc["remote_bind_port"], +]); +assert(isset($cmds["shell"])); +assert(str_contains($cmds["shell"], (string) $alloc["remote_bind_port"])); +echo "OK RsshSessionProvisioner + operatorCommands\n"; +' + +echo "All RSSH PHP unit checks passed." diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_short_links_api.sh b/sim/cluster0/lab-seeds/backend/scripts/test_short_links_api.sh new file mode 100755 index 0000000..377ac1a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_short_links_api.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Short links admin API smoke — session cookie + RBAC. +# Usage: +# COOKIE='ac_crash_sess=…' CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_short_links_api.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE="${CRASHES_BASE:-http://127.0.0.1:8080}" +COOKIE="${COOKIE:-}" +JAR="${COOKIE_JAR:-/tmp/short_links_api_cookies.txt}" + +if [[ -z "$COOKIE" && ! -f "$JAR" ]]; then + echo "Set COOKIE=ac_crash_sess=… or login first (COOKIE_JAR=$JAR)" >&2 + exit 1 +fi + +curl_api() { + local url="$1" + shift + if [[ -n "$COOKIE" ]]; then + curl -fsS -b "$COOKIE" "$@" "$url" + else + curl -fsS -b "$JAR" -c "$JAR" "$@" "$url" + fi +} + +echo "==> dashboard" +curl_api "$BASE/api/short_links.php?action=dashboard" | grep -q '"ok":true' && echo OK + +if [[ "${FULL:-}" == "1" ]]; then + echo "==> mint bearer" + resp="$(curl_api "$BASE/api/short_links.php?action=mint_bearer" \ + -X POST -H 'Content-Type: application/json' \ + -d '{"label":"api-smoke-'"$(date +%s)"'","can_permanent":true,"mint_signer":true}')" + echo "$resp" | grep -q '"token"' && echo OK bearer + signer="$(echo "$resp" | sed -n 's/.*"signer_key":"\([^"]*\)".*/\1/p')" + bearer="$(echo "$resp" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" + bid="$(echo "$resp" | sed -n 's/.*"bearer_id":\([0-9]*\).*/\1/p')" + if [[ -n "$signer" && -n "$bid" ]]; then + echo "==> permanent link" + curl_api "$BASE/api/short_links.php?action=create_link" \ + -X POST -H 'Content-Type: application/json' \ + -d '{"bearer_id":'"$bid"',"url":"https://example.com/perm-'"$(date +%s)"'","ttl":0,"signer":"'"$signer"'"}' \ + | grep -q '"short_url"' && echo OK permanent + fi +fi + +echo "Done" diff --git a/sim/cluster0/lab-seeds/backend/scripts/test_tags_api.sh b/sim/cluster0/lab-seeds/backend/scripts/test_tags_api.sh new file mode 100755 index 0000000..f4062b2 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/test_tags_api.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Smoke-test reports list + tag edit API (session cookie after login). +# +# Usage: +# export CRASH_BASE="https://f0xx.org/app/androidcast_project/crashes" +# export CRASH_USER="admin" +# export CRASH_PASS="secret" +# ./scripts/test_tags_api.sh +# +# Or pass a browser session cookie: +# export CRASH_COOKIE="PHPSESSID=..." +# ./scripts/test_tags_api.sh + +set -euo pipefail + +BASE="${CRASH_BASE:-http://127.0.0.1:8080}" +COOKIE_JAR="${TMPDIR:-/tmp}/crash_api_cookies_$$.txt" +trap 'rm -f "$COOKIE_JAR"' EXIT + +if [[ -z "${CRASH_COOKIE:-}" ]]; then + if [[ -z "${CRASH_USER:-}" || -z "${CRASH_PASS:-}" ]]; then + echo "Set CRASH_USER + CRASH_PASS or CRASH_COOKIE" >&2 + exit 1 + fi + curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ + -d "username=${CRASH_USER}&password=${CRASH_PASS}" \ + "${BASE}/login" -o /dev/null + CURL_AUTH=(-b "$COOKIE_JAR") +else + CURL_AUTH=(-H "Cookie: ${CRASH_COOKIE}") +fi + +echo "== tag catalog ==" +curl -sS "${CURL_AUTH[@]}" "${BASE}/api/tag_catalog.php" | head -c 400 +echo "" + +echo "== reports (first page) ==" +LIST=$(curl -sS "${CURL_AUTH[@]}" "${BASE}/api/reports.php?page=1&per_page=5") +echo "$LIST" | head -c 500 +echo "" + +ID=$(echo "$LIST" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' | head -1) +if [[ -z "$ID" ]]; then + echo "No report id in list; skip tag write test" + exit 0 +fi + +echo "== set tags on report #$ID (duplicate + fixed) ==" +BODY='{"id":'"$ID"',"tags":[{"id":"duplicate","label":"duplicate","bg":"#6d28d9"},{"id":"fixed","label":"fixed","bg":"#047857"}]}' +curl -sS "${CURL_AUTH[@]}" -X POST -H "Content-Type: application/json" \ + -d "$BODY" "${BASE}/api/report_tags.php" +echo "" + +echo "== filter AND duplicate+fixed ==" +curl -sS "${CURL_AUTH[@]}" \ + "${BASE}/api/reports.php?tag=duplicate&tag=fixed&tag_mode=and&per_page=3" | head -c 400 +echo "" + +echo "OK" diff --git a/sim/cluster0/lab-seeds/backend/scripts/upload_fake_graph_sessions.php b/sim/cluster0/lab-seeds/backend/scripts/upload_fake_graph_sessions.php new file mode 100644 index 0000000..101f8c8 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/upload_fake_graph_sessions.php @@ -0,0 +1,56 @@ +#!/usr/bin/env php + $line) { + $payload = json_decode($line, true); + if (!is_array($payload)) { + $fail++; + continue; + } + if ($dryRun) { + $ok++; + continue; + } + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'ignore_errors' => true, + 'timeout' => 20, + ], + ]); + $resp = @file_get_contents($api, false, $ctx); + $code = 0; + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $code = (int) $m[1]; + } + if ($code >= 200 && $code < 300) { + $ok++; + } else { + $fail++; + fwrite(STDERR, "FAIL line " . ($i + 1) . " http=$code resp=$resp\n"); + } +} + +echo "Done: ok=$ok fail=$fail total=" . count($lines) . "\n"; +exit($fail > 0 ? 1 : 0); diff --git a/sim/cluster0/lab-seeds/backend/scripts/validate_be_services.sh b/sim/cluster0/lab-seeds/backend/scripts/validate_be_services.sh new file mode 100755 index 0000000..bb0bf59 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/validate_be_services.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Reachability smoke for mobile-app + console services (public or CRASHES_BASE). +# Exit 0 when all required checks pass; prints FAIL lines for ticket triage. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PUBLIC="${PUBLIC_BASE:-https://apps.f0xx.org}" +CRASHES="${CRASHES_BASE:-${PUBLIC}/app/androidcast_project/crashes}" +GRAPH="${GRAPH_BASE:-${PUBLIC}/app/androidcast_project/graphs}" +HUB="${HUB_BASE:-${PUBLIC}/app/androidcast_project/}" +BUILD="${BUILD_BASE:-${PUBLIC}/app/androidcast_project/build/}" +OTA="${OTA_CHANNEL:-${PUBLIC}/v0/ota/channel/stable.json}" + +SHORTENER="${URL_SHORTENER_BASE:-https://s.f0xx.org}" + +fail=0 +ok() { echo "OK $*"; } +bad() { echo "FAIL $*"; fail=1; } +note() { echo "WARN $*"; } + +http_code() { + curl -sS -o /dev/null -w '%{http_code}' --max-time "${CURL_TIMEOUT:-15}" "$1" 2>/dev/null || echo "000" +} + +js_syntax() { + local url="$1" + local tmp + tmp="$(mktemp --suffix=.js)" + if ! curl -sSLf --max-time "${CURL_TIMEOUT:-15}" "$url" -o "$tmp" 2>/dev/null; then + rm -f "$tmp" + echo "fetch" + return 1 + fi + if node --check "$tmp" 2>/dev/null; then + rm -f "$tmp" + echo "ok" + return 0 + fi + rm -f "$tmp" + echo "syntax" + return 1 +} + +echo "== Android Cast service reachability ==" +echo "PUBLIC=$PUBLIC" +echo + +# Hub +code="$(http_code "$HUB")" +[[ "$code" =~ ^(200|301|302)$ ]] && ok "hub $HUB HTTP $code" || bad "hub $HUB HTTP $code" + +# Crashes console (login redirect OK) +code="$(http_code "$CRASHES/")" +[[ "$code" =~ ^(200|302)$ ]] && ok "crashes $CRASHES/ HTTP $code" || bad "crashes $CRASHES/ HTTP $code" + +# Graphs +code="$(http_code "$GRAPH/")" +[[ "$code" =~ ^(200|302)$ ]] && ok "graphs $GRAPH/ HTTP $code" || bad "graphs $GRAPH/ HTTP $code" + +# Builder +code="$(http_code "$BUILD")" +[[ "$code" =~ ^(200|302)$ ]] && ok "build $BUILD HTTP $code" || bad "build $BUILD HTTP $code" + +# OTA channel (may 404 until wired — warn only) +code="$(http_code "$OTA")" +if [[ "$code" == "200" ]]; then + ok "ota $OTA HTTP $code" +elif [[ "$code" == "404" ]]; then + note "ota $OTA HTTP 404 — BE mount empty or channel missing" +elif [[ "$code" == "400" ]]; then + note "ota $OTA HTTP 400 — FE missing location /v0/ota/ → BE :80 (run install-fe-ota-nginx.sh on FE)" +else + bad "ota $OTA HTTP $code" +fi + +# URL shortener (opt-in vhost — warn until PHP + MariaDB live) +code="$(http_code "$SHORTENER/api/v1/health")" +if [[ "$code" == "200" ]]; then + ok "url-shortener $SHORTENER/api/v1/health HTTP $code" +elif [[ "$code" =~ ^(000|502|503|504)$ ]]; then + note "url-shortener $SHORTENER/api/v1/health HTTP $code — nginx up? sync backend/url-shortener + php-fpm" +else + note "url-shortener $SHORTENER/api/v1/health HTTP $code (stub expects 200 when docroot synced)" +fi + +# Device APIs (no session) — 403 on public edge is OK if upload/heartbeat work +code="$(http_code "$CRASHES/api/diag.php")" +if [[ "$code" == "200" ]]; then + ok "diag API HTTP $code" +elif [[ "$code" == "403" ]]; then + note "diag API HTTP 403 (edge block; use upload/heartbeat for device path)" +else + bad "diag API HTTP $code" +fi +code="$(http_code "$CRASHES/api/heartbeat.php")" +[[ "$code" =~ ^(200|400|405)$ ]] && ok "heartbeat API reachable HTTP $code" || bad "heartbeat API HTTP $code" + +# JS assets +for pair in \ + "crashes-app:${CRASHES}/assets/js/app.js" \ + "graphs-js:${GRAPH}/assets/js/graphs.js" \ + "tickets-js:${CRASHES}/assets/js/tickets.js" \ + "i18n-js:${CRASHES}/assets/js/i18n.js"; do + name="${pair%%:*}" + url="${pair#*:}" + st="$(js_syntax "$url" || true)" + [[ "$st" == "ok" ]] && ok "js $name syntax" || bad "js $name ($st) $url" +done + +# Authenticated API smoke (optional) +if [[ "${RUN_API_SMOKE:-1}" == "1" ]] && command -v bash >/dev/null 2>&1; then + if CRASHES_BASE="$CRASHES" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then + ok "remote_access API smoke" + else + bad "remote_access API smoke" + fi + if CRASHES_BASE="$CRASHES" bash "$ROOT/scripts/test_rbac_api.sh" >/dev/null 2>&1; then + ok "rbac API smoke" + else + bad "rbac API smoke" + fi +fi + +echo +if [[ "$fail" -eq 0 ]]; then + echo "All required service checks passed." +else + echo "Some checks failed — file tickets via ingest or Issues dashboard." +fi +exit "$fail" diff --git a/sim/cluster0/lab-seeds/backend/scripts/verify_auth_totp.php b/sim/cluster0/lab-seeds/backend/scripts/verify_auth_totp.php new file mode 100644 index 0000000..ed75566 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/scripts/verify_auth_totp.php @@ -0,0 +1,14 @@ +/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qiE '^(session|pdo_mysql|pdo_sqlite)$'; then + echo php81 + return + fi + if php -m 2>/dev/null | grep -qi '^session$'; then + echo php + return + fi + if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qi '^session$'; then + echo php81 + return + fi + echo php +} +PHP="$(php_bin)" + +fail=0 +warn=0 + +ok() { echo "OK $*"; } +bad() { echo "FAIL $*"; fail=1; } +note() { echo "WARN $*"; warn=1; } + +echo "== Remote access production verify ==" +echo "backend: $ROOT" +echo + +# MariaDB rejects PostgreSQL-style NULLS LAST/FIRST (SQLite dev may hide this). +if grep -R --include='*.php' -E 'NULLS (LAST|FIRST)' "$ROOT/src" >/dev/null 2>&1; then + bad "MariaDB-incompatible SQL (NULLS LAST/FIRST) under src/" +else + ok "no NULLS LAST/FIRST in PHP SQL" +fi + +# config.php +CFG="$ROOT/config/config.php" +if [[ ! -f "$CFG" ]]; then + bad "config/config.php missing (copy from config.example.php)" +else + ok "config.php present" +fi + +"$PHP" -r ' +$config = require "'"$CFG"'"; +$ra = $config["remote_access"] ?? []; +$pub = trim((string)($ra["wg_server_public_key"] ?? "")); +$ep = trim((string)($ra["wg_endpoint"] ?? "")); +if ($pub === "") { fwrite(STDERR, "FAIL wg_server_public_key is empty — run: wg show wg0 public-key\n"); exit(2); } +if ($ep === "") { fwrite(STDERR, "FAIL wg_endpoint is empty\n"); exit(2); } +if (empty($ra["require_wg_tools"])) { fwrite(STDERR, "WARN require_wg_tools should be true on production\n"); exit(3); } +echo "OK remote_access config: endpoint=$ep, require_wg_tools=" . (($ra["require_wg_tools"] ?? false) ? "true" : "false") . "\n"; +' || { + code=$? + if [[ $code -eq 2 ]]; then fail=1; elif [[ $code -eq 3 ]]; then warn=1; fi +} + +# wireguard-tools +if command -v wg >/dev/null 2>&1; then + ok "wg in PATH ($(command -v wg))" +else + bad "wireguard-tools missing (apk add wireguard-tools)" +fi + +IFACE="${WG_INTERFACE:-wg0}" +if ip link show "$IFACE" >/dev/null 2>&1; then + ok "interface $IFACE up" + if command -v wg >/dev/null 2>&1; then + wg show "$IFACE" 2>/dev/null | head -3 || note "wg show $IFACE returned nothing" + fi +else + note "interface $IFACE not found (data plane not ready; HTTP control plane may still work)" +fi + +# PHP can shell_exec wg (same user as FPM) +if "$PHP" -r 'echo trim((string)@shell_exec("wg genkey 2>/dev/null"));' | grep -qE '.{40,}'; then + ok "php can run wg genkey (FPM user must match for connect)" +else + note "php CLI cannot run wg genkey — ensure www-data can run wg if provision_peers=true" +fi + +# DB tables (via bootstrap) — use FPM user + php81 so PDO/mysql extensions match production +DB_RUN="$PHP" +DB_USER="" +if [[ "$(id -u)" -eq 0 ]] && id nginx >/dev/null 2>&1; then + DB_RUN="sudo -u nginx $PHP" + DB_USER="nginx" +fi +$DB_RUN -r ' +require "'"$ROOT"'/src/bootstrap.php"; +RemoteAccessRepository::ensureSchema(); +$pdo = Database::pdo(); +foreach (["remote_access_devices","remote_access_sessions","remote_access_events"] as $t) { + if (!Database::tableExists($pdo, $t)) { fwrite(STDERR, "FAIL table missing: $t\n"); exit(2); } +} +echo "OK MariaDB/SQLite remote_access tables present\n"; +' || fail=1 + +$DB_RUN -r ' +require "'"$ROOT"'/src/bootstrap.php"; +RemoteAccessRepository::ensureSchema(); +RemoteAccessRepository::listDevices(); +echo "OK listDevices() SQL executes\n"; +' || bad "listDevices() failed (check ORDER BY / MariaDB syntax)" + +# Orphan wg peers (dry-run; run sync_wg_peers.php --apply on BE to fix) +if [[ -f "$ROOT/scripts/sync_wg_peers.php" ]]; then + WG_SYNC="$($DB_RUN "$ROOT/scripts/sync_wg_peers.php" --dry-run 2>/dev/null || true)" + if [[ -n "$WG_SYNC" ]]; then + pruned=$(echo "$WG_SYNC" | awk -F= '/^pruned_peers=/{print $2}') + if [[ -n "$pruned" && "$pruned" != "0" ]]; then + note "wg0 has ${pruned} orphan peer(s) — run: php81 scripts/sync_wg_peers.php --apply" + else + ok "wg0 peers in sync with active sessions" + fi + fi +fi + +# API smoke (local php -S or deployed BASE) +BASE="${BASE:-${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}}" +if [[ -x "$ROOT/scripts/test_remote_access_api.sh" ]]; then + if CRASHES_BASE="$BASE" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then + ok "API smoke test ($BASE)" + else + note "API smoke test failed against $BASE (set BASE or CRASHES_BASE=…/crashes)" + fi +fi + +if [[ "${RA_E2E:-}" == "1" ]] && [[ -x "$ROOT/scripts/ra_e2e_cli.sh" ]]; then + if command -v jq >/dev/null 2>&1; then + if CRASHES_BASE="$BASE" bash "$ROOT/scripts/ra_e2e_cli.sh" >/dev/null 2>&1; then + ok "RA E2E CLI ($BASE)" + else + note "RA E2E CLI failed against $BASE (needs jq, admin login, remote_access RBAC)" + fi + else + note "RA E2E skipped (install jq)" + fi +fi + +echo +if [[ $fail -ne 0 ]]; then + echo "Result: NOT READY ($fail hard failure(s), $warn warning(s))" + exit 1 +fi +if [[ $warn -ne 0 ]]; then + echo "Result: READY WITH WARNINGS ($warn warning(s))" + exit 0 +fi +echo "Result: READY" +exit 0 diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/001_rbac_companies.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/001_rbac_companies.sql new file mode 100644 index 0000000..7710787 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/001_rbac_companies.sql @@ -0,0 +1,46 @@ +-- Phase 1: companies, memberships, devices, report scoping. +-- Idempotent on fresh DB; existing installs: prefer app auto-migration (Database::ensureRbacSchema). +-- Manual run: mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql + +USE androidcast_crashes; + +CREATE TABLE IF NOT EXISTS companies ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_companies_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS company_memberships ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + company_id INT UNSIGNED NOT NULL, + role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member', + permissions_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_membership_user_company (user_id, company_id), + KEY idx_membership_company (company_id), + CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL, + external_id VARCHAR(128) NOT NULL, + display_name VARCHAR(256) NULL, + manufacturer VARCHAR(128) NULL, + model VARCHAR(128) NULL, + source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto', + registered_at_ms BIGINT NOT NULL, + last_seen_at_ms BIGINT NOT NULL, + meta_json LONGTEXT NULL, + UNIQUE KEY uq_devices_company_external (company_id, external_id), + KEY idx_devices_company (company_id), + CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- reports.company_id / reports.device_id: run 002_reports_company_columns.sql as root (app user cannot ALTER) + +INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default'); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/002_reports_company_columns.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/002_reports_company_columns.sql new file mode 100644 index 0000000..caef8f7 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/002_reports_company_columns.sql @@ -0,0 +1,24 @@ +-- Add RBAC columns to existing reports table (requires ALTER — run as MySQL/MariaDB root). +-- App user androidcast typically has SELECT,INSERT,UPDATE,DELETE only. +-- +-- mysql -u root -p androidcast_crashes < sql/migrations/002_reports_company_columns.sql +-- +-- If 001_rbac_companies.sql was not applied yet, run that first. + +USE androidcast_crashes; + +INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default'); + +ALTER TABLE reports + ADD COLUMN IF NOT EXISTS company_id INT UNSIGNED NOT NULL DEFAULT 1 AFTER id, + ADD COLUMN IF NOT EXISTS device_id BIGINT UNSIGNED NULL AFTER company_id; + +UPDATE reports SET company_id = 1 WHERE company_id IS NULL OR company_id = 0; + +ALTER TABLE reports ADD INDEX IF NOT EXISTS idx_reports_company (company_id); +ALTER TABLE reports ADD INDEX IF NOT EXISTS idx_reports_device (device_id); + +INSERT IGNORE INTO company_memberships (user_id, company_id, role) +SELECT u.id, 1, + CASE WHEN LOWER(u.role) IN ('root', 'admin', 'platform_admin') THEN 'owner' ELSE 'member' END +FROM users u; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/003_tickets.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/003_tickets.sql new file mode 100644 index 0000000..1e9e505 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/003_tickets.sql @@ -0,0 +1,32 @@ +-- Tickets (tasks / QA / user issues) — same console as crash reports. +-- Run: mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql + +CREATE TABLE IF NOT EXISTS tickets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + device_id BIGINT UNSIGNED NULL, + ticket_id VARCHAR(128) NOT NULL, + title VARCHAR(256) NOT NULL, + brief TEXT NULL, + body LONGTEXT NOT NULL, + opened_at_ms BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + owner_username VARCHAR(64) NOT NULL DEFAULT '', + verified_by_username VARCHAR(64) NULL, + device_model VARCHAR(128) NULL, + app_package VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + os_name VARCHAR(64) NULL, + os_version VARCHAR(128) NULL, + rating TINYINT UNSIGNED NOT NULL DEFAULT 0, + tags_json LONGTEXT NOT NULL, + payload_json LONGTEXT NOT NULL, + UNIQUE KEY uq_tickets_ticket_id (ticket_id), + KEY idx_tickets_company (company_id), + KEY idx_tickets_opened (opened_at_ms), + KEY idx_tickets_updated (updated_at_ms), + KEY idx_tickets_owner (owner_username), + CONSTRAINT fk_tickets_company FOREIGN KEY (company_id) REFERENCES companies (id), + CONSTRAINT fk_tickets_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/003_tickets.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/003_tickets.sqlite.sql new file mode 100644 index 0000000..415edf8 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/003_tickets.sqlite.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL DEFAULT 1, + device_id INTEGER NULL, + ticket_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + brief TEXT, + body TEXT NOT NULL, + opened_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + owner_username TEXT NOT NULL DEFAULT '', + verified_by_username TEXT, + device_model TEXT, + app_package TEXT, + app_version TEXT, + os_name TEXT, + os_version TEXT, + rating INTEGER NOT NULL DEFAULT 0, + tags_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT NOT NULL, + FOREIGN KEY (company_id) REFERENCES companies(id), + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_tickets_company ON tickets(company_id); +CREATE INDEX IF NOT EXISTS idx_tickets_opened ON tickets(opened_at_ms DESC); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/004_ticket_workflow.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/004_ticket_workflow.sql new file mode 100644 index 0000000..611c090 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/004_ticket_workflow.sql @@ -0,0 +1,18 @@ +-- Ticket workflow, assignees, comments. Run as MySQL root on existing DBs. +-- mysql -u root -p androidcast_crashes < sql/migrations/004_ticket_workflow.sql + +ALTER TABLE tickets + ADD COLUMN workflow_state VARCHAR(32) NOT NULL DEFAULT 'triage' AFTER tags_json, + ADD COLUMN assignees_json LONGTEXT NOT NULL DEFAULT '[]' AFTER workflow_state; + +CREATE TABLE IF NOT EXISTS ticket_comments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ticket_id BIGINT UNSIGNED NOT NULL, + author_username VARCHAR(64) NOT NULL, + body LONGTEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + KEY idx_ticket_comments_ticket (ticket_id), + KEY idx_ticket_comments_created (created_at_ms), + CONSTRAINT fk_ticket_comments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/004_ticket_workflow.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/004_ticket_workflow.sqlite.sql new file mode 100644 index 0000000..5c7a66a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/004_ticket_workflow.sqlite.sql @@ -0,0 +1,13 @@ +ALTER TABLE tickets ADD COLUMN workflow_state TEXT NOT NULL DEFAULT 'triage'; +ALTER TABLE tickets ADD COLUMN assignees_json TEXT NOT NULL DEFAULT '[]'; + +CREATE TABLE IF NOT EXISTS ticket_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + author_username TEXT NOT NULL, + body TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket ON ticket_comments (ticket_id); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/005_ticket_attachments.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/005_ticket_attachments.sql new file mode 100644 index 0000000..6a824a0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/005_ticket_attachments.sql @@ -0,0 +1,20 @@ +-- Ticket attachments: uploaded files + external links (incl. Google URLs). +-- mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql + +CREATE TABLE IF NOT EXISTS ticket_attachments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ticket_id BIGINT UNSIGNED NOT NULL, + kind ENUM('file', 'link') NOT NULL, + title VARCHAR(256) NOT NULL DEFAULT '', + external_url TEXT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'generic', + storage_name VARCHAR(128) NULL, + original_name VARCHAR(256) NULL, + mime_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NULL, + created_by VARCHAR(64) NOT NULL, + created_at_ms BIGINT NOT NULL, + KEY idx_ticket_attachments_ticket (ticket_id), + KEY idx_ticket_attachments_created (created_at_ms), + CONSTRAINT fk_ticket_attachments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/005_ticket_attachments.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/005_ticket_attachments.sqlite.sql new file mode 100644 index 0000000..f4d8b6b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/005_ticket_attachments.sqlite.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ticket_attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('file', 'link')), + title TEXT NOT NULL DEFAULT '', + external_url TEXT NULL, + provider TEXT NOT NULL DEFAULT 'generic', + storage_name TEXT NULL, + original_name TEXT NULL, + mime_type TEXT NULL, + size_bytes INTEGER NULL, + created_by TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_ticket_attachments_ticket ON ticket_attachments (ticket_id); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/006_graph_sessions.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/006_graph_sessions.sql new file mode 100644 index 0000000..820bb04 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/006_graph_sessions.sql @@ -0,0 +1,30 @@ +-- Graph session metrics (direct MariaDB ingest; no ETL). +-- Run as root on existing servers: mysql -u root -p androidcast_crashes < sql/migrations/006_graph_sessions.sql + +CREATE TABLE IF NOT EXISTS graph_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + user_id INT UNSIGNED NULL, + device_id VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + sdk_int INT NULL, + started_at_ms BIGINT NOT NULL, + ended_at_ms BIGINT NOT NULL, + duration_s INT NOT NULL, + completed TINYINT(1) NOT NULL DEFAULT 1, + direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender', + transport VARCHAR(24) NOT NULL DEFAULT 'wifi', + avg_kbps INT NOT NULL DEFAULT 0, + recv_kbps INT NOT NULL DEFAULT 0, + packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0, + ntp_source VARCHAR(32) NOT NULL DEFAULT 'device', + ntp_correction_s INT NULL, + install_source VARCHAR(32) NOT NULL DEFAULT 'direct', + meta_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_graph_session_id (session_id), + KEY idx_graph_company_time (company_id, started_at_ms), + KEY idx_graph_device (device_id), + KEY idx_graph_app_version (app_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/006_graph_sessions.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/006_graph_sessions.sqlite.sql new file mode 100644 index 0000000..005a86c --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/006_graph_sessions.sqlite.sql @@ -0,0 +1,6 @@ +-- SQLite graph_sessions indexes (table may already exist via GraphRepository::ensureSchema). + +CREATE INDEX IF NOT EXISTS idx_graph_company_time ON graph_sessions (company_id, started_at_ms); +CREATE INDEX IF NOT EXISTS idx_graph_user_time ON graph_sessions (user_id, started_at_ms); +CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms); +CREATE INDEX IF NOT EXISTS idx_graph_device ON graph_sessions (device_id); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/007_remote_access.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/007_remote_access.sql new file mode 100644 index 0000000..0e9113b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/007_remote_access.sql @@ -0,0 +1,56 @@ +-- Remote access control plane (WireGuard v1, reverse SSH reserved). +-- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql + +CREATE TABLE IF NOT EXISTS remote_access_devices ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(128) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + whitelisted TINYINT(1) NOT NULL DEFAULT 0, + opt_in_mode ENUM('none','wireguard','rssh') NOT NULL DEFAULT 'none', + last_random VARCHAR(96) NULL, + app_version VARCHAR(64) NULL, + last_seen_at TIMESTAMP NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_ra_device (device_id), + KEY idx_ra_device_company (company_id), + KEY idx_ra_device_whitelist (whitelisted, opt_in_mode) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS remote_access_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(36) NOT NULL, + device_id VARCHAR(128) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + operator_user_id INT UNSIGNED NULL, + status ENUM('pending','active','inactive','closed','closed_timeout') NOT NULL DEFAULT 'pending', + tunnel ENUM('wireguard','ssh_reverse') NOT NULL DEFAULT 'wireguard', + endpoint VARCHAR(255) NULL, + wg_client_private_key VARCHAR(64) NULL, + wg_client_address VARCHAR(64) NULL, + wg_server_public_key VARCHAR(64) NULL, + wg_peer_allowed_ips VARCHAR(255) NULL, + expires_at INT UNSIGNED NULL, + last_activity_at TIMESTAMP NULL, + close_reason VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP NULL, + UNIQUE KEY uq_ra_session (session_id), + KEY idx_ra_sess_device (device_id, status), + KEY idx_ra_sess_status (status, last_activity_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS remote_access_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(36) NULL, + device_id VARCHAR(128) NOT NULL, + actor_user_id INT UNSIGNED NULL, + action VARCHAR(64) NOT NULL, + reason VARCHAR(255) NULL, + meta_json LONGTEXT NULL, + client_ip VARCHAR(64) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_ra_evt_device (device_id, created_at), + KEY idx_ra_evt_session (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/007_remote_access.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/007_remote_access.sqlite.sql new file mode 100644 index 0000000..ea95223 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/007_remote_access.sqlite.sql @@ -0,0 +1,50 @@ +-- SQLite dev mirror for remote access tables. + +CREATE TABLE IF NOT EXISTS remote_access_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL DEFAULT 1, + whitelisted INTEGER NOT NULL DEFAULT 0, + opt_in_mode TEXT NOT NULL DEFAULT 'none', + last_random TEXT NULL, + app_version TEXT NULL, + last_seen_at TEXT NULL, + notes TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS remote_access_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + device_id TEXT NOT NULL, + company_id INTEGER NOT NULL DEFAULT 1, + operator_user_id INTEGER NULL, + status TEXT NOT NULL DEFAULT 'pending', + tunnel TEXT NOT NULL DEFAULT 'wireguard', + endpoint TEXT NULL, + wg_client_private_key TEXT NULL, + wg_client_address TEXT NULL, + wg_server_public_key TEXT NULL, + wg_peer_allowed_ips TEXT NULL, + expires_at INTEGER NULL, + last_activity_at TEXT NULL, + close_reason TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + closed_at TEXT NULL +); + +CREATE TABLE IF NOT EXISTS remote_access_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NULL, + device_id TEXT NOT NULL, + actor_user_id INTEGER NULL, + action TEXT NOT NULL, + reason TEXT NULL, + meta_json TEXT NULL, + client_ip TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_ra_evt_device ON remote_access_events(device_id, created_at); +CREATE INDEX IF NOT EXISTS idx_ra_sess_device ON remote_access_sessions(device_id, status); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/008_auth_email_2fa.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/008_auth_email_2fa.sql new file mode 100644 index 0000000..ecb4977 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/008_auth_email_2fa.sql @@ -0,0 +1,41 @@ +-- Email registration + 2FA tables (MariaDB). Run as DB root once; SQLite dev auto-migrates via AuthEmailSchema. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS email_normalized VARCHAR(254) NULL, + ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP NULL, + ADD COLUMN IF NOT EXISTS status ENUM('pending','active','locked','disabled') NOT NULL DEFAULT 'active', + ADD COLUMN IF NOT EXISTS recovery_email_normalized VARCHAR(254) NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email_normalized ON users (email_normalized); + +CREATE TABLE IF NOT EXISTS auth_tokens ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NULL, + purpose ENUM('verify_email','reset_password','login_magic') NOT NULL, + token_hash CHAR(64) NOT NULL, + email_normalized VARCHAR(254) NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_auth_tokens_hash (token_hash), + KEY idx_auth_tokens_email (email_normalized) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS auth_factors ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + type ENUM('totp','webauthn','backup_code') NOT NULL, + secret_encrypted TEXT NULL, + label VARCHAR(64) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_auth_factors_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS auth_attempts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ip_hash CHAR(64) NOT NULL, + username_hash CHAR(64) NULL, + outcome VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_auth_attempts_ip (ip_hash, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/008_graph_sessions_indexes.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/008_graph_sessions_indexes.sql new file mode 100644 index 0000000..dfaadfc --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/008_graph_sessions_indexes.sql @@ -0,0 +1,5 @@ +-- Extra graph_sessions indexes for graphs dashboard (user scope + platform-wide time range). +-- Idempotent on MariaDB 10.5+: duplicate index names are ignored manually when re-run. + +CREATE INDEX IF NOT EXISTS idx_graph_user_time ON graph_sessions (user_id, started_at_ms); +CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/009_rssh_sessions.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/009_rssh_sessions.sql new file mode 100644 index 0000000..86f03c5 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/009_rssh_sessions.sql @@ -0,0 +1,8 @@ +-- RSSH session credentials (MariaDB). Idempotent ALTERs. +ALTER TABLE remote_access_sessions + ADD COLUMN IF NOT EXISTS rssh_bastion_host VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS rssh_bastion_port INT UNSIGNED NULL, + ADD COLUMN IF NOT EXISTS rssh_username VARCHAR(64) NULL, + ADD COLUMN IF NOT EXISTS rssh_secret VARCHAR(128) NULL, + ADD COLUMN IF NOT EXISTS rssh_remote_port INT UNSIGNED NULL, + ADD COLUMN IF NOT EXISTS rssh_local_port INT UNSIGNED NULL DEFAULT 8022; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/009_rssh_sessions.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/009_rssh_sessions.sqlite.sql new file mode 100644 index 0000000..1c33681 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/009_rssh_sessions.sqlite.sql @@ -0,0 +1 @@ +-- RSSH columns for SQLite (applied via Database::ensureSchema). diff --git a/sim/cluster0/lab-seeds/backend/sql/schema.mariadb.sql b/sim/cluster0/lab-seeds/backend/sql/schema.mariadb.sql new file mode 100644 index 0000000..0722610 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/schema.mariadb.sql @@ -0,0 +1,127 @@ +-- Fresh MariaDB install for Android Cast crash reporter. +-- Run as root: mysql -u root -p < sql/schema.mariadb.sql +-- App user needs: SELECT, INSERT, UPDATE, DELETE on androidcast_crashes.* + +CREATE DATABASE IF NOT EXISTS androidcast_crashes + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE androidcast_crashes; + +CREATE TABLE IF NOT EXISTS companies ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_companies_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('root', 'platform_admin', 'admin', 'viewer') NOT NULL DEFAULT 'viewer', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS company_memberships ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + company_id INT UNSIGNED NOT NULL, + role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member', + permissions_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_membership_user_company (user_id, company_id), + KEY idx_membership_company (company_id), + CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL, + external_id VARCHAR(128) NOT NULL, + display_name VARCHAR(256) NULL, + manufacturer VARCHAR(128) NULL, + model VARCHAR(128) NULL, + source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto', + registered_at_ms BIGINT NOT NULL, + last_seen_at_ms BIGINT NOT NULL, + meta_json LONGTEXT NULL, + UNIQUE KEY uq_devices_company_external (company_id, external_id), + KEY idx_devices_company (company_id), + CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + device_id BIGINT UNSIGNED NULL, + report_id VARCHAR(128) NOT NULL, + fingerprint VARCHAR(64) NOT NULL, + crash_type VARCHAR(32) NOT NULL, + generated_at_ms BIGINT NOT NULL, + received_at_ms BIGINT NOT NULL, + device_model VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + payload_json LONGTEXT NOT NULL, + tags_json LONGTEXT NOT NULL, + UNIQUE KEY uq_reports_report_id (report_id), + KEY idx_reports_company (company_id), + KEY idx_reports_device (device_id), + KEY idx_reports_generated (generated_at_ms), + KEY idx_reports_received (received_at_ms), + KEY idx_reports_fingerprint (fingerprint), + KEY idx_reports_fp_received (fingerprint, received_at_ms), + CONSTRAINT fk_reports_company FOREIGN KEY (company_id) REFERENCES companies (id), + CONSTRAINT fk_reports_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS report_views ( + user_id INT UNSIGNED NOT NULL, + report_id BIGINT UNSIGNED NOT NULL, + viewed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_id, report_id), + CONSTRAINT fk_report_views_report + FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tickets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + device_id BIGINT UNSIGNED NULL, + ticket_id VARCHAR(128) NOT NULL, + title VARCHAR(256) NOT NULL, + brief TEXT NULL, + body LONGTEXT NOT NULL, + opened_at_ms BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + owner_username VARCHAR(64) NOT NULL DEFAULT '', + verified_by_username VARCHAR(64) NULL, + device_model VARCHAR(128) NULL, + app_package VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + os_name VARCHAR(64) NULL, + os_version VARCHAR(128) NULL, + rating TINYINT UNSIGNED NOT NULL DEFAULT 0, + tags_json LONGTEXT NOT NULL, + payload_json LONGTEXT NOT NULL, + UNIQUE KEY uq_tickets_ticket_id (ticket_id), + KEY idx_tickets_company (company_id), + KEY idx_tickets_opened (opened_at_ms), + KEY idx_tickets_updated (updated_at_ms), + KEY idx_tickets_owner (owner_username), + CONSTRAINT fk_tickets_company FOREIGN KEY (company_id) REFERENCES companies (id), + CONSTRAINT fk_tickets_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default'); + +INSERT IGNORE INTO users (id, username, password_hash, role) +VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root'); +-- default password: admin — change in production + +INSERT IGNORE INTO company_memberships (user_id, company_id, role) +VALUES (1, 1, 'owner'); diff --git a/sim/cluster0/lab-seeds/backend/sql/schema.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/schema.sqlite.sql new file mode 100644 index 0000000..9a1fdbe --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/schema.sqlite.sql @@ -0,0 +1,113 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS company_memberships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + company_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + permissions_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, company_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + external_id TEXT NOT NULL, + display_name TEXT, + manufacturer TEXT, + model TEXT, + source TEXT NOT NULL DEFAULT 'auto', + registered_at_ms INTEGER NOT NULL, + last_seen_at_ms INTEGER NOT NULL, + meta_json TEXT, + UNIQUE (company_id, external_id), + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL DEFAULT 1, + device_id INTEGER, + report_id TEXT NOT NULL UNIQUE, + fingerprint TEXT NOT NULL, + crash_type TEXT NOT NULL, + generated_at_ms INTEGER NOT NULL, + received_at_ms INTEGER NOT NULL, + device_model TEXT, + app_version TEXT, + payload_json TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + FOREIGN KEY (company_id) REFERENCES companies(id), + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS report_views ( + user_id INTEGER NOT NULL, + report_id INTEGER NOT NULL, + viewed_at_ms INTEGER NOT NULL, + PRIMARY KEY (user_id, report_id), + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL DEFAULT 1, + device_id INTEGER NULL, + ticket_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + brief TEXT, + body TEXT NOT NULL, + opened_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + owner_username TEXT NOT NULL DEFAULT '', + verified_by_username TEXT, + device_model TEXT, + app_package TEXT, + app_version TEXT, + os_name TEXT, + os_version TEXT, + rating INTEGER NOT NULL DEFAULT 0, + tags_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT NOT NULL, + FOREIGN KEY (company_id) REFERENCES companies(id), + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_tickets_company ON tickets(company_id); +CREATE INDEX IF NOT EXISTS idx_tickets_opened ON tickets(opened_at_ms DESC); + +CREATE INDEX IF NOT EXISTS idx_reports_company ON reports(company_id); +CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC); +CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC); +CREATE INDEX IF NOT EXISTS idx_reports_fingerprint ON reports(fingerprint); +CREATE INDEX IF NOT EXISTS idx_reports_company_fingerprint ON reports(company_id, fingerprint); +CREATE INDEX IF NOT EXISTS idx_reports_company_received ON reports(company_id, received_at_ms); +CREATE INDEX IF NOT EXISTS idx_reports_fp_received ON reports(fingerprint, received_at_ms); + +INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default'); + +INSERT OR IGNORE INTO users (id, username, password_hash, role) +VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root'); +-- password: admin + +INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) +VALUES (1, 1, 'owner'); diff --git a/sim/cluster0/lab-seeds/backend/src/AnalyticsHead.php b/sim/cluster0/lab-seeds/backend/src/AnalyticsHead.php new file mode 100644 index 0000000..f520eee --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/AnalyticsHead.php @@ -0,0 +1,26 @@ + $measurementId, + 'platform' => $platform, + 'basePath' => $basePath, + 'debug' => (bool) cfg('analytics.debug', false), + ]; + $json = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP); + if ($json === false) { + return; + } + echo '', "\n"; + echo '', "\n"; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/Auth.php b/sim/cluster0/lab-seeds/backend/src/Auth.php new file mode 100644 index 0000000..f7db881 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/Auth.php @@ -0,0 +1,175 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 56 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 a6e49e8d07788ac2b4ea48f886f9a46ec6f5c1eab5b9723e1554e1fe53765efd + */ +declare(strict_types=1); + +final class Auth { + public static function user(): ?array { + $u = $_SESSION['user'] ?? null; + if (!$u || !empty($u['companies'])) { + return $u; + } + $uid = (int) ($u['id'] ?? 0); + if ($uid <= 0) { + return $u; + } + $stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1'); + $stmt->execute([$uid]); + $row = $stmt->fetch(); + if (!$row) { + return $u; + } + Database::ensureRbacSchema(); + Rbac::seedMembershipsForUser($uid, (string) ($row['role'] ?? 'viewer')); + $_SESSION['user'] = Rbac::buildSessionUser($row); + return $_SESSION['user']; + } + + public static function check(): void { + if (!self::user()) { + header('Location: ' . self::authUrl('/login')); + exit; + } + } + + public static function can(string $action): bool { + return Rbac::can($action); + } + + public static function canEditTags(?int $companyId = null): bool { + return Rbac::can('edit_tags', $companyId); + } + + /** Ticket owner or company admin+ (same gate as tag edit for alpha). */ + public static function canEditTicket(array $ticket, ?int $companyId = null): bool { + if (self::canEditTags($companyId)) { + return true; + } + $user = self::user(); + if (!$user) { + return false; + } + $owner = trim((string) ($ticket['owner_username'] ?? '')); + return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0; + } + + /** + * @return true|'pending_2fa'|false + */ + public static function login(string $username, string $password): bool|string { + if (AuthAttempts::isRateLimited($username)) { + return false; + } + $pdo = Database::pdo(); + AuthEmailSchema::ensure($pdo); + $stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1'); + $stmt->execute([$username]); + $row = $stmt->fetch(); + if (!$row || !password_verify($password, $row['password_hash'])) { + AuthAttempts::record('login_fail', $username); + return false; + } + $status = (string) ($row['status'] ?? 'active'); + if ($status === 'pending') { + return false; + } + if ($status === 'locked' || $status === 'disabled') { + AuthAttempts::record('login_fail', $username); + return false; + } + $uid = (int) ($row['id'] ?? 0); + if ($uid > 0 && AuthFactors::hasTotp($uid)) { + $_SESSION['pending_2fa_user_id'] = $uid; + $_SESSION['pending_2fa_exp'] = time() + 300; + AuthAttempts::record('login_ok', $username); + return 'pending_2fa'; + } + $_SESSION['user'] = Rbac::buildSessionUser($row); + AuthAttempts::record('login_ok', $username); + return true; + } + + public static function completeTotpLogin(string $code): bool { + $uid = (int) ($_SESSION['pending_2fa_user_id'] ?? 0); + $exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0); + if ($uid <= 0 || $exp < time()) { + self::clearPending2fa(); + return false; + } + $secret = AuthFactors::getTotpSecret($uid); + if ($secret === null || !AuthTotp::verify($secret, $code)) { + return false; + } + self::clearPending2fa(); + $stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1'); + $stmt->execute([$uid]); + $row = $stmt->fetch(); + if (!$row) { + return false; + } + $_SESSION['user'] = Rbac::buildSessionUser($row); + return true; + } + + public static function pending2faUserId(): int { + $exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0); + if ($exp < time()) { + return 0; + } + return (int) ($_SESSION['pending_2fa_user_id'] ?? 0); + } + + public static function clearPending2fa(): void { + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']); + } + + public static function logout(): void { + self::clearPending2fa(); + unset($_SESSION['user']); + } + + public static function basePath(): string { + $bp = rtrim((string) cfg('base_path', ''), '/'); + if ($bp === '') { + return $bp; + } + // If nginx serves /issues/ but config.php still has legacy /crashes, asset URLs must match nginx. + $uri = (string) ($_SERVER['REQUEST_URI'] ?? ''); + if (str_contains($uri, '/issues') && str_ends_with($bp, '/crashes')) { + return substr($bp, 0, -strlen('/crashes')) . '/issues'; + } + return $bp; + } + + /** Shared project prefix, e.g. /app/androidcast_project */ + public static function projectBasePath(): string { + $p = trim((string) cfg('project_base_path', '')); + if ($p !== '') { + return rtrim($p, '/'); + } + $bp = self::basePath(); + if (preg_match('#^(.+)/(?:crashes|issues|graphs|build)$#', $bp, $m)) { + return $m[1]; + } + return '/app/androidcast_project'; + } + + /** Login/logout/register/2FA live at project root, not under /issues/. */ + public static function authUrl(string $path = ''): string { + $root = self::projectBasePath(); + $path = '/' . ltrim($path, '/'); + if ($path === '/' || $path === '') { + return $root; + } + return $root . $path; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/AuthAttempts.php b/sim/cluster0/lab-seeds/backend/src/AuthAttempts.php new file mode 100644 index 0000000..c8d5e38 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/AuthAttempts.php @@ -0,0 +1,108 @@ +prepare( + 'INSERT INTO auth_attempts (ip_hash, username_hash, outcome, created_at) VALUES (?, ?, ?, ?)' + )->execute([$ipHash, $userHash, $outcome, date('Y-m-d H:i:s')]); + } + + public static function isRateLimited(?string $username): bool { + self::ensureSchema(); + $max = max(3, (int) cfg('auth.max_attempts', 8)); + $windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60); + $since = date('Y-m-d H:i:s', time() - $windowSec); + $pdo = Database::pdo(); + $ipHash = self::hashIp(self::clientIp()); + $st = $pdo->prepare( + "SELECT COUNT(*) FROM auth_attempts WHERE ip_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?" + ); + $st->execute([$ipHash, $since]); + if ((int) $st->fetchColumn() >= $max) { + return true; + } + if ($username !== null && $username !== '') { + $userHash = self::hashUsername($username); + $st = $pdo->prepare( + "SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?" + ); + $st->execute([$userHash, $since]); + if ((int) $st->fetchColumn() >= $max) { + return true; + } + } + return false; + } + + public static function recentFailureCount(string $username): int { + self::ensureSchema(); + $windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60); + $since = date('Y-m-d H:i:s', time() - $windowSec); + $st = Database::pdo()->prepare( + "SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?" + ); + $st->execute([self::hashUsername($username), $since]); + return (int) $st->fetchColumn(); + } + + public static function clearForUser(int $userId): int { + self::ensureSchema(); + $st = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1'); + $st->execute([$userId]); + $username = $st->fetchColumn(); + if (!is_string($username) || $username === '') { + return 0; + } + return self::clearForUsername($username); + } + + public static function clearForUsername(string $username): int { + self::ensureSchema(); + $hash = self::hashUsername($username); + $st = Database::pdo()->prepare('DELETE FROM auth_attempts WHERE username_hash = ?'); + $st->execute([$hash]); + $deleted = $st->rowCount(); + $pdo = Database::pdo(); + $pdo->prepare( + "UPDATE users SET status = 'active' WHERE username = ? AND status = 'locked'" + )->execute([$username]); + return $deleted; + } + + public static function clientIp(): string { + $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; + if (is_string($xff) && $xff !== '') { + $parts = explode(',', $xff); + return trim($parts[0]); + } + return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); + } + + public static function hashIp(string $ip): string { + return hash_hmac('sha256', trim($ip), self::pepper()); + } + + public static function hashUsername(string $username): string { + return hash_hmac('sha256', strtolower(trim($username)), self::pepper()); + } + + private static function pepper(): string { + $p = (string) cfg('auth.encryption_key', ''); + if ($p === '') { + $p = 'dev-change-me-set-auth.encryption_key-in-config.php'; + } + return $p; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/AuthCrypto.php b/sim/cluster0/lab-seeds/backend/src/AuthCrypto.php new file mode 100644 index 0000000..8d68d7e --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/AuthCrypto.php @@ -0,0 +1,68 @@ + Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL', + 'email_verified_at' => Database::isMysql() ? 'TIMESTAMP NULL' : 'TEXT NULL', + 'status' => Database::isMysql() + ? "ENUM('pending','active','locked','disabled') NOT NULL DEFAULT 'active'" + : "TEXT NOT NULL DEFAULT 'active'", + 'recovery_email_normalized' => Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL', + ]; + foreach ($cols as $name => $ddl) { + if (in_array($name, $existing, true)) { + continue; + } + $pdo->exec('ALTER TABLE users ADD COLUMN ' . $name . ' ' . $ddl); + $existing[] = $name; + } + } + + private static function ensureAuthTokens(PDO $pdo): void { + if (Database::tableExists($pdo, 'auth_tokens')) { + return; + } + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE auth_tokens ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NULL, + purpose ENUM('verify_email','reset_password','login_magic') NOT NULL, + token_hash CHAR(64) NOT NULL, + email_normalized VARCHAR(254) NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_auth_tokens_hash (token_hash), + KEY idx_auth_tokens_email (email_normalized) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + return; + } + $pdo->exec( + "CREATE TABLE auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NULL, + purpose TEXT NOT NULL, + token_hash TEXT NOT NULL, + email_normalized TEXT NULL, + expires_at TEXT NOT NULL, + used_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens (token_hash)'); + } + + private static function ensureAuthFactors(PDO $pdo): void { + if (Database::tableExists($pdo, 'auth_factors')) { + return; + } + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE auth_factors ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + type ENUM('totp','webauthn','backup_code') NOT NULL, + secret_encrypted TEXT NULL, + label VARCHAR(64) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_auth_factors_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + return; + } + $pdo->exec( + "CREATE TABLE auth_factors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL, + secret_encrypted TEXT NULL, + label TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + + private static function ensureAuthAttempts(PDO $pdo): void { + if (Database::tableExists($pdo, 'auth_attempts')) { + return; + } + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE auth_attempts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ip_hash CHAR(64) NOT NULL, + username_hash CHAR(64) NULL, + outcome VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_auth_attempts_ip (ip_hash, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + return; + } + $pdo->exec( + "CREATE TABLE auth_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_hash TEXT NOT NULL, + username_hash TEXT NULL, + outcome TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/AuthFactors.php b/sim/cluster0/lab-seeds/backend/src/AuthFactors.php new file mode 100644 index 0000000..ef92783 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/AuthFactors.php @@ -0,0 +1,56 @@ +prepare( + "SELECT id FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1" + ); + $st->execute([$userId]); + return (bool) $st->fetchColumn(); + } + + public static function getTotpSecret(int $userId): ?string { + if ($userId <= 0) { + return null; + } + self::ensureSchema(); + $st = Database::pdo()->prepare( + "SELECT secret_encrypted FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1" + ); + $st->execute([$userId]); + $enc = $st->fetchColumn(); + if (!is_string($enc) || $enc === '') { + return null; + } + $plain = AuthCrypto::decrypt($enc); + return $plain !== '' ? $plain : null; + } + + public static function enrollTotp(int $userId, string $secret): void { + self::ensureSchema(); + $enc = AuthCrypto::encrypt($secret); + $pdo = Database::pdo(); + $pdo->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]); + $pdo->prepare( + 'INSERT INTO auth_factors (user_id, type, secret_encrypted, label, created_at) VALUES (?, ?, ?, ?, ?)' + )->execute([$userId, 'totp', $enc, 'Authenticator', date('Y-m-d H:i:s')]); + } + + public static function removeTotp(int $userId): void { + self::ensureSchema(); + Database::pdo()->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php new file mode 100644 index 0000000..c6d3168 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php @@ -0,0 +1,142 @@ +'); + $replyTo = (string) cfg('mail.reply_to', ''); + $transport = strtolower((string) cfg('mail.transport', 'smtp')); + if ($transport === 'sendmail') { + return self::sendMail($to, $subject, $bodyText, $from, $replyTo); + } + return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo); + } + + public static function sendVerifyEmail(string $to, string $verifyUrl): bool { + $subject = 'Verify your Android Cast account'; + $body = "Open this link to verify your email (valid 24h):\n\n" . $verifyUrl . "\n"; + return self::send($to, $subject, $body); + } + + private static function sendMail(string $to, string $subject, string $body, string $from, string $replyTo): bool { + $headers = "From: {$from}\r\n"; + if ($replyTo !== '') { + $headers .= "Reply-To: {$replyTo}\r\n"; + } + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + return @mail($to, $subject, $body, $headers); + } + + private static function sendSmtp(string $to, string $subject, string $body, string $from, string $replyTo): bool { + $host = (string) cfg('mail.smtp.host', '127.0.0.1'); + $port = (int) cfg('mail.smtp.port', 587); + $enc = strtolower((string) cfg('mail.smtp.encryption', 'tls')); + $user = (string) cfg('mail.smtp.username', ''); + $pass = (string) cfg('mail.smtp.password', ''); + $remote = ($enc === 'ssl' ? 'ssl://' : '') . $host; + $errno = 0; + $errstr = ''; + $fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15); + if ($fp === false) { + error_log('AuthMailer SMTP connect failed: ' . $errstr); + return self::sendMail($to, $subject, $body, $from, $replyTo); + } + stream_set_timeout($fp, 15); + if (!self::smtpExpect($fp, [220])) { + fclose($fp); + return false; + } + $ehloHost = 'localhost'; + fwrite($fp, "EHLO {$ehloHost}\r\n"); + if (!self::smtpExpect($fp, [250])) { + fclose($fp); + return false; + } + if ($enc === 'tls') { + fwrite($fp, "STARTTLS\r\n"); + if (!self::smtpExpect($fp, [220])) { + fclose($fp); + return false; + } + if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + fclose($fp); + return false; + } + fwrite($fp, "EHLO {$ehloHost}\r\n"); + if (!self::smtpExpect($fp, [250])) { + fclose($fp); + return false; + } + } + if ($user !== '') { + fwrite($fp, "AUTH LOGIN\r\n"); + if (!self::smtpExpect($fp, [334])) { + fclose($fp); + return false; + } + fwrite($fp, base64_encode($user) . "\r\n"); + if (!self::smtpExpect($fp, [334])) { + fclose($fp); + return false; + } + fwrite($fp, base64_encode($pass) . "\r\n"); + if (!self::smtpExpect($fp, [235])) { + fclose($fp); + return false; + } + } + $fromAddr = self::extractAddress($from); + fwrite($fp, "MAIL FROM:<{$fromAddr}>\r\n"); + if (!self::smtpExpect($fp, [250])) { + fclose($fp); + return false; + } + fwrite($fp, "RCPT TO:<{$to}>\r\n"); + if (!self::smtpExpect($fp, [250, 251])) { + fclose($fp); + return false; + } + fwrite($fp, "DATA\r\n"); + if (!self::smtpExpect($fp, [354])) { + fclose($fp); + return false; + } + $msg = "From: {$from}\r\n"; + if ($replyTo !== '') { + $msg .= "Reply-To: {$replyTo}\r\n"; + } + $msg .= "To: {$to}\r\nSubject: {$subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n"; + fwrite($fp, $msg); + if (!self::smtpExpect($fp, [250])) { + fclose($fp); + return false; + } + fwrite($fp, "QUIT\r\n"); + fclose($fp); + return true; + } + + /** @param resource $fp @param list $codes */ + private static function smtpExpect($fp, array $codes): bool { + $line = ''; + while (($chunk = fgets($fp, 512)) !== false) { + $line .= $chunk; + if (strlen($chunk) >= 4 && $chunk[3] === ' ') { + break; + } + } + $code = (int) substr(trim($line), 0, 3); + return in_array($code, $codes, true); + } + + private static function extractAddress(string $from): string { + if (preg_match('/<([^>]+)>/', $from, $m)) { + return trim($m[1]); + } + return trim($from); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/AuthRegistration.php b/sim/cluster0/lab-seeds/backend/src/AuthRegistration.php new file mode 100644 index 0000000..af9ade4 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/AuthRegistration.php @@ -0,0 +1,102 @@ + false, 'error' => 'rate_limited']; + } + $emailNorm = self::normalizeEmail($email); + if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) { + AuthAttempts::record('register_fail', $username ?: $email); + return ['ok' => false, 'error' => 'invalid_email']; + } + if (strlen($password) < 10) { + AuthAttempts::record('register_fail', $username ?: $email); + return ['ok' => false, 'error' => 'weak_password']; + } + $username = trim($username); + if ($username === '') { + $username = strstr($emailNorm, '@', true) ?: $emailNorm; + } + $pdo = Database::pdo(); + $st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); + $st->execute([$emailNorm, $username]); + if ($st->fetchColumn()) { + AuthAttempts::record('register_fail', $username); + return ['ok' => false, 'error' => 'already_registered']; + } + $hash = password_hash($password, PASSWORD_DEFAULT); + $pdo->prepare( + 'INSERT INTO users (username, password_hash, role, email_normalized, status) VALUES (?, ?, ?, ?, ?)' + )->execute([ + $username, + $hash, + 'viewer', + $emailNorm, + 'pending', + ]); + $userId = (int) $pdo->lastInsertId(); + $token = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $token); + $expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S); + $pdo->prepare( + 'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?)' + )->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]); + $base = rtrim((string) cfg('base_path', ''), '/'); + $origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/'); + $verifyUrl = $origin . $base . '/verify-email?token=' . urlencode($token); + if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) { + error_log('AuthRegistration: verify mail failed for ' . $emailNorm); + } + AuthAttempts::record('register_ok', $username); + return ['ok' => true]; + } + + /** @return array{ok:bool,error?:string} */ + public static function verifyEmailToken(string $token): array { + self::ensureSchema(); + $token = trim($token); + if ($token === '') { + return ['ok' => false, 'error' => 'missing_token']; + } + $hash = hash('sha256', $token); + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT * FROM auth_tokens WHERE token_hash = ? AND purpose = 'verify_email' AND used_at IS NULL LIMIT 1" + ); + $st->execute([$hash]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return ['ok' => false, 'error' => 'invalid_token']; + } + $exp = strtotime((string) ($row['expires_at'] ?? '')); + if ($exp !== false && $exp < time()) { + return ['ok' => false, 'error' => 'expired_token']; + } + $userId = (int) ($row['user_id'] ?? 0); + $now = date('Y-m-d H:i:s'); + $pdo->prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?')->execute([$now, (int) $row['id']]); + $pdo->prepare( + "UPDATE users SET email_verified_at = ?, status = 'active' WHERE id = ?" + )->execute([$now, $userId]); + return ['ok' => true]; + } + + private static function normalizeEmail(string $email): string { + return strtolower(trim($email)); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/AuthTotp.php b/sim/cluster0/lab-seeds/backend/src/AuthTotp.php new file mode 100644 index 0000000..8656ed0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/AuthTotp.php @@ -0,0 +1,93 @@ + $longUrl]; + } + $bearers = ShortLinksRepository::listBearers(); + if ($bearers === []) { + return ['long_url' => $longUrl]; + } + $bearerId = (int) ($bearers[0]['id'] ?? 0); + if ($bearerId <= 0) { + return ['long_url' => $longUrl]; + } + $mint = ShortLinksRepository::createLink($bearerId, $longUrl, 900); + if (empty($mint['ok'])) { + return ['long_url' => $longUrl]; + } + return [ + 'long_url' => $longUrl, + 'short_url' => (string) ($mint['short_url'] ?? ''), + 'qr_url' => (string) ($mint['qr_url'] ?? ''), + ]; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/Database.php b/sim/cluster0/lab-seeds/backend/src/Database.php new file mode 100644 index 0000000..934dd60 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/Database.php @@ -0,0 +1,669 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + self::ensureSchema(); + return self::$pdo; + } + $path = cfg('db.sqlite_path'); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + self::$pdo = new PDO('sqlite:' . $path, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + self::$pdo->exec('PRAGMA foreign_keys = ON'); + self::ensureSchema(); + return self::$pdo; + } + + /** Upsert into report_views (portable). */ + public static function upsertReportView(int $userId, int $reportId, int $viewedAtMs): void { + $pdo = self::pdo(); + if (self::isMysql()) { + $sql = 'INSERT INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE viewed_at_ms = VALUES(viewed_at_ms)'; + } else { + $sql = 'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)'; + } + $stmt = $pdo->prepare($sql); + $stmt->execute([$userId, $reportId, $viewedAtMs]); + } + + /** Idempotent schema setup for sqlite and mysql. */ + public static function ensureSchema(): void { + if (self::$schemaChecked) { + return; + } + self::$schemaChecked = true; + $pdo = self::$pdo; + if ($pdo === null) { + return; + } + if (self::isMysql()) { + self::ensureMysqlSchema($pdo); + return; + } + self::ensureSqliteSchema($pdo); + } + + private static function ensureSqliteSchema(PDO $pdo): void { + $need = static function (string $table) use ($pdo): bool { + return !self::tableExists($pdo, $table); + }; + if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) { + self::ensureReportColumns($pdo); + self::ensureReportListIndexes($pdo); + self::ensureReportViewsTable($pdo); + self::ensureRbacSchema($pdo); + AuthEmailSchema::ensure($pdo); + return; + } + $schemaFile = __DIR__ . '/../sql/schema.sqlite.sql'; + if (!is_readable($schemaFile)) { + throw new RuntimeException('schema file missing: ' . $schemaFile); + } + if (!$need('reports') && !self::reportsSchemaOk($pdo)) { + $pdo->exec('DROP TABLE IF EXISTS reports'); + } + $pdo->exec((string) file_get_contents($schemaFile)); + self::ensureReportColumns($pdo); + self::ensureReportListIndexes($pdo); + self::ensureReportViewsTable($pdo); + self::ensureRbacSchema($pdo); + AuthEmailSchema::ensure($pdo); + } + + private static function ensureMysqlSchema(PDO $pdo): void { + if (!self::tableExists($pdo, 'users')) { + $pdo->exec( + "CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_username (username) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + $pdo->exec( + "INSERT IGNORE INTO users (id, username, password_hash, role) + VALUES (1, 'admin', '\$2y\$10\$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root')" + ); + } + if (!self::tableExists($pdo, 'reports')) { + $pdo->exec( + "CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + report_id VARCHAR(128) NOT NULL, + fingerprint VARCHAR(64) NOT NULL, + crash_type VARCHAR(32) NOT NULL, + generated_at_ms BIGINT NOT NULL, + received_at_ms BIGINT NOT NULL, + device_model VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + payload_json LONGTEXT NOT NULL, + tags_json LONGTEXT NOT NULL, + UNIQUE KEY uq_reports_report_id (report_id), + KEY idx_reports_generated (generated_at_ms), + KEY idx_reports_received (received_at_ms), + KEY idx_reports_fingerprint (fingerprint), + KEY idx_reports_fp_received (fingerprint, received_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + self::ensureMysqlReportColumns($pdo); + } + self::ensureReportListIndexes($pdo); + self::ensureMysqlReportViewsTable($pdo); + self::ensureRbacSchema($pdo); + AuthEmailSchema::ensure($pdo); + self::ensureTicketsTable($pdo); + } + + /** Active PDO; opens the pool when callers have not touched the DB yet (e.g. cached Auth session). */ + private static function connection(?PDO $pdo = null): PDO { + return $pdo ?? self::$pdo ?? self::pdo(); + } + + public static function ticketsTableExists(?PDO $pdo = null): bool { + return self::tableExists(self::connection($pdo), 'tickets'); + } + + /** @throws RuntimeException when `tickets` is missing (MariaDB needs root migration). */ + public static function requireTicketsTable(?PDO $pdo = null): void { + $pdo = self::connection($pdo); + self::ensureTicketsTable($pdo); + if (!self::tableExists($pdo, 'tickets')) { + throw new RuntimeException(self::ticketsMigrationHint()); + } + } + + public static function ticketsMigrationHint(): string { + return 'Table tickets is missing. On MariaDB run as MySQL root: ' + . 'mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql ' + . '(or ./scripts/migrate-003-tickets.sh from backend/)'; + } + + public static function ensureTicketsTable(?PDO $pdo = null): void { + $pdo = self::connection($pdo); + if (self::tableExists($pdo, 'tickets')) { + return; + } + $ok = false; + if (self::isMysql()) { + $sql = file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sql') ?: ''; + $sql = preg_replace('/^\s*--.*$/m', '', $sql) ?? $sql; + $sql = preg_replace('/^\s*USE\s+[^;]+;\s*/mi', '', $sql) ?? $sql; + $ok = self::execSchemaSql($pdo, trim($sql), 'create tickets mysql'); + } else { + $ok = self::execSchemaSql( + $pdo, + file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '', + 'create tickets sqlite' + ); + } + if (!$ok && !self::tableExists($pdo, 'tickets')) { + error_log('crash_reporter: ' . self::ticketsMigrationHint()); + if (function_exists('log_crash_event')) { + log_crash_event('schema', self::ticketsMigrationHint()); + } + } + } + + /** + * Run DDL; app DB user often has no ALTER — log and continue (run sql/migrations as root). + */ + private static function execSchemaSql(PDO $pdo, string $sql, string $label): bool { + try { + $pdo->exec($sql); + return true; + } catch (PDOException $e) { + $msg = $label . ': ' . $e->getMessage(); + error_log('crash_reporter schema ' . $msg); + if (function_exists('log_crash_event')) { + log_crash_event('schema', $msg); + } + return false; + } + } + + public static function reportsHaveRbacColumns(?PDO $pdo = null): bool { + $pdo ??= self::$pdo; + if ($pdo === null || !self::tableExists($pdo, 'reports')) { + return false; + } + $names = self::columnNames($pdo, 'reports'); + return in_array('company_id', $names, true); + } + + /** Phase 1 RBAC tables + report company/device columns (idempotent). */ + public static function ensureRbacSchema(?PDO $pdo = null): void { + $pdo ??= self::$pdo; + if ($pdo === null) { + return; + } + self::ensureCompaniesTable($pdo); + self::seedDefaultCompany($pdo); + self::ensureCompanyMembershipsTable($pdo); + self::ensureDevicesTable($pdo); + self::ensureReportCompanyColumns($pdo); + self::backfillCompanyMemberships($pdo); + if (!self::reportsHaveRbacColumns($pdo)) { + error_log( + 'crash_reporter: reports.company_id missing — run as MySQL root: ' + . 'mysql -u root -p androidcast_crashes < sql/migrations/002_reports_company_columns.sql' + ); + } + } + + private static function ensureCompaniesTable(PDO $pdo): void { + if (self::tableExists($pdo, 'companies')) { + return; + } + if (self::isMysql()) { + self::execSchemaSql( + $pdo, + "CREATE TABLE companies ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_companies_slug (slug) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + 'create companies' + ); + return; + } + self::execSchemaSql( + $pdo, + "CREATE TABLE companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + 'create companies' + ); + } + + private static function seedDefaultCompany(PDO $pdo): void { + if (self::isMysql()) { + self::execSchemaSql( + $pdo, + "INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')", + 'seed company' + ); + return; + } + self::execSchemaSql( + $pdo, + "INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')", + 'seed company' + ); + } + + private static function ensureCompanyMembershipsTable(PDO $pdo): void { + if (self::tableExists($pdo, 'company_memberships')) { + return; + } + if (self::isMysql()) { + self::execSchemaSql( + $pdo, + "CREATE TABLE company_memberships ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + company_id INT UNSIGNED NOT NULL, + role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member', + permissions_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_membership_user_company (user_id, company_id), + KEY idx_membership_company (company_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + 'create company_memberships' + ); + return; + } + self::execSchemaSql( + $pdo, + "CREATE TABLE company_memberships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + company_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + permissions_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, company_id) + )", + 'create company_memberships' + ); + } + + private static function ensureDevicesTable(PDO $pdo): void { + if (self::tableExists($pdo, 'devices')) { + return; + } + if (self::isMysql()) { + self::execSchemaSql( + $pdo, + "CREATE TABLE devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL, + external_id VARCHAR(128) NOT NULL, + display_name VARCHAR(256) NULL, + manufacturer VARCHAR(128) NULL, + model VARCHAR(128) NULL, + source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto', + registered_at_ms BIGINT NOT NULL, + last_seen_at_ms BIGINT NOT NULL, + meta_json LONGTEXT NULL, + UNIQUE KEY uq_devices_company_external (company_id, external_id), + KEY idx_devices_company (company_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + 'create devices' + ); + return; + } + self::execSchemaSql( + $pdo, + "CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + external_id TEXT NOT NULL, + display_name TEXT, + manufacturer TEXT, + model TEXT, + source TEXT NOT NULL DEFAULT 'auto', + registered_at_ms INTEGER NOT NULL, + last_seen_at_ms INTEGER NOT NULL, + meta_json TEXT, + UNIQUE (company_id, external_id) + )", + 'create devices' + ); + } + + private static function ensureReportCompanyColumns(PDO $pdo): void { + if (!self::tableExists($pdo, 'reports')) { + return; + } + $names = self::columnNames($pdo, 'reports'); + if (!in_array('company_id', $names, true)) { + if (self::isMysql()) { + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD COLUMN company_id INT UNSIGNED NOT NULL DEFAULT 1', + 'alter reports.company_id' + ); + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD KEY idx_reports_company (company_id)', + 'index reports.company_id' + ); + } else { + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD COLUMN company_id INTEGER NOT NULL DEFAULT 1', + 'alter reports.company_id' + ); + } + self::execSchemaSql( + $pdo, + 'UPDATE reports SET company_id = 1 WHERE company_id IS NULL OR company_id = 0', + 'backfill reports.company_id' + ); + } + $names = self::columnNames($pdo, 'reports'); + if (!in_array('device_id', $names, true)) { + if (self::isMysql()) { + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD COLUMN device_id BIGINT UNSIGNED NULL', + 'alter reports.device_id' + ); + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD KEY idx_reports_device (device_id)', + 'index reports.device_id' + ); + } else { + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD COLUMN device_id INTEGER NULL', + 'alter reports.device_id' + ); + } + } + } + + private static function backfillCompanyMemberships(PDO $pdo): void { + if (!self::tableExists($pdo, 'users') || !self::tableExists($pdo, 'company_memberships')) { + return; + } + $companyId = 1; + $rows = $pdo->query('SELECT id, role FROM users')->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $uid = (int) $row['id']; + $globalRole = strtolower((string) ($row['role'] ?? 'viewer')); + $companyRole = in_array($globalRole, ['root', 'admin', 'platform_admin'], true) ? 'owner' : 'member'; + if (self::isMysql()) { + $stmt = $pdo->prepare( + 'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)' + ); + } else { + $stmt = $pdo->prepare( + 'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)' + ); + } + $stmt->execute([$uid, $companyId, $companyRole]); + } + if (self::isMysql()) { + $pdo->exec( + "INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')" + ); + } else { + $pdo->exec( + "INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')" + ); + } + } + + private static function ensureMysqlReportColumns(PDO $pdo): void { + $names = self::columnNames($pdo, 'reports'); + if (!in_array('tags_json', $names, true)) { + if (self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD COLUMN tags_json LONGTEXT NOT NULL', + 'alter reports.tags_json' + )) { + self::execSchemaSql( + $pdo, + "UPDATE reports SET tags_json = '[]' WHERE tags_json IS NULL OR tags_json = ''", + 'backfill reports.tags_json' + ); + } + } + } + + private static function ensureMysqlReportViewsTable(PDO $pdo): void { + if (self::tableExists($pdo, 'report_views')) { + return; + } + $pdo->exec( + 'CREATE TABLE IF NOT EXISTS report_views ( + user_id INT UNSIGNED NOT NULL, + report_id BIGINT UNSIGNED NOT NULL, + viewed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_id, report_id), + CONSTRAINT fk_report_views_report + FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + } + + private static function ensureReportColumns(PDO $pdo): void { + $names = self::columnNames($pdo, 'reports'); + if (!in_array('tags_json', $names, true)) { + self::execSchemaSql( + $pdo, + "ALTER TABLE reports ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'", + 'alter reports.tags_json' + ); + } + } + + /** Indexes for list API (fingerprint stats join + sort by received_at). */ + private static function ensureReportListIndexes(PDO $pdo): void { + if (!self::tableExists($pdo, 'reports')) { + return; + } + if (self::isMysql()) { + $existing = []; + $stmt = $pdo->query("SHOW INDEX FROM reports"); + if ($stmt !== false) { + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $existing[(string) ($row['Key_name'] ?? '')] = true; + } + } + $add = static function (string $name, string $sql) use ($pdo, $existing): void { + if (!isset($existing[$name])) { + self::execSchemaSql($pdo, $sql, 'index reports.' . $name); + } + }; + $add('idx_reports_company_fingerprint', 'ALTER TABLE reports ADD KEY idx_reports_company_fingerprint (company_id, fingerprint)'); + $add('idx_reports_company_received', 'ALTER TABLE reports ADD KEY idx_reports_company_received (company_id, received_at_ms)'); + if (!isset($existing['idx_reports_fp_received'])) { + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD KEY idx_reports_fp_received (fingerprint, received_at_ms)', + 'index reports.fp_received' + ); + } + return; + } + self::execSchemaSql( + $pdo, + 'CREATE INDEX IF NOT EXISTS idx_reports_company_fingerprint ON reports(company_id, fingerprint)', + 'index reports.company_fingerprint' + ); + self::execSchemaSql( + $pdo, + 'CREATE INDEX IF NOT EXISTS idx_reports_company_received ON reports(company_id, received_at_ms)', + 'index reports.company_received' + ); + self::execSchemaSql( + $pdo, + 'CREATE INDEX IF NOT EXISTS idx_reports_fp_received ON reports(fingerprint, received_at_ms)', + 'index reports.fp_received' + ); + } + + private static function ensureReportViewsTable(PDO $pdo): void { + if (self::tableExists($pdo, 'report_views')) { + return; + } + $pdo->exec( + 'CREATE TABLE IF NOT EXISTS report_views ( + user_id INTEGER NOT NULL, + report_id INTEGER NOT NULL, + viewed_at_ms INTEGER NOT NULL, + PRIMARY KEY (user_id, report_id) + )' + ); + } + + private static function mysqlQuoteIdentifier(string $name): string { + return '`' . str_replace('`', '``', $name) . '`'; + } + + public static function tableExists(PDO $pdo, string $table): bool { + if (self::isMysql()) { + // App DB users often lack information_schema visibility; SHOW TABLES uses normal grants. + $stmt = $pdo->query('SHOW TABLES LIKE ' . $pdo->quote($table)); + if ($stmt !== false && $stmt->fetchColumn() !== false) { + return true; + } + try { + $pdo->query('SELECT 1 FROM ' . self::mysqlQuoteIdentifier($table) . ' LIMIT 0'); + return true; + } catch (PDOException $e) { + $sqlState = $e->getCode(); + $errno = (int) ($e->errorInfo[1] ?? 0); + if ($sqlState === '42S02' || $errno === 1146) { + return false; + } + throw $e; + } + } + $stmt = $pdo->prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1" + ); + $stmt->execute([$table]); + return $stmt->fetchColumn() !== false; + } + + /** Serialize cross-request DDL (MariaDB GET_LOCK). No-op on SQLite. */ + public static function withMigrationLock(PDO $pdo, string $name, callable $fn): void { + if (!self::isMysql()) { + $fn(); + return; + } + $lockName = 'ac_migrate_' . preg_replace('/[^a-z0-9_]/', '_', strtolower($name)); + $stmt = $pdo->query('SELECT GET_LOCK(' . $pdo->quote($lockName) . ', 90)'); + $got = $stmt !== false && (int) $stmt->fetchColumn() === 1; + if (!$got) { + return; + } + try { + $fn(); + } finally { + $pdo->query('SELECT RELEASE_LOCK(' . $pdo->quote($lockName) . ')'); + } + } + + /** @return list */ + public static function columnNames(PDO $pdo, string $table): array { + if (self::isMysql()) { + $stmt = $pdo->query('SHOW COLUMNS FROM ' . self::mysqlQuoteIdentifier($table)); + if ($stmt === false) { + return []; + } + return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'Field'); + } + return array_column( + $pdo->query('PRAGMA table_info(' . preg_replace('/[^a-z0-9_]/i', '', $table) . ')')->fetchAll(PDO::FETCH_ASSOC), + 'name' + ); + } + + /** @return list */ + public static function listTables(PDO $pdo): array { + if (self::isMysql()) { + $stmt = $pdo->query('SHOW TABLES'); + if ($stmt === false) { + return []; + } + return array_values(array_filter($stmt->fetchAll(PDO::FETCH_COLUMN), 'is_string')); + } + return $pdo->query( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" + )->fetchAll(PDO::FETCH_COLUMN); + } + + private static function reportsSchemaOk(PDO $pdo): bool { + if (!self::tableExists($pdo, 'reports')) { + return false; + } + $names = self::columnNames($pdo, 'reports'); + foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) { + if (!in_array($col, $names, true)) { + return false; + } + } + return true; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/DeviceRepository.php b/sim/cluster0/lab-seeds/backend/src/DeviceRepository.php new file mode 100644 index 0000000..d207497 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/DeviceRepository.php @@ -0,0 +1,103 @@ + $v !== ''); + if ($parts === []) { + return 'unknown'; + } + return substr(hash('sha256', implode('|', $parts)), 0, 32); + } + + public static function upsertFromPayload(int $companyId, array $device, string $source = 'auto'): int { + $companyId = max(1, $companyId); + $externalId = self::externalKey($device); + $now = (int) round(microtime(true) * 1000); + $manufacturer = trim((string) ($device['manufacturer'] ?? '')); + $model = trim((string) ($device['model'] ?? '')); + $display = trim($manufacturer . ' ' . $model); + $meta = json_encode($device, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($meta === false) { + $meta = '{}'; + } + + $pdo = Database::pdo(); + if (Database::isMysql()) { + $stmt = $pdo->prepare( + 'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source, + registered_at_ms, last_seen_at_ms, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + manufacturer = VALUES(manufacturer), + model = VALUES(model), + last_seen_at_ms = VALUES(last_seen_at_ms), + meta_json = VALUES(meta_json)' + ); + $stmt->execute([ + $companyId, + $externalId, + $display !== '' ? $display : null, + $manufacturer !== '' ? $manufacturer : null, + $model !== '' ? $model : null, + $source, + $now, + $now, + $meta, + ]); + $sel = $pdo->prepare( + 'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1' + ); + $sel->execute([$companyId, $externalId]); + return (int) $sel->fetchColumn(); + } + + $stmt = $pdo->prepare( + 'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1' + ); + $stmt->execute([$companyId, $externalId]); + $existing = $stmt->fetchColumn(); + if ($existing !== false) { + $upd = $pdo->prepare( + 'UPDATE devices SET display_name = ?, manufacturer = ?, model = ?, + last_seen_at_ms = ?, meta_json = ? WHERE id = ?' + ); + $upd->execute([ + $display !== '' ? $display : null, + $manufacturer !== '' ? $manufacturer : null, + $model !== '' ? $model : null, + $now, + $meta, + (int) $existing, + ]); + return (int) $existing; + } + $ins = $pdo->prepare( + 'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source, + registered_at_ms, last_seen_at_ms, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $ins->execute([ + $companyId, + $externalId, + $display !== '' ? $display : null, + $manufacturer !== '' ? $manufacturer : null, + $model !== '' ? $model : null, + $source, + $now, + $now, + $meta, + ]); + return (int) $pdo->lastInsertId(); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/GraphRepository.php b/sim/cluster0/lab-seeds/backend/src/GraphRepository.php new file mode 100644 index 0000000..8d01253 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/GraphRepository.php @@ -0,0 +1,600 @@ +prepare( + 'SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1' + ); + $stmt->execute([$table, $column]); + return (bool) $stmt->fetchColumn(); + } + $stmt = $pdo->query('PRAGMA table_info(' . $table . ')'); + if ($stmt === false) { + return false; + } + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + if (($row['name'] ?? '') === $column) { + return true; + } + } + return false; + } + + private static function indexExists(PDO $pdo, string $table, string $index): bool { + if (Database::isMysql()) { + $stmt = $pdo->prepare( + 'SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1' + ); + $stmt->execute([$table, $index]); + return (bool) $stmt->fetchColumn(); + } + $stmt = $pdo->query('PRAGMA index_list(' . $table . ')'); + if ($stmt === false) { + return false; + } + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + if (($row['name'] ?? '') === $index) { + return true; + } + } + return false; + } + + private static function createTable(PDO $pdo): void { + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE IF NOT EXISTS graph_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + user_id INT UNSIGNED NULL, + device_id VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + sdk_int INT NULL, + started_at_ms BIGINT NOT NULL, + ended_at_ms BIGINT NOT NULL, + duration_s INT NOT NULL, + completed TINYINT(1) NOT NULL DEFAULT 1, + direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender', + transport VARCHAR(24) NOT NULL DEFAULT 'wifi', + avg_kbps INT NOT NULL DEFAULT 0, + recv_kbps INT NOT NULL DEFAULT 0, + packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0, + ntp_source VARCHAR(32) NOT NULL DEFAULT 'device', + ntp_correction_s INT NULL, + install_source VARCHAR(32) NOT NULL DEFAULT 'direct', + meta_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_graph_session_id (session_id), + KEY idx_graph_company_time (company_id, started_at_ms), + KEY idx_graph_device (device_id), + KEY idx_graph_app_version (app_version) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + return; + } + $pdo->exec( + "CREATE TABLE IF NOT EXISTS graph_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NULL, + device_id TEXT NULL, + app_version TEXT NULL, + sdk_int INTEGER NULL, + started_at_ms INTEGER NOT NULL, + ended_at_ms INTEGER NOT NULL, + duration_s INTEGER NOT NULL, + completed INTEGER NOT NULL DEFAULT 1, + direction TEXT NOT NULL DEFAULT 'sender', + transport TEXT NOT NULL DEFAULT 'wifi', + avg_kbps INTEGER NOT NULL DEFAULT 0, + recv_kbps INTEGER NOT NULL DEFAULT 0, + packet_loss_pct REAL NOT NULL DEFAULT 0.0, + ntp_source TEXT NOT NULL DEFAULT 'device', + ntp_correction_s INTEGER NULL, + install_source TEXT NOT NULL DEFAULT 'direct', + meta_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + + private static function ensureIndexes(PDO $pdo): void { + $defs = Database::isMysql() + ? [ + 'idx_graph_user_time' => 'CREATE INDEX idx_graph_user_time ON graph_sessions (user_id, started_at_ms)', + 'idx_graph_started' => 'CREATE INDEX idx_graph_started ON graph_sessions (started_at_ms)', + ] + : [ + 'idx_graph_company_time' => 'CREATE INDEX IF NOT EXISTS idx_graph_company_time ON graph_sessions (company_id, started_at_ms)', + 'idx_graph_user_time' => 'CREATE INDEX IF NOT EXISTS idx_graph_user_time ON graph_sessions (user_id, started_at_ms)', + 'idx_graph_started' => 'CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms)', + 'idx_graph_device' => 'CREATE INDEX IF NOT EXISTS idx_graph_device ON graph_sessions (device_id)', + ]; + foreach ($defs as $name => $sql) { + if (self::indexExists($pdo, 'graph_sessions', $name)) { + continue; + } + try { + $pdo->exec($sql); + } catch (PDOException $e) { + $msg = strtolower($e->getMessage()); + if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) { + throw $e; + } + } + } + } + + private static function ensureColumns(PDO $pdo): void { + $cols = [ + 'device_id' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL', + 'app_version' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL', + 'sdk_int' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL', + 'completed' => Database::isMysql() ? 'TINYINT(1) NOT NULL DEFAULT 1' : 'INTEGER NOT NULL DEFAULT 1', + 'recv_kbps' => Database::isMysql() ? 'INT NOT NULL DEFAULT 0' : 'INTEGER NOT NULL DEFAULT 0', + 'ntp_correction_s' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL', + ]; + foreach ($cols as $name => $ddl) { + if (self::columnExists($pdo, 'graph_sessions', $name)) { + continue; + } + try { + $pdo->exec('ALTER TABLE graph_sessions ADD COLUMN ' . $name . ' ' . $ddl); + } catch (PDOException $e) { + $msg = strtolower($e->getMessage()); + if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) { + throw $e; + } + } + } + } + + public static function insertSession(array $in): void { + self::ensureSchema(); + $pdo = Database::pdo(); + $sessionId = trim((string) ($in['session_id'] ?? '')); + if ($sessionId === '') { + throw new InvalidArgumentException('session_id is required'); + } + $start = (int) ($in['started_at_epoch_ms'] ?? 0); + $end = (int) ($in['ended_at_epoch_ms'] ?? 0); + if ($start <= 0 || $end <= 0 || $end < $start) { + throw new InvalidArgumentException('invalid session timing'); + } + $dir = strtolower(trim((string) ($in['direction'] ?? 'sender'))); + if ($dir !== 'sender' && $dir !== 'receiver') { + $dir = 'sender'; + } + $transport = strtolower(trim((string) ($in['transport'] ?? 'wifi'))); + if ($transport === '') { + $transport = 'wifi'; + } + $ntp = strtolower(trim((string) ($in['ntp_source'] ?? ($in['time_source'] ?? 'device')))); + if ($ntp === '') { + $ntp = 'device'; + } + $install = strtolower(trim((string) ($in['install_source'] ?? 'direct'))); + if ($install === '') { + $install = 'direct'; + } + $duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000))); + $avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0)); + $recvKbps = max(0, (int) ($in['recv_kbps'] ?? 0)); + $loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0)); + $companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId()); + if ($companyId <= 0) { + $companyId = Rbac::defaultCompanyId(); + } + $userId = isset($in['user_id']) ? (int) $in['user_id'] : null; + if ($userId !== null && $userId <= 0) { + $userId = null; + } + $deviceId = trim((string) ($in['device_id'] ?? '')); + if ($deviceId === '' && is_array($in['device'] ?? null)) { + $deviceId = trim((string) (($in['device']['id'] ?? $in['device']['device_id'] ?? ''))); + } + $appVersion = trim((string) ($in['app_version'] ?? '')); + if ($appVersion === '' && is_array($in['app'] ?? null)) { + $appVersion = trim((string) ($in['app']['version_name'] ?? $in['app']['version'] ?? '')); + } + $sdkInt = isset($in['sdk_int']) ? (int) $in['sdk_int'] : null; + if ($sdkInt === null && is_array($in['device'] ?? null)) { + $sdkInt = isset($in['device']['sdk_int']) ? (int) $in['device']['sdk_int'] : null; + } + $completed = 1; + if (array_key_exists('completed', $in)) { + $completed = !empty($in['completed']) ? 1 : 0; + } + $ntpCorrection = isset($in['ntp_correction_s']) ? (int) $in['ntp_correction_s'] : null; + if ($ntpCorrection === null && isset($in['ntp_correction_seconds'])) { + $ntpCorrection = (int) $in['ntp_correction_seconds']; + } + $meta = $in['meta'] ?? null; + $metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null; + + if (Database::isMysql()) { + $sql = 'INSERT INTO graph_sessions + (session_id, company_id, user_id, device_id, app_version, sdk_int, + started_at_ms, ended_at_ms, duration_s, completed, direction, transport, + avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + ended_at_ms = VALUES(ended_at_ms), + duration_s = VALUES(duration_s), + completed = VALUES(completed), + avg_kbps = VALUES(avg_kbps), + recv_kbps = VALUES(recv_kbps), + packet_loss_pct = VALUES(packet_loss_pct), + ntp_source = VALUES(ntp_source), + ntp_correction_s = VALUES(ntp_correction_s), + install_source = VALUES(install_source), + device_id = COALESCE(VALUES(device_id), device_id), + app_version = COALESCE(VALUES(app_version), app_version), + sdk_int = COALESCE(VALUES(sdk_int), sdk_int), + meta_json = VALUES(meta_json)'; + } else { + $sql = 'INSERT OR REPLACE INTO graph_sessions + (session_id, company_id, user_id, device_id, app_version, sdk_int, + started_at_ms, ended_at_ms, duration_s, completed, direction, transport, + avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + } + $stmt = $pdo->prepare($sql); + $stmt->execute([ + $sessionId, + $companyId, + $userId, + $deviceId !== '' ? $deviceId : null, + $appVersion !== '' ? $appVersion : null, + $sdkInt, + $start, + $end, + $duration, + $completed, + $dir, + $transport, + $avgKbps, + $recvKbps, + $loss, + $ntp, + $ntpCorrection, + $install, + $metaJson, + ]); + } + + public static function dashboardData(int $days = 14): array { + self::ensureSchema(); + $days = max(1, min(60, $days)); + $user = Auth::user(); + $uid = (int) ($user['id'] ?? 0); + $scopeUserId = $uid > 0 ? $uid : null; + $activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId(); + $viewer = self::viewerRole($user); + + $payload = [ + 'window_days' => $days, + 'viewer' => $viewer, + 'links' => self::serviceLinks(), + ]; + $payload['live_cast'] = LiveCastRepository::analytics($days, $user ?? []); + $payload['user'] = self::buildScope($days, $activeCompanyId, null); + if ($viewer === 'user') { + return $payload; + } + $payload['slug_admin'] = self::buildScope($days, $activeCompanyId, null); + if ($viewer !== 'platform_admin') { + return $payload; + } + $payload['platform_admin'] = self::buildScope($days, null, null, true); + return $payload; + } + + private static function viewerRole(?array $user): string { + if (Rbac::isGlobalAdmin($user)) { + return 'platform_admin'; + } + $companyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId(); + $uid = (int) ($user['id'] ?? 0); + $role = ($companyId > 0 && $uid > 0) ? Rbac::companyRole($uid, $companyId) : null; + if ($role === 'owner' || $role === 'admin') { + return 'slug_admin'; + } + return 'user'; + } + + /** @return array */ + private static function serviceLinks(): array { + $prefix = '/app/androidcast_project'; + return [ + 'hub' => $prefix . '/', + 'graphs' => $prefix . '/graphs/', + 'issues' => $prefix . '/issues/?view=reports', + 'tickets' => $prefix . '/issues/?view=tickets', + 'build' => $prefix . '/build/', + 'git' => $prefix . '/git/', + ]; + } + + private static function buildScope(int $days, ?int $companyId, ?int $userId, bool $platform = false): array { + $startMs = (int) ((time() - ($days * 86400)) * 1000); + $scope = [ + 'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId), + 'unique_devices_per_day' => self::uniqueDevicesPerDay($startMs, $companyId, $userId), + 'avg_duration_s_per_day' => self::avgDurationPerDay($startMs, $companyId, $userId), + 'send_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'sender'), + 'recv_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'receiver'), + 'success_ratio_pct' => self::successRatio($startMs, $companyId, $userId), + 'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'avg_kbps'), + 'avg_recv_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'recv_kbps'), + 'crashes_per_day' => self::crashesPerDay($startMs, $companyId), + 'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId), + 'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId), + 'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId), + 'app_versions' => self::breakdown('app_version', $startMs, $companyId, $userId), + ]; + if (Database::tableExists(Database::pdo(), 'tickets')) { + $scope['tickets_per_day'] = self::ticketsPerDay($startMs, $companyId); + } + if ($platform) { + $scope['top_crash_fingerprints'] = self::topCrashFingerprints($startMs, 8); + $scope['crashes_by_app_version'] = self::crashesByAppVersion($startMs); + $scope['ntp_correction_avg_s'] = self::avgNtpCorrection($startMs, $companyId, $userId); + } + return $scope; + } + + private static function sessionsPerDay( + int $startMs, + ?int $companyId, + ?int $userId, + ?string $direction = null + ): array { + $where = ['started_at_ms >= ?']; + $params = [$startMs]; + if ($direction !== null) { + $where[] = 'direction = ?'; + $params[] = $direction; + } + self::appendScope($where, $params, $companyId, $userId); + return self::countByDay('graph_sessions', 'started_at_ms', $where, $params); + } + + private static function uniqueDevicesPerDay(int $startMs, ?int $companyId, ?int $userId): array { + $where = ['started_at_ms >= ?', "device_id IS NOT NULL", "device_id <> ''"]; + $params = [$startMs]; + self::appendScope($where, $params, $companyId, $userId); + $day = self::msDayExpr('started_at_ms'); + $sql = 'SELECT ' . $day . ' AS d, COUNT(DISTINCT device_id) AS c FROM graph_sessions WHERE ' + . implode(' AND ', $where) . ' GROUP BY d ORDER BY d'; + $stmt = Database::pdo()->prepare($sql); + $stmt->execute($params); + $bucket = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $bucket[(string) ($row['d'] ?? '')] = (int) ($row['c'] ?? 0); + } + return self::assocToSeries($bucket); + } + + private static function avgDurationPerDay(int $startMs, ?int $companyId, ?int $userId): array { + return self::avgNumericPerDay($startMs, $companyId, $userId, 'duration_s'); + } + + private static function successRatio(int $startMs, ?int $companyId, ?int $userId): float { + $where = ['started_at_ms >= ?']; + $params = [$startMs]; + self::appendScope($where, $params, $companyId, $userId); + $sql = 'SELECT COUNT(*) AS total, + SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) AS ok + FROM graph_sessions WHERE ' . implode(' AND ', $where); + $stmt = Database::pdo()->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + $total = (int) ($row['total'] ?? 0); + $ok = (int) ($row['ok'] ?? 0); + if ($total <= 0) { + return 0.0; + } + return round(100.0 * $ok / $total, 1); + } + + private static function avgBitratePerDay( + int $startMs, + ?int $companyId, + ?int $userId, + string $column + ): array { + $allowed = ['avg_kbps', 'recv_kbps']; + if (!in_array($column, $allowed, true)) { + return []; + } + return self::avgNumericPerDay($startMs, $companyId, $userId, $column); + } + + private static function crashesPerDay(int $startMs, ?int $companyId): array { + if (!Database::tableExists(Database::pdo(), 'reports')) { + return []; + } + $where = ['received_at_ms >= ?']; + $params = [$startMs]; + if ($companyId !== null && $companyId > 0) { + $where[] = 'company_id = ?'; + $params[] = $companyId; + } + return self::countByDay('reports', 'received_at_ms', $where, $params); + } + + private static function ticketsPerDay(int $startMs, ?int $companyId): array { + $where = ['opened_at_ms >= ?']; + $params = [$startMs]; + if ($companyId !== null && $companyId > 0) { + $where[] = 'company_id = ?'; + $params[] = $companyId; + } + return self::countByDay('tickets', 'opened_at_ms', $where, $params); + } + + private static function topCrashFingerprints(int $startMs, int $limit): array { + if (!Database::tableExists(Database::pdo(), 'reports')) { + return []; + } + $sql = 'SELECT fingerprint, COUNT(*) AS c, MAX(id) AS last_id + FROM reports WHERE received_at_ms >= ? + GROUP BY fingerprint ORDER BY c DESC LIMIT ' . (int) $limit; + $stmt = Database::pdo()->prepare($sql); + $stmt->execute([$startMs]); + $base = Auth::basePath(); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $fp = (string) ($row['fingerprint'] ?? ''); + $id = (int) ($row['last_id'] ?? 0); + $out[] = [ + 'label' => $fp, + 'value' => (int) ($row['c'] ?? 0), + 'href' => $id > 0 ? $base . '/?view=report&id=' . $id : null, + ]; + } + return $out; + } + + private static function crashesByAppVersion(int $startMs): array { + if (!Database::tableExists(Database::pdo(), 'reports')) { + return []; + } + $sql = "SELECT COALESCE(NULLIF(app_version, ''), 'unknown') AS k, COUNT(*) AS c + FROM reports WHERE received_at_ms >= ? + GROUP BY k ORDER BY c DESC LIMIT 12"; + $stmt = Database::pdo()->prepare($sql); + $stmt->execute([$startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)]; + } + return $out; + } + + private static function avgNtpCorrection(int $startMs, ?int $companyId, ?int $userId): ?float { + $where = ['started_at_ms >= ?', 'ntp_correction_s IS NOT NULL']; + $params = [$startMs]; + self::appendScope($where, $params, $companyId, $userId); + $sql = 'SELECT AVG(ABS(ntp_correction_s)) AS avg_s FROM graph_sessions WHERE ' + . implode(' AND ', $where); + $stmt = Database::pdo()->prepare($sql); + $stmt->execute($params); + $v = $stmt->fetchColumn(); + return $v === false || $v === null ? null : round((float) $v, 2); + } + + private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array { + $allowed = ['ntp_source', 'install_source', 'transport', 'app_version']; + if (!in_array($column, $allowed, true)) { + return []; + } + $where = ['started_at_ms >= ?']; + $params = [$startMs]; + if ($column === 'app_version') { + $where[] = "app_version IS NOT NULL"; + $where[] = "app_version <> ''"; + } + self::appendScope($where, $params, $companyId, $userId); + $sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE ' + . implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC'; + $stmt = Database::pdo()->prepare($sql); + $stmt->execute($params); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $label = (string) ($row['k'] ?? 'unknown'); + if ($label === '') { + $label = 'unknown'; + } + $out[] = ['label' => $label, 'value' => (int) ($row['c'] ?? 0)]; + } + return $out; + } + + private static function msDayExpr(string $msColumn): string { + if (Database::isMysql()) { + return 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))'; + } + return "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')"; + } + + private static function countByDay(string $table, string $msColumn, array $where, array $params): array { + $day = self::msDayExpr($msColumn); + $sql = 'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . ' WHERE ' + . implode(' AND ', $where) . ' GROUP BY d ORDER BY d'; + $stmt = Database::pdo()->prepare($sql); + $stmt->execute($params); + $bucket = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $bucket[(string) ($row['d'] ?? '')] = (int) ($row['c'] ?? 0); + } + return self::assocToSeries($bucket); + } + + private static function avgNumericPerDay( + int $startMs, + ?int $companyId, + ?int $userId, + string $column + ): array { + $where = ['started_at_ms >= ?']; + $params = [$startMs]; + self::appendScope($where, $params, $companyId, $userId); + $day = self::msDayExpr('started_at_ms'); + $sql = 'SELECT ' . $day . ' AS d, AVG(' . $column . ') AS v FROM graph_sessions WHERE ' + . implode(' AND ', $where) . ' GROUP BY d ORDER BY d'; + $stmt = Database::pdo()->prepare($sql); + $stmt->execute($params); + $avg = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $avg[(string) ($row['d'] ?? '')] = round((float) ($row['v'] ?? 0), 1); + } + return self::assocToSeries($avg); + } + + private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void { + if ($companyId !== null && $companyId > 0) { + $where[] = 'company_id = ?'; + $params[] = $companyId; + } + if ($userId !== null && $userId > 0) { + $where[] = 'user_id = ?'; + $params[] = $userId; + } + } + + private static function assocToSeries(array $assoc): array { + ksort($assoc); + $out = []; + foreach ($assoc as $k => $v) { + $out[] = ['x' => $k, 'y' => $v]; + } + return $out; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/LiveCastRepository.php b/sim/cluster0/lab-seeds/backend/src/LiveCastRepository.php new file mode 100644 index 0000000..0b5398a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/LiveCastRepository.php @@ -0,0 +1,607 @@ +exec( + "CREATE TABLE live_cast_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + owner_user_id INT UNSIGNED NULL, + owner_username VARCHAR(64) NULL, + device_id VARCHAR(128) NULL, + platform VARCHAR(24) NOT NULL DEFAULT 'android', + status ENUM('intent','live','closed','expired') NOT NULL DEFAULT 'intent', + started_at_ms BIGINT NOT NULL, + last_heartbeat_ms BIGINT NOT NULL, + ended_at_ms BIGINT NULL, + max_duration_s INT NOT NULL DEFAULT 300, + join_short_url TEXT NULL, + join_qr_url TEXT NULL, + join_slug VARCHAR(32) NULL, + direct_short_url TEXT NULL, + direct_qr_url TEXT NULL, + direct_slug VARCHAR(32) NULL, + codec_video VARCHAR(32) NULL, + codec_audio VARCHAR(32) NULL, + bw_mode VARCHAR(24) NULL, + metadata_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_live_cast_session_id (session_id), + KEY idx_live_cast_company_status (company_id, status), + KEY idx_live_cast_last_hb (last_heartbeat_ms), + KEY idx_live_cast_owner (owner_user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL DEFAULT 1, + owner_user_id INTEGER NULL, + owner_username TEXT NULL, + device_id TEXT NULL, + platform TEXT NOT NULL DEFAULT 'android', + status TEXT NOT NULL DEFAULT 'intent', + started_at_ms INTEGER NOT NULL, + last_heartbeat_ms INTEGER NOT NULL, + ended_at_ms INTEGER NULL, + max_duration_s INTEGER NOT NULL DEFAULT 300, + join_short_url TEXT NULL, + join_qr_url TEXT NULL, + join_slug TEXT NULL, + direct_short_url TEXT NULL, + direct_qr_url TEXT NULL, + direct_slug TEXT NULL, + codec_video TEXT NULL, + codec_audio TEXT NULL, + bw_mode TEXT NULL, + metadata_json TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + } + + if (!Database::tableExists($pdo, 'live_cast_join_events')) { + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE live_cast_join_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + event_type ENUM('open','join','leave') NOT NULL DEFAULT 'open', + platform VARCHAR(24) NOT NULL DEFAULT 'unknown', + channel VARCHAR(24) NOT NULL DEFAULT 'short_link', + direct TINYINT(1) NOT NULL DEFAULT 0, + user_id INT UNSIGNED NULL, + anon_id VARCHAR(96) NULL, + user_agent VARCHAR(255) NULL, + ip_hash CHAR(64) NULL, + created_at_ms BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_live_join_session_time (session_id, created_at_ms), + KEY idx_live_join_company_time (company_id, created_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_join_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + company_id INTEGER NOT NULL DEFAULT 1, + event_type TEXT NOT NULL DEFAULT 'open', + platform TEXT NOT NULL DEFAULT 'unknown', + channel TEXT NOT NULL DEFAULT 'short_link', + direct INTEGER NOT NULL DEFAULT 0, + user_id INTEGER NULL, + anon_id TEXT NULL, + user_agent TEXT NULL, + ip_hash TEXT NULL, + created_at_ms INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + } + } + + private static function ensureIndexes(PDO $pdo): void { + if (Database::isMysql()) { + return; + } + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_company_status ON live_cast_sessions (company_id, status)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_last_hb ON live_cast_sessions (last_heartbeat_ms)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_session_time ON live_cast_join_events (session_id, created_at_ms)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_company_time ON live_cast_join_events (company_id, created_at_ms)'); + } + + /** @return array */ + public static function createIntent(array $input, array $actor): array { + self::ensureSchema(); + self::expireStale(); + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $userId = (int) ($actor['id'] ?? 0); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $ownerUsername = trim((string) ($actor['username'] ?? '')); + $platform = strtolower(trim((string) ($input['platform'] ?? 'android'))); + if ($platform === '') { + $platform = 'android'; + } + $deviceId = trim((string) ($input['device_id'] ?? '')); + $maxDuration = (int) ($input['max_duration_s'] ?? 300); + $maxDuration = max(60, min(1800, $maxDuration)); + if (str_starts_with($platform, 'education')) { + $maxDuration = min($maxDuration, 300); + } + + $sessionId = self::newSessionId($now, $companyId, $userId); + $joinLongUrl = self::joinLongUrl($sessionId, false); + $directLongUrl = self::joinLongUrl($sessionId, true); + + $links = self::makeShortLinks($joinLongUrl, $directLongUrl); + $meta = is_array($input['meta'] ?? null) ? $input['meta'] : []; + + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_sessions ( + session_id, company_id, owner_user_id, owner_username, device_id, platform, status, + started_at_ms, last_heartbeat_ms, max_duration_s, + join_short_url, join_qr_url, join_slug, direct_short_url, direct_qr_url, direct_slug, + codec_video, codec_audio, bw_mode, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $companyId, + $userId > 0 ? $userId : null, + $ownerUsername !== '' ? $ownerUsername : null, + $deviceId !== '' ? $deviceId : null, + $platform, + 'intent', + $now, + $now, + $maxDuration, + $links['join_short_url'], + $links['join_qr_url'], + $links['join_slug'], + $links['direct_short_url'], + $links['direct_qr_url'], + $links['direct_slug'], + trim((string) ($input['codec_video'] ?? '')) ?: null, + trim((string) ($input['codec_audio'] ?? '')) ?: null, + trim((string) ($input['bw_mode'] ?? '')) ?: null, + safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ]); + + return self::sessionById($sessionId) ?? []; + } + + /** @return array|null */ + public static function heartbeat(string $sessionId, array $input, array $actor): ?array { + self::ensureSchema(); + self::expireStale(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return null; + } + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $status = strtolower(trim((string) ($input['status'] ?? 'active'))); + if ($status !== 'ended' && $status !== 'active') { + $status = 'active'; + } + $nextState = $status === 'ended' ? 'closed' : 'live'; + $stmt = $pdo->prepare( + 'UPDATE live_cast_sessions + SET status = ?, last_heartbeat_ms = ?, ended_at_ms = CASE WHEN ? = ? THEN ? ELSE ended_at_ms END, + codec_video = COALESCE(NULLIF(?, \'\'), codec_video), + codec_audio = COALESCE(NULLIF(?, \'\'), codec_audio), + bw_mode = COALESCE(NULLIF(?, \'\'), bw_mode) + WHERE session_id = ? AND company_id = ?' + ); + $stmt->execute([ + $nextState, + $now, + $nextState, + 'closed', + $now, + trim((string) ($input['codec_video'] ?? '')), + trim((string) ($input['codec_audio'] ?? '')), + trim((string) ($input['bw_mode'] ?? '')), + $sessionId, + $companyId, + ]); + if ($stmt->rowCount() < 1) { + return null; + } + if (isset($input['stats']) && is_array($input['stats'])) { + $row = self::sessionById($sessionId); + $meta = []; + if ($row !== null && !empty($row['metadata_json'])) { + $decoded = json_decode((string) $row['metadata_json'], true); + if (is_array($decoded)) { + $meta = $decoded; + } + } + $meta['last_stats'] = $input['stats']; + $meta['last_stats_ms'] = $now; + $upd = $pdo->prepare('UPDATE live_cast_sessions SET metadata_json = ? WHERE session_id = ?'); + $upd->execute([ + safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + $sessionId, + ]); + } + return self::sessionById($sessionId); + } + + /** @return list> */ + public static function activeSessions(array $actor, ?string $platform = null): array { + self::ensureSchema(); + self::expireStale(); + $pdo = Database::pdo(); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $where = ['company_id = ?', "status IN ('intent','live')"]; + $params = [$companyId]; + if ($platform !== null && trim($platform) !== '') { + $where[] = 'platform = ?'; + $params[] = strtolower(trim($platform)); + } + $sql = 'SELECT session_id, owner_user_id, owner_username, device_id, platform, status, + started_at_ms, last_heartbeat_ms, max_duration_s, ended_at_ms, + join_short_url, join_qr_url, direct_short_url, direct_qr_url, + codec_video, codec_audio, bw_mode + FROM live_cast_sessions + WHERE ' . implode(' AND ', $where) . ' + ORDER BY last_heartbeat_ms DESC LIMIT 200'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = self::normalizeSessionRow($row); + } + return $out; + } + + /** @return array */ + public static function analytics(int $days, array $actor): array { + self::ensureSchema(); + self::expireStale(); + $days = max(1, min(90, $days)); + $startMs = (int) ((time() - ($days * 86400)) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $pdo = Database::pdo(); + + $castsPerDay = self::countByDay($pdo, 'live_cast_sessions', 'started_at_ms', $startMs, $companyId); + $avgDurationPerDay = self::avgDurationByDay($pdo, $startMs, $companyId); + $platformMix = self::breakdown($pdo, 'live_cast_sessions', 'platform', $startMs, $companyId); + $videoCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_video', $startMs, $companyId); + $audioCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_audio', $startMs, $companyId); + $bwModeMix = self::breakdown($pdo, 'live_cast_sessions', 'bw_mode', $startMs, $companyId); + $joinsPerPlatform = self::breakdown($pdo, 'live_cast_join_events', 'platform', $startMs, $companyId); + + $topUsersStmt = $pdo->prepare( + 'SELECT COALESCE(NULLIF(owner_username, \'\'), \'unknown\') AS u, COUNT(*) AS c + FROM live_cast_sessions + WHERE company_id = ? AND started_at_ms >= ? + GROUP BY u ORDER BY c DESC LIMIT 20' + ); + $topUsersStmt->execute([$companyId, $startMs]); + $topUsers = []; + foreach ($topUsersStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $topUsers[] = ['label' => (string) ($row['u'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)]; + } + + return [ + 'window_days' => $days, + 'casts_per_day' => $castsPerDay, + 'avg_length_s_per_day' => $avgDurationPerDay, + 'platform_mix' => $platformMix, + 'joins_platform_mix' => $joinsPerPlatform, + 'video_codec_mix' => $videoCodecMix, + 'audio_codec_mix' => $audioCodecMix, + 'bw_mode_mix' => $bwModeMix, + 'top_users' => $topUsers, + ]; + } + + public static function registerJoin(string $sessionId, array $payload, array $actor): bool { + self::ensureSchema(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return false; + } + $session = self::sessionById($sessionId); + if ($session === null) { + return false; + } + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $companyId = (int) ($session['company_id'] ?? Rbac::defaultCompanyId()); + $eventType = strtolower(trim((string) ($payload['event_type'] ?? 'open'))); + if (!in_array($eventType, ['open', 'join', 'leave'], true)) { + $eventType = 'open'; + } + $platform = strtolower(trim((string) ($payload['platform'] ?? 'unknown'))); + if ($platform === '') { + $platform = 'unknown'; + } + $channel = strtolower(trim((string) ($payload['channel'] ?? 'short_link'))); + if ($channel === '') { + $channel = 'short_link'; + } + $direct = !empty($payload['direct']) ? 1 : 0; + $userId = (int) ($actor['id'] ?? 0); + $anon = trim((string) ($payload['anon_id'] ?? '')); + $ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + $ip = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); + $ipHash = $ip !== '' ? hash('sha256', $ip . '|ac-live-cast') : null; + + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_join_events + (session_id, company_id, event_type, platform, channel, direct, user_id, anon_id, user_agent, ip_hash, created_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $companyId, + $eventType, + $platform, + $channel, + $direct, + $userId > 0 ? $userId : null, + $anon !== '' ? $anon : null, + $ua !== '' ? substr($ua, 0, 255) : null, + $ipHash, + $now, + ]); + return true; + } + + /** @return array|null */ + public static function sessionById(string $sessionId): ?array { + self::ensureSchema(); + $stmt = Database::pdo()->prepare( + 'SELECT * FROM live_cast_sessions WHERE session_id = ? LIMIT 1' + ); + $stmt->execute([$sessionId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + return null; + } + return self::normalizeSessionRow($row); + } + + private static function expireStale(): void { + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $staleBefore = $now - 60_000; + $sql = "UPDATE live_cast_sessions + SET status = 'expired', ended_at_ms = COALESCE(ended_at_ms, ?) + WHERE status IN ('intent','live') + AND last_heartbeat_ms < ?"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$now, $staleBefore]); + } + + /** @return array */ + private static function normalizeSessionRow(array $row): array { + return [ + 'session_id' => (string) ($row['session_id'] ?? ''), + 'company_id' => (int) ($row['company_id'] ?? 0), + 'owner_user_id' => isset($row['owner_user_id']) ? (int) $row['owner_user_id'] : null, + 'owner_username' => (string) ($row['owner_username'] ?? ''), + 'device_id' => (string) ($row['device_id'] ?? ''), + 'platform' => (string) ($row['platform'] ?? ''), + 'status' => (string) ($row['status'] ?? ''), + 'started_at_ms' => (int) ($row['started_at_ms'] ?? 0), + 'last_heartbeat_ms' => (int) ($row['last_heartbeat_ms'] ?? 0), + 'ended_at_ms' => isset($row['ended_at_ms']) ? (int) $row['ended_at_ms'] : null, + 'max_duration_s' => (int) ($row['max_duration_s'] ?? 0), + 'join_short_url' => (string) ($row['join_short_url'] ?? ''), + 'join_qr_url' => (string) ($row['join_qr_url'] ?? ''), + 'direct_short_url' => (string) ($row['direct_short_url'] ?? ''), + 'direct_qr_url' => (string) ($row['direct_qr_url'] ?? ''), + 'codec_video' => (string) ($row['codec_video'] ?? ''), + 'codec_audio' => (string) ($row['codec_audio'] ?? ''), + 'bw_mode' => (string) ($row['bw_mode'] ?? ''), + 'metadata_json' => (string) ($row['metadata_json'] ?? ''), + ]; + } + + private static function newSessionId(int $nowMs, int $companyId, int $userId): string { + $seed = $nowMs . ':' . $companyId . ':' . $userId . ':' . bin2hex(random_bytes(6)); + return 'live-' . substr(hash('sha256', $seed), 0, 20); + } + + private static function joinLongUrl(string $sessionId, bool $direct): string { + $base = rtrim((string) cfg('base_path', '/app/androidcast_project/issues'), '/'); + if (str_ends_with($base, '/crashes')) { + $base = substr($base, 0, -strlen('/crashes')) . '/issues'; + } + $qs = 'session_id=' . rawurlencode($sessionId); + if ($direct) { + $qs .= '&direct=1'; + } + return 'https://apps.f0xx.org' . $base . '/live/join?' . $qs; + } + + /** @return array */ + private static function makeShortLinks(string $joinLongUrl, string $directLongUrl): array { + $out = [ + 'join_short_url' => null, + 'join_qr_url' => null, + 'join_slug' => null, + 'direct_short_url' => null, + 'direct_qr_url' => null, + 'direct_slug' => null, + ]; + if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) { + return $out; + } + $bearers = ShortLinksRepository::listBearers(); + if ($bearers === []) { + return $out; + } + $bearerId = (int) ($bearers[0]['id'] ?? 0); + if ($bearerId <= 0) { + return $out; + } + $join = ShortLinksRepository::createLink($bearerId, $joinLongUrl, 3600); + if (!empty($join['ok'])) { + $out['join_short_url'] = (string) ($join['short_url'] ?? ''); + $out['join_qr_url'] = (string) ($join['qr_url'] ?? ''); + $out['join_slug'] = (string) ($join['slug'] ?? ''); + } + $direct = ShortLinksRepository::createLink($bearerId, $directLongUrl, 3600); + if (!empty($direct['ok'])) { + $out['direct_short_url'] = (string) ($direct['short_url'] ?? ''); + $out['direct_qr_url'] = (string) ($direct['qr_url'] ?? ''); + $out['direct_slug'] = (string) ($direct['slug'] ?? ''); + } + return $out; + } + + /** @return list */ + private static function countByDay(PDO $pdo, string $table, string $msColumn, int $startMs, int $companyId): array { + $day = Database::isMysql() + ? 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))' + : "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')"; + $stmt = $pdo->prepare( + 'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . ' + WHERE company_id = ? AND ' . $msColumn . ' >= ? + GROUP BY d ORDER BY d' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => (int) ($row['c'] ?? 0)]; + } + return $out; + } + + /** @return list */ + private static function avgDurationByDay(PDO $pdo, int $startMs, int $companyId): array { + $day = Database::isMysql() + ? 'DATE(FROM_UNIXTIME(FLOOR(started_at_ms / 1000)))' + : "strftime('%Y-%m-%d', started_at_ms / 1000, 'unixepoch')"; + $stmt = $pdo->prepare( + 'SELECT ' . $day . ' AS d, + AVG(CASE WHEN ended_at_ms IS NOT NULL AND ended_at_ms >= started_at_ms + THEN (ended_at_ms - started_at_ms) / 1000.0 ELSE 0 END) AS v + FROM live_cast_sessions + WHERE company_id = ? AND started_at_ms >= ? + GROUP BY d ORDER BY d' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => round((float) ($row['v'] ?? 0), 1)]; + } + return $out; + } + + /** @return list> */ + public static function listSessions(int $days, array $actor, int $limit = 200): array { + self::ensureSchema(); + self::expireStale(); + $days = max(1, min(90, $days)); + $startMs = (int) ((time() - ($days * 86400)) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $limit = max(1, min(500, $limit)); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT s.session_id, s.owner_user_id, s.owner_username, s.device_id, s.platform, s.status, + s.started_at_ms, s.last_heartbeat_ms, s.ended_at_ms, s.max_duration_s, + s.join_short_url, s.direct_short_url, s.codec_video, s.codec_audio, s.bw_mode, + (SELECT COUNT(*) FROM live_cast_join_events j + WHERE j.session_id = s.session_id AND j.event_type IN (\'open\', \'join\')) AS join_opens + FROM live_cast_sessions s + WHERE s.company_id = ? AND s.started_at_ms >= ? + ORDER BY s.last_heartbeat_ms DESC + LIMIT ' . (int) $limit + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $normalized = self::normalizeSessionRow($row); + $normalized['join_opens'] = (int) ($row['join_opens'] ?? 0); + $out[] = $normalized; + } + return $out; + } + + /** @return array|null */ + public static function publicSession(string $sessionId): ?array { + $row = self::sessionById($sessionId); + if ($row === null) { + return null; + } + return [ + 'session_id' => $row['session_id'], + 'owner_username' => $row['owner_username'], + 'platform' => $row['platform'], + 'status' => $row['status'], + 'started_at_ms' => $row['started_at_ms'], + 'last_heartbeat_ms' => $row['last_heartbeat_ms'], + 'ended_at_ms' => $row['ended_at_ms'], + 'max_duration_s' => $row['max_duration_s'], + 'codec_video' => $row['codec_video'], + 'codec_audio' => $row['codec_audio'], + 'bw_mode' => $row['bw_mode'], + 'join_short_url' => $row['join_short_url'] ?? '', + 'join_qr_url' => $row['join_qr_url'] ?? '', + 'signaling_api' => Auth::basePath() . '/api/live_cast_signaling.php', + ]; + } + + /** @return list */ + private static function breakdown(PDO $pdo, string $table, string $column, int $startMs, int $companyId): array { + $allowedTables = ['live_cast_sessions', 'live_cast_join_events']; + if (!in_array($table, $allowedTables, true)) { + return []; + } + $allowedCols = ['platform', 'codec_video', 'codec_audio', 'bw_mode']; + if ($table === 'live_cast_join_events') { + $allowedCols = ['platform']; + } + if (!in_array($column, $allowedCols, true)) { + return []; + } + $timeColumn = $table === 'live_cast_sessions' ? 'started_at_ms' : 'created_at_ms'; + $stmt = $pdo->prepare( + 'SELECT COALESCE(NULLIF(' . $column . ', \'\'), \'unknown\') AS k, COUNT(*) AS c + FROM ' . $table . ' + WHERE company_id = ? AND ' . $timeColumn . ' >= ? + GROUP BY k ORDER BY c DESC' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)]; + } + return $out; + } +} + diff --git a/sim/cluster0/lab-seeds/backend/src/LiveCastSignalingRepository.php b/sim/cluster0/lab-seeds/backend/src/LiveCastSignalingRepository.php new file mode 100644 index 0000000..219eddb --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/LiveCastSignalingRepository.php @@ -0,0 +1,134 @@ +exec( + "CREATE TABLE live_cast_signal_messages ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + peer_role VARCHAR(16) NOT NULL DEFAULT 'caster', + msg_type VARCHAR(24) NOT NULL, + payload_json LONGTEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + KEY idx_live_signal_session_id (session_id, id), + KEY idx_live_signal_session_type (session_id, msg_type, id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_signal_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + peer_role TEXT NOT NULL DEFAULT 'caster', + msg_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL + )" + ); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_signal_session_id ON live_cast_signal_messages (session_id, id)'); + } + } + }); + self::$schemaEnsured = true; + } + + /** @param array $payload */ + public static function post(string $sessionId, string $peerRole, string $msgType, array $payload): int { + self::ensureSchema(); + $sessionId = trim($sessionId); + $peerRole = strtolower(trim($peerRole)); + $msgType = strtolower(trim($msgType)); + if ($sessionId === '' || $msgType === '') { + return 0; + } + if (!in_array($peerRole, ['caster', 'viewer', 'mobile'], true)) { + $peerRole = 'caster'; + } + $now = (int) floor(microtime(true) * 1000); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_signal_messages (session_id, peer_role, msg_type, payload_json, created_at_ms) + VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $peerRole, + $msgType, + safe_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + $now, + ]); + return (int) $pdo->lastInsertId(); + } + + /** @return list> */ + public static function poll(string $sessionId, int $sinceId, ?string $msgType = null, ?string $fromRole = null): array { + self::ensureSchema(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return []; + } + $sinceId = max(0, $sinceId); + $where = ['session_id = ?', 'id > ?']; + $params = [$sessionId, $sinceId]; + if ($msgType !== null && trim($msgType) !== '') { + $where[] = 'msg_type = ?'; + $params[] = strtolower(trim($msgType)); + } + if ($fromRole !== null && trim($fromRole) !== '') { + $where[] = 'peer_role = ?'; + $params[] = strtolower(trim($fromRole)); + } + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT id, session_id, peer_role, msg_type, payload_json, created_at_ms + FROM live_cast_signal_messages + WHERE ' . implode(' AND ', $where) . ' + ORDER BY id ASC LIMIT 200' + ); + $stmt->execute($params); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $payload = json_decode((string) ($row['payload_json'] ?? ''), true); + $out[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'session_id' => (string) ($row['session_id'] ?? ''), + 'peer_role' => (string) ($row['peer_role'] ?? ''), + 'msg_type' => (string) ($row['msg_type'] ?? ''), + 'payload' => is_array($payload) ? $payload : [], + 'created_at_ms' => (int) ($row['created_at_ms'] ?? 0), + ]; + } + return $out; + } + + /** @return array|null */ + public static function latest(string $sessionId, string $msgType, ?string $fromRole = null): ?array { + self::ensureSchema(); + $rows = self::poll($sessionId, 0, $msgType, $fromRole); + if ($rows === []) { + return null; + } + return $rows[count($rows) - 1]; + } + + public static function purgeSession(string $sessionId): void { + self::ensureSchema(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return; + } + $stmt = Database::pdo()->prepare('DELETE FROM live_cast_signal_messages WHERE session_id = ?'); + $stmt->execute([$sessionId]); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/Rbac.php b/sim/cluster0/lab-seeds/backend/src/Rbac.php new file mode 100644 index 0000000..95e67b1 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/Rbac.php @@ -0,0 +1,401 @@ + */ + private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY]; + + /** Company role => granted actions (unless global admin). */ + /** + * Named privilege sets stored in company_memberships.permissions_json as {"grants":[...]}. + * Company role defaults still apply; grants extend (not replace) role actions. + */ + public const PRIVILEGE_SET_NONE = ''; + public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user'; + public const PRIVILEGE_SET_REMOTE_ACCESS_ADMIN = 'remote_access_admin'; + public const PRIVILEGE_SET_SHORT_LINKS_VIEWER = 'short_links_viewer'; + public const PRIVILEGE_SET_SHORT_LINKS_USER = 'short_links_user'; + + /** @var array> */ + public const PRIVILEGE_SETS = [ + self::PRIVILEGE_SET_REMOTE_ACCESS_USER => [ + 'remote_access_view', + 'remote_access_operate', + ], + self::PRIVILEGE_SET_REMOTE_ACCESS_ADMIN => [ + 'remote_access_view', + 'remote_access_operate', + 'remote_access_admin', + ], + self::PRIVILEGE_SET_SHORT_LINKS_VIEWER => [ + 'short_links_view', + ], + self::PRIVILEGE_SET_SHORT_LINKS_USER => [ + 'short_links_view', + 'short_links_operate', + ], + ]; + + private const COMPANY_ROLE_ACTIONS = [ + self::COMPANY_OWNER => [ + 'view_reports', + 'edit_tags', + 'upload_reports', + 'manage_devices', + 'manage_company_users', + 'manage_company_settings', + 'manage_self', + 'remote_access_view', + 'remote_access_operate', + 'remote_access_admin', + 'short_links_view', + 'short_links_operate', + ], + self::COMPANY_ADMIN => [ + 'view_reports', + 'edit_tags', + 'upload_reports', + 'manage_devices', + 'manage_company_users', + 'manage_self', + 'remote_access_view', + 'remote_access_operate', + 'remote_access_admin', + 'short_links_view', + 'short_links_operate', + ], + self::COMPANY_MEMBER => [ + 'view_reports', + 'manage_self', + 'remote_access_view', + ], + ]; + + public static function normalizeGlobalRole(string $role): string { + $role = strtolower(trim($role)); + if ($role === self::GLOBAL_ADMIN_LEGACY) { + return self::GLOBAL_PLATFORM_ADMIN; + } + return $role; + } + + public static function isGlobalAdmin(?array $user = null): bool { + $user ??= Auth::user(); + if (!$user) { + return false; + } + $role = self::normalizeGlobalRole((string) ($user['role'] ?? '')); + return in_array($role, self::GLOBAL_ADMIN_ROLES, true); + } + + /** @return list|null null = all companies (global admin) */ + public static function allowedCompanyIds(?array $user = null): ?array { + if (self::isGlobalAdmin($user)) { + return null; + } + $user ??= Auth::user(); + if (!$user) { + return []; + } + $ids = []; + foreach ($user['companies'] ?? [] as $c) { + if (isset($c['id'])) { + $ids[] = (int) $c['id']; + } + } + return array_values(array_unique($ids)); + } + + public static function activeCompanyId(?array $user = null): ?int { + $user ??= Auth::user(); + if (!$user) { + return null; + } + $id = (int) ($user['active_company_id'] ?? 0); + return $id > 0 ? $id : null; + } + + public static function companyRole(int $userId, int $companyId): ?string { + $stmt = Database::pdo()->prepare( + 'SELECT role FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1' + ); + $stmt->execute([$userId, $companyId]); + $role = $stmt->fetchColumn(); + return is_string($role) && $role !== '' ? $role : null; + } + + public static function can(string $action, ?int $companyId = null, ?array $user = null): bool { + $user ??= Auth::user(); + if (!$user) { + return false; + } + if (self::isGlobalAdmin($user)) { + return true; + } + if ($action === 'manage_platform') { + return false; + } + if ($action === 'manage_self') { + return true; + } + $companyId ??= self::activeCompanyId($user); + if ($companyId === null || $companyId <= 0) { + return false; + } + $role = self::membershipRoleForUser($user, $companyId); + if ($role === null) { + return false; + } + $grants = self::COMPANY_ROLE_ACTIONS[$role] ?? []; + if (in_array($action, $grants, true)) { + return true; + } + return self::hasPermissionOverride($user, $companyId, $action); + } + + public static function canAccessCompany(int $companyId, ?array $user = null): bool { + $allowed = self::allowedCompanyIds($user); + if ($allowed === null) { + return true; + } + return in_array($companyId, $allowed, true); + } + + public static function canAccessReport(array $report, ?array $user = null): bool { + $companyId = (int) ($report['company_id'] ?? 0); + if ($companyId <= 0) { + return self::isGlobalAdmin($user); + } + return self::canAccessCompany($companyId, $user); + } + + public static function defaultCompanyId(): int { + static $cached = 0; + if ($cached > 0) { + return $cached; + } + $fromCfg = (int) cfg('rbac.default_company_id', 0); + if ($fromCfg > 0) { + $cached = $fromCfg; + return $cached; + } + $slug = (string) cfg('rbac.default_company_slug', 'default'); + $pdo = Database::pdo(); + $stmt = $pdo->prepare('SELECT id FROM companies WHERE slug = ? LIMIT 1'); + $stmt->execute([$slug]); + $id = (int) $stmt->fetchColumn(); + if ($id > 0) { + $cached = $id; + return $cached; + } + $cached = 1; + return $cached; + } + + /** + * Append company scope to WHERE fragments. + * + * @param list $where + * @param list $params + */ + public static function appendCompanyScope(string $column, array &$where, array &$params, ?array $user = null): void { + $allowed = self::allowedCompanyIds($user); + if ($allowed === null) { + return; + } + if ($allowed === []) { + $where[] = '0=1'; + return; + } + $placeholders = implode(',', array_fill(0, count($allowed), '?')); + $where[] = "$column IN ($placeholders)"; + foreach ($allowed as $id) { + $params[] = $id; + } + } + + /** Match fingerprint counts within the same company. */ + public static function fingerprintCountSql(string $rAlias = 'r', string $r2Alias = 'r2'): string { + return " AND $r2Alias.company_id = $rAlias.company_id"; + } + + /** + * @return list + */ + public static function fetchMemberships(int $userId): array { + $stmt = Database::pdo()->prepare( + 'SELECT c.id, c.slug, c.name, m.role + FROM company_memberships m + INNER JOIN companies c ON c.id = m.company_id + WHERE m.user_id = ? + ORDER BY c.name ASC' + ); + $stmt->execute([$userId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $out = []; + foreach ($rows as $row) { + $out[] = [ + 'id' => (int) $row['id'], + 'slug' => (string) $row['slug'], + 'name' => (string) $row['name'], + 'role' => (string) $row['role'], + ]; + } + return $out; + } + + /** + * @param list $memberships + */ + public static function pickActiveCompanyId(array $memberships): int { + if ($memberships === []) { + return self::defaultCompanyId(); + } + return (int) $memberships[0]['id']; + } + + /** @return array session user payload */ + public static function buildSessionUser(array $dbUser): array { + $uid = (int) $dbUser['id']; + $memberships = self::fetchMemberships($uid); + return [ + 'id' => $uid, + 'username' => (string) $dbUser['username'], + 'role' => self::normalizeGlobalRole((string) ($dbUser['role'] ?? self::GLOBAL_VIEWER)), + 'companies' => $memberships, + 'active_company_id' => self::pickActiveCompanyId($memberships), + ]; + } + + public static function seedMembershipsForUser(int $userId, string $globalRole): void { + $pdo = Database::pdo(); + $companyId = self::defaultCompanyId(); + $globalRole = self::normalizeGlobalRole($globalRole); + $companyRole = self::COMPANY_MEMBER; + if (in_array($globalRole, self::GLOBAL_ADMIN_ROLES, true)) { + $companyRole = self::COMPANY_OWNER; + } + if (Database::isMysql()) { + $sql = 'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'; + } else { + $sql = 'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'; + } + $stmt = $pdo->prepare($sql); + $stmt->execute([$userId, $companyId, $companyRole]); + } + + private static function membershipRoleForUser(array $user, int $companyId): ?string { + foreach ($user['companies'] ?? [] as $c) { + if ((int) ($c['id'] ?? 0) === $companyId) { + return (string) ($c['role'] ?? ''); + } + } + return null; + } + + /** @return list */ + public static function privilegeSetKeys(): array { + return array_keys(self::PRIVILEGE_SETS); + } + + /** @return list */ + public static function grantsForPrivilegeSet(string $setKey): array { + $setKey = trim($setKey); + if ($setKey === '' || $setKey === self::PRIVILEGE_SET_NONE) { + return []; + } + return self::PRIVILEGE_SETS[$setKey] ?? []; + } + + public static function encodePermissionsJson(?string $privilegeSetKey): ?string { + $grants = self::grantsForPrivilegeSet((string) $privilegeSetKey); + if ($grants === []) { + return null; + } + return json_encode(['grants' => $grants], JSON_UNESCAPED_UNICODE); + } + + public static function privilegeSetFromPermissionsJson(?string $raw): string { + if (!is_string($raw) || trim($raw) === '') { + return self::PRIVILEGE_SET_NONE; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return self::PRIVILEGE_SET_NONE; + } + $grants = $decoded['grants'] ?? $decoded; + if (!is_array($grants)) { + return self::PRIVILEGE_SET_NONE; + } + sort($grants); + foreach (self::PRIVILEGE_SETS as $key => $want) { + $sorted = $want; + sort($sorted); + if ($grants === $sorted) { + return $key; + } + } + return 'custom'; + } + + public static function isRoot(?array $user = null): bool { + $user ??= Auth::user(); + if (!$user) { + return false; + } + return self::normalizeGlobalRole((string) ($user['role'] ?? '')) === self::GLOBAL_ROOT; + } + + public static function canManageRbac(?array $user = null): bool { + $user ??= Auth::user(); + if (!$user) { + return false; + } + if (self::isGlobalAdmin($user)) { + return true; + } + foreach ($user['companies'] ?? [] as $c) { + $role = (string) ($c['role'] ?? ''); + if ($role === self::COMPANY_OWNER || $role === self::COMPANY_ADMIN) { + return true; + } + } + return false; + } + + private static function hasPermissionOverride(array $user, int $companyId, string $action): bool { + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT permissions_json FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1' + ); + $stmt->execute([(int) $user['id'], $companyId]); + $raw = $stmt->fetchColumn(); + if (!is_string($raw) || $raw === '') { + return false; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return false; + } + $grants = $decoded['grants'] ?? $decoded; + if (!is_array($grants)) { + return false; + } + return in_array($action, $grants, true); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/RbacAdminRepository.php b/sim/cluster0/lab-seeds/backend/src/RbacAdminRepository.php new file mode 100644 index 0000000..d6d8dc8 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/RbacAdminRepository.php @@ -0,0 +1,252 @@ + false, 'error' => 'forbidden']; + } + $actorId = (int) ($actor['id'] ?? 0); + $globalAdmin = Rbac::isGlobalAdmin($actor); + $companies = self::listCompaniesForActor($actor); + $companyIds = array_map(static fn($c) => (int) $c['id'], $companies); + + return [ + 'ok' => true, + 'actor' => [ + 'id' => $actorId, + 'username' => (string) ($actor['username'] ?? ''), + 'role' => (string) ($actor['role'] ?? ''), + 'is_root' => Rbac::isRoot($actor), + 'is_global_admin' => $globalAdmin, + ], + 'companies' => $companies, + 'users' => self::listUsersWithMemberships($companyIds, $globalAdmin), + 'global_roles' => [ + Rbac::GLOBAL_ROOT, + Rbac::GLOBAL_PLATFORM_ADMIN, + Rbac::GLOBAL_VIEWER, + ], + 'company_roles' => [ + Rbac::COMPANY_OWNER, + Rbac::COMPANY_ADMIN, + Rbac::COMPANY_MEMBER, + ], + 'privilege_sets' => array_map( + static fn($key) => [ + 'key' => $key, + 'label' => str_replace('_', ' ', $key), + 'grants' => Rbac::grantsForPrivilegeSet($key), + ], + Rbac::privilegeSetKeys() + ), + 'permissions' => [ + 'can_edit_global_roles' => Rbac::isRoot($actor), + 'can_manage_platform' => Rbac::can('manage_platform', null, $actor), + 'can_manage_company_users' => $globalAdmin, + ], + ]; + } + + public static function setGlobalRole(int $targetUserId, string $role, ?array $actor = null): array { + $actor ??= Auth::user(); + if (!$actor || !Rbac::isRoot($actor)) { + return ['ok' => false, 'error' => 'forbidden']; + } + $role = Rbac::normalizeGlobalRole($role); + $allowed = [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN, Rbac::GLOBAL_VIEWER]; + if (!in_array($role, $allowed, true)) { + return ['ok' => false, 'error' => 'invalid_role']; + } + if ($targetUserId <= 0) { + return ['ok' => false, 'error' => 'invalid_user']; + } + $pdo = Database::pdo(); + $stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?'); + $stmt->execute([$role, $targetUserId]); + if ($stmt->rowCount() < 1) { + return ['ok' => false, 'error' => 'user_not_found']; + } + if (in_array($role, [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN], true)) { + Rbac::seedMembershipsForUser($targetUserId, $role); + } + return ['ok' => true]; + } + + public static function setCompanyRole(int $targetUserId, int $companyId, string $role, ?array $actor = null): array { + $actor ??= Auth::user(); + if (!$actor || !self::canEditMembership($actor, $companyId)) { + return ['ok' => false, 'error' => 'forbidden']; + } + $allowed = [Rbac::COMPANY_OWNER, Rbac::COMPANY_ADMIN, Rbac::COMPANY_MEMBER]; + if (!in_array($role, $allowed, true)) { + return ['ok' => false, 'error' => 'invalid_role']; + } + if ($targetUserId <= 0 || $companyId <= 0) { + return ['ok' => false, 'error' => 'invalid_input']; + } + $pdo = Database::pdo(); + if (Database::isMysql()) { + $sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE role = VALUES(role)'; + } else { + $sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?) + ON CONFLICT(user_id, company_id) DO UPDATE SET role = excluded.role'; + } + $stmt = $pdo->prepare($sql); + $stmt->execute([$targetUserId, $companyId, $role]); + return ['ok' => true]; + } + + public static function applyPrivilegeSet(int $targetUserId, int $companyId, string $setKey, ?array $actor = null): array { + $actor ??= Auth::user(); + if (!$actor || !self::canEditMembership($actor, $companyId)) { + return ['ok' => false, 'error' => 'forbidden']; + } + if ($targetUserId <= 0 || $companyId <= 0) { + return ['ok' => false, 'error' => 'invalid_input']; + } + $setKey = trim($setKey); + if ($setKey !== '' && $setKey !== Rbac::PRIVILEGE_SET_NONE && !isset(Rbac::PRIVILEGE_SETS[$setKey])) { + return ['ok' => false, 'error' => 'invalid_privilege_set']; + } + $json = Rbac::encodePermissionsJson($setKey === Rbac::PRIVILEGE_SET_NONE ? '' : $setKey); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'UPDATE company_memberships SET permissions_json = ? WHERE user_id = ? AND company_id = ?' + ); + $stmt->execute([$json, $targetUserId, $companyId]); + if ($stmt->rowCount() < 1) { + return ['ok' => false, 'error' => 'membership_not_found']; + } + return ['ok' => true]; + } + + /** @return list */ + private static function listCompaniesForActor(array $actor): array { + $pdo = Database::pdo(); + $allowed = Rbac::allowedCompanyIds($actor); + if ($allowed === null) { + $stmt = $pdo->query('SELECT id, slug, name FROM companies ORDER BY name ASC'); + return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + if ($allowed === []) { + return []; + } + $ph = implode(',', array_fill(0, count($allowed), '?')); + $stmt = $pdo->prepare("SELECT id, slug, name FROM companies WHERE id IN ($ph) ORDER BY name ASC"); + $stmt->execute($allowed); + return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @param list> $rows @return list */ + private static function mapCompanies(array $rows): array { + $out = []; + foreach ($rows as $row) { + $out[] = [ + 'id' => (int) $row['id'], + 'slug' => (string) $row['slug'], + 'name' => (string) $row['name'], + ]; + } + return $out; + } + + /** + * @param list $companyIds + * @return list> + */ + private static function listUsersWithMemberships(array $companyIds, bool $globalAdmin): array { + $pdo = Database::pdo(); + if ($globalAdmin) { + $users = $pdo->query('SELECT id, username, role FROM users ORDER BY username ASC')->fetchAll(PDO::FETCH_ASSOC); + } else { + if ($companyIds === []) { + return []; + } + $ph = implode(',', array_fill(0, count($companyIds), '?')); + $stmt = $pdo->prepare( + "SELECT DISTINCT u.id, u.username, u.role + FROM users u + INNER JOIN company_memberships m ON m.user_id = u.id + WHERE m.company_id IN ($ph) + ORDER BY u.username ASC" + ); + $stmt->execute($companyIds); + $users = $stmt->fetchAll(PDO::FETCH_ASSOC); + } + $out = []; + foreach ($users as $u) { + $uid = (int) $u['id']; + $memberships = self::membershipsForUser($uid, $companyIds, $globalAdmin); + $out[] = [ + 'id' => $uid, + 'username' => (string) $u['username'], + 'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')), + 'memberships' => $memberships, + 'auth_failures' => AuthAttempts::recentFailureCount((string) $u['username']), + ]; + } + return $out; + } + + /** + * @param list $companyIds + * @return list> + */ + private static function membershipsForUser(int $userId, array $companyIds, bool $globalAdmin): array { + $pdo = Database::pdo(); + if ($globalAdmin) { + $stmt = $pdo->prepare( + 'SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json + FROM company_memberships m + INNER JOIN companies c ON c.id = m.company_id + WHERE m.user_id = ? + ORDER BY c.name ASC' + ); + $stmt->execute([$userId]); + } else { + if ($companyIds === []) { + return []; + } + $ph = implode(',', array_fill(0, count($companyIds), '?')); + $params = array_merge([$userId], $companyIds); + $stmt = $pdo->prepare( + "SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json + FROM company_memberships m + INNER JOIN companies c ON c.id = m.company_id + WHERE m.user_id = ? AND m.company_id IN ($ph) + ORDER BY c.name ASC" + ); + $stmt->execute($params); + } + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $raw = isset($row['permissions_json']) ? (string) $row['permissions_json'] : ''; + $out[] = [ + 'company_id' => (int) $row['company_id'], + 'slug' => (string) $row['slug'], + 'name' => (string) $row['name'], + 'role' => (string) $row['role'], + 'privilege_set' => Rbac::privilegeSetFromPermissionsJson($raw), + ]; + } + return $out; + } + + private static function canEditMembership(array $actor, int $companyId): bool { + if (Rbac::isGlobalAdmin($actor)) { + return true; + } + if (!Rbac::canAccessCompany($companyId, $actor)) { + return false; + } + $uid = (int) ($actor['id'] ?? 0); + $role = $uid > 0 ? Rbac::companyRole($uid, $companyId) : null; + return $role === Rbac::COMPANY_OWNER || $role === Rbac::COMPANY_ADMIN; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php b/sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php new file mode 100644 index 0000000..6739920 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php @@ -0,0 +1,1423 @@ +exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $ddl); + } + + private static function ensureLastClientIpColumn(PDO $pdo): void { + $col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL'; + self::addColumnIfMissing($pdo, 'remote_access_devices', 'last_client_ip', $col); + } + + private static function ensureDeviceMetaColumn(PDO $pdo): void { + $col = Database::isMysql() ? 'LONGTEXT NULL' : 'TEXT NULL'; + self::addColumnIfMissing($pdo, 'remote_access_devices', 'device_meta_json', $col); + } + + /** @param list $where @param list $params */ + private static function appendCompanyScopeSql(string $column, array &$where, array &$params): void { + Rbac::appendCompanyScope($column, $where, $params); + } + + public static function canAccessDevice(string $deviceId): bool { + $device = self::findDevice($deviceId); + if ($device === null) { + return Rbac::isGlobalAdmin(); + } + + return Rbac::canAccessCompany((int) ($device['company_id'] ?? 0)); + } + + public static function canOperatorCloseSession(string $sessionId, int $operatorUserId): bool { + $session = self::findSession($sessionId); + if ($session === null) { + return false; + } + $companyId = (int) ($session['company_id'] ?? 0); + if (!Rbac::canAccessCompany($companyId)) { + return false; + } + if (Rbac::isGlobalAdmin() || Rbac::can('remote_access_admin', $companyId)) { + return true; + } + + return $operatorUserId > 0 + && (int) ($session['operator_user_id'] ?? 0) === $operatorUserId + && Rbac::can('remote_access_operate', $companyId); + } + + /** @return array|null */ + public static function findSession(string $sessionId): ?array { + self::ensureSchema(); + $st = Database::pdo()->prepare('SELECT * FROM remote_access_sessions WHERE session_id = ? LIMIT 1'); + $st->execute([$sessionId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + private static function ensureRsshColumns(PDO $pdo): void { + if (!Database::tableExists($pdo, 'remote_access_sessions')) { + return; + } + $cols = [ + 'rssh_bastion_host' => Database::isMysql() ? 'VARCHAR(255) NULL' : 'TEXT NULL', + 'rssh_bastion_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL', + 'rssh_username' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL', + 'rssh_secret' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL', + 'rssh_remote_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL', + 'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL', + ]; + foreach ($cols as $name => $ddl) { + self::addColumnIfMissing($pdo, 'remote_access_sessions', $name, $ddl); + } + } + + private static function ensureSessionColumns(PDO $pdo): void { + $col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL'; + self::addColumnIfMissing($pdo, 'remote_access_sessions', 'wg_client_public_key', $col); + } + + private static function createTables(PDO $pdo): void { + $path = Database::isMysql() + ? __DIR__ . '/../sql/migrations/007_remote_access.sql' + : __DIR__ . '/../sql/migrations/007_remote_access.sqlite.sql'; + if (!is_readable($path)) { + throw new RuntimeException('remote access migration missing: ' . $path); + } + $sql = (string) file_get_contents($path); + foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) { + if ($stmt !== '') { + $pdo->exec($stmt); + } + } + } + + /** @return array heartbeat.ra response payload */ + public static function handleDeviceHeartbeat(array $heartbeat, string $clientIp): array { + self::ensureSchema(); + $deviceId = trim((string) ($heartbeat['device_id'] ?? '')); + if ($deviceId === '') { + return self::raEnvelope('deny', ['reason' => 'missing_device_id']); + } + + $status = strtolower(trim((string) ($heartbeat['status'] ?? 'enable'))); + $random = trim((string) ($heartbeat['random'] ?? '')); + $appVersion = trim((string) ($heartbeat['app_version'] ?? '')); + $capabilities = $heartbeat['capabilities'] ?? []; + if (!is_array($capabilities)) { + $capabilities = []; + } + $tunnelMode = self::inferTunnelMode($capabilities, $heartbeat); + + if ($status === 'disable') { + return self::handleDisable($deviceId, $random, $clientIp, $appVersion); + } + + $existing = self::findDevice($deviceId); + $pendingSession = null; + if ($existing !== null && self::isRateLimited($existing)) { + $pendingSession = self::findConnectableSession($deviceId, $random); + if ($pendingSession === null) { + return self::raEnvelope('wait', ['reason' => 'rate_limited']); + } + } + + $deviceMeta = is_array($heartbeat['device'] ?? null) ? $heartbeat['device'] : []; + $routeScope = trim((string) ($heartbeat['vpn_route_scope'] ?? '')); + $appScope = trim((string) ($heartbeat['vpn_app_scope'] ?? '')); + if ($routeScope !== '') { + $deviceMeta['vpn_route_scope'] = $routeScope; + } + if ($appScope !== '') { + $deviceMeta['vpn_app_scope'] = $appScope; + } + + self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp, $deviceMeta); + + $device = self::findDevice($deviceId); + if (!$device || !(int) ($device['whitelisted'] ?? 0)) { + self::logEvent($deviceId, null, null, 'poll_wait', 'not_whitelisted', [ + 'opt_in' => $tunnelMode, + ], $clientIp); + return self::raEnvelope('wait', ['reason' => 'not_whitelisted']); + } + + if ($tunnelMode === self::OPT_NONE) { + return self::raEnvelope('wait', ['reason' => 'opt_in_none']); + } + + if ($tunnelMode === self::OPT_RSSH) { + $session = $pendingSession ?? self::findConnectableSession($deviceId, $random); + if ($session === null) { + self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp); + return self::raEnvelope('wait', ['reason' => 'no_pending_session']); + } + self::touchSessionActivity((string) ($session['session_id'] ?? '')); + return self::buildConnectResponse($session, $deviceId, $clientIp); + } + + $session = $pendingSession ?? self::findConnectableSession($deviceId, $random); + if ($session === null) { + self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp); + return self::raEnvelope('wait', ['reason' => 'no_pending_session']); + } + + self::touchSessionActivity((string) ($session['session_id'] ?? '')); + + return self::buildConnectResponse($session, $deviceId, $clientIp); + } + + /** @param array $device */ + private static function isRateLimited(array $device): bool { + $min = max(15, (int) cfg('remote_access.min_poll_interval_s', 45)); + $last = (string) ($device['last_seen_at'] ?? ''); + if ($last === '') { + return false; + } + $ts = strtotime($last); + if ($ts === false) { + return false; + } + return (time() - $ts) < $min; + } + + private static function touchSessionActivity(string $sessionId): void { + if ($sessionId === '') { + return; + } + Database::pdo()->prepare( + 'UPDATE remote_access_sessions SET last_activity_at = ? WHERE session_id = ?' + )->execute([self::nowSql(), $sessionId]); + } + + /** @param list $capabilities */ + private static function inferTunnelMode(array $capabilities, array $heartbeat): string { + foreach ($capabilities as $cap) { + $c = strtolower(trim((string) $cap)); + if ($c === 'wireguard' || $c === 'wg') { + return self::OPT_WIREGUARD; + } + if ($c === 'ssh_reverse' || $c === 'rssh') { + return self::OPT_RSSH; + } + } + $mode = strtolower(trim((string) ($heartbeat['tunnel_mode'] ?? ''))); + if (in_array($mode, [self::OPT_WIREGUARD, self::OPT_RSSH, self::OPT_NONE], true)) { + return $mode; + } + return self::OPT_WIREGUARD; + } + + private static function handleDisable(string $deviceId, string $random, string $clientIp, string $appVersion): array { + $pdo = Database::pdo(); + $now = self::nowSql(); + $pdo->prepare( + 'UPDATE remote_access_devices SET whitelisted = 0, opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([self::OPT_NONE, $random, $appVersion, $now, $now, $deviceId]); + + self::closeActiveSessionsForDevice($deviceId, 'user_disabled'); + self::logEvent($deviceId, null, null, 'user_disable', 'device_opt_out', null, $clientIp); + self::logEvent($deviceId, null, null, 'whitelist_revoked', 'device_opt_out', null, $clientIp); + + return self::raEnvelope('disabled', ['reason' => 'user_opt_out']); + } + + private static function upsertDevicePoll( + string $deviceId, + string $random, + string $appVersion, + string $tunnelMode, + string $clientIp, + array $deviceMeta = [] + ): void { + $pdo = Database::pdo(); + $now = self::nowSql(); + $metaJson = self::encodeDeviceMeta($deviceMeta); + $existing = self::findDevice($deviceId); + if ($existing) { + if ($metaJson !== null) { + $pdo->prepare( + 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, + device_meta_json = ?, last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([$tunnelMode, $random, $appVersion, $metaJson, $clientIp, $now, $now, $deviceId]); + } else { + $pdo->prepare( + 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, + last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([$tunnelMode, $random, $appVersion, $clientIp, $now, $now, $deviceId]); + } + return; + } + $pdo->prepare( + 'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, opt_in_mode, last_random, + app_version, device_meta_json, last_client_ip, last_seen_at, created_at, updated_at) + VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $deviceId, + Rbac::defaultCompanyId(), + $tunnelMode, + $random, + $appVersion, + $metaJson, + $clientIp !== '' ? $clientIp : null, + $now, + $now, + $now, + ]); + self::logEvent($deviceId, null, null, 'device_seen', 'first_poll', ['opt_in' => $tunnelMode], $clientIp); + } + + /** @param array $deviceMeta */ + private static function encodeDeviceMeta(array $deviceMeta): ?string { + if ($deviceMeta === []) { + return null; + } + $allowed = ['name', 'manufacturer', 'brand', 'model', 'device', 'product', 'sdk_int', 'release', 'abis', 'lan_ip', 'wan_ip']; + $clean = []; + foreach ($allowed as $key) { + if (!array_key_exists($key, $deviceMeta)) { + continue; + } + $val = $deviceMeta[$key]; + if ($key === 'sdk_int') { + $clean[$key] = (int) $val; + } elseif ($key === 'abis' && is_array($val)) { + $clean[$key] = array_values(array_filter(array_map('strval', $val))); + } elseif (is_scalar($val)) { + $s = trim((string) $val); + if ($s !== '') { + $clean[$key] = $s; + } + } + } + if ($clean === []) { + return null; + } + $json = json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return $json === false ? null : $json; + } + + /** @return array|null */ + private static function findDevice(string $deviceId): ?array { + $st = Database::pdo()->prepare('SELECT * FROM remote_access_devices WHERE device_id = ? LIMIT 1'); + $st->execute([$deviceId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + /** @return array|null */ + private static function findConnectableSession(string $deviceId, string $random): ?array { + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT * FROM remote_access_sessions + WHERE device_id = ? AND status IN ('pending','active') + ORDER BY id DESC LIMIT 5" + ); + $st->execute([$deviceId]); + $rows = $st->fetchAll(PDO::FETCH_ASSOC); + if (!is_array($rows)) { + return null; + } + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $exp = (int) ($row['expires_at'] ?? 0); + if ($exp > 0 && $exp < time()) { + self::closeSession((string) $row['session_id'], self::STATUS_CLOSED_TIMEOUT, 'expired'); + continue; + } + return $row; + } + return null; + } + + /** @param array $session */ + private static function buildConnectResponse(array $session, string $deviceId, string $clientIp): array { + $tunnel = (string) ($session['tunnel'] ?? 'wireguard'); + if ($tunnel === 'ssh_reverse') { + return self::buildRsshConnectResponse($session, $deviceId, $clientIp); + } + return self::buildWireGuardConnectResponse($session, $deviceId, $clientIp); + } + + /** @param array $session */ + private static function buildWireGuardConnectResponse(array $session, string $deviceId, string $clientIp): array { + $sessionId = (string) ($session['session_id'] ?? ''); + $tunnel = (string) ($session['tunnel'] ?? 'wireguard'); + if ($tunnel !== 'wireguard') { + return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]); + } + + $keys = []; + try { + $keys = self::ensureSessionWireGuardKeys($sessionId, $session); + } catch (Throwable $e) { + error_log('RemoteAccess connect keys: ' . $e->getMessage()); + return self::raEnvelope('deny', ['reason' => 'key_generation_failed']); + } + if (str_starts_with($keys['client_public'] ?? '', 'dev-placeholder-pub-') + && (bool) cfg('remote_access.require_wg_tools', false)) { + return self::raEnvelope('deny', ['reason' => 'wg_tools_required']); + } + $endpoint = (string) ($session['endpoint'] ?? WireGuardAddressPool::defaultEndpoint()); + $expiresAt = (int) ($session['expires_at'] ?? (time() + 3600)); + + self::markSessionActive($sessionId); + self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'wireguard', null, $clientIp); + + return self::raEnvelope('connect', [ + 'session_id' => $sessionId, + 'endpoint' => $endpoint, + 'tunnel' => 'wireguard', + 'credentials' => [ + 'mode' => 'ephemeral_pubkey', + 'interface_private_key' => $keys['client_private'], + 'interface_address' => $keys['client_address'], + 'peer_public_key' => $keys['server_public'], + 'peer_endpoint' => $endpoint, + 'peer_allowed_ips' => $keys['allowed_ips'], + ], + 'expires_at' => $expiresAt, + ]); + } + + /** @param array $session */ + private static function buildRsshConnectResponse(array $session, string $deviceId, string $clientIp): array { + $sessionId = (string) ($session['session_id'] ?? ''); + $creds = self::ensureSessionRsshCredentials($sessionId, $session); + $expiresAt = (int) ($session['expires_at'] ?? (time() + 3600)); + $endpoint = RsshSessionProvisioner::endpointFromSession($session); + + self::markSessionActive($sessionId); + self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'ssh_reverse', null, $clientIp); + + return self::raEnvelope('connect', [ + 'session_id' => $sessionId, + 'endpoint' => $endpoint, + 'tunnel' => 'ssh_reverse', + 'credentials' => [ + 'mode' => 'password', + 'bastion_host' => $creds['bastion_host'], + 'bastion_port' => $creds['bastion_port'], + 'username' => $creds['username'], + 'password' => $creds['password'], + 'remote_bind_port' => $creds['remote_bind_port'], + 'local_forward_host' => '127.0.0.1', + 'local_forward_port' => $creds['local_forward_port'], + ], + 'expires_at' => $expiresAt, + ]); + } + + /** @param array $session @return array{bastion_host:string,bastion_port:int,username:string,password:string,remote_bind_port:int,local_forward_port:int} */ + private static function ensureSessionRsshCredentials(string $sessionId, array $session): array { + $user = trim((string) ($session['rssh_username'] ?? '')); + $secret = trim((string) ($session['rssh_secret'] ?? '')); + $remote = (int) ($session['rssh_remote_port'] ?? 0); + $local = (int) ($session['rssh_local_port'] ?? 0); + $host = trim((string) ($session['rssh_bastion_host'] ?? '')); + $port = (int) ($session['rssh_bastion_port'] ?? 0); + if ($user !== '' && $secret !== '' && $remote > 0) { + return [ + 'bastion_host' => $host !== '' ? $host : (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'), + 'bastion_port' => $port > 0 ? $port : (int) cfg('remote_access.rssh.bastion_port', 443), + 'username' => $user, + 'password' => $secret, + 'remote_bind_port' => $remote, + 'local_forward_port' => $local > 0 ? $local : (int) cfg('remote_access.rssh.local_forward_port', 8022), + ]; + } + $alloc = RsshSessionProvisioner::allocate($sessionId); + $bastion = RsshBastionProvisioner::addUser($alloc['username'], $alloc['password']); + if (!$bastion['ok']) { + throw new RuntimeException((string) ($bastion['error'] ?? 'bastion_user_add_failed')); + } + Database::pdo()->prepare( + 'UPDATE remote_access_sessions SET rssh_bastion_host = ?, rssh_bastion_port = ?, rssh_username = ?, rssh_secret = ?, rssh_remote_port = ?, rssh_local_port = ? WHERE session_id = ?' + )->execute([ + $alloc['bastion_host'], + $alloc['bastion_port'], + $alloc['username'], + $alloc['password'], + $alloc['remote_bind_port'], + $alloc['local_forward_port'], + $sessionId, + ]); + return $alloc; + } + + /** @param array $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string,client_public:string} */ + private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array { + $priv = trim((string) ($session['wg_client_private_key'] ?? '')); + $pub = trim((string) ($session['wg_client_public_key'] ?? '')); + $addr = trim((string) ($session['wg_client_address'] ?? '')); + $serverPub = trim((string) ($session['wg_server_public_key'] ?? '')); + $allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? '')); + if ($priv !== '' && $addr !== '' && $serverPub !== '') { + if ($pub === '') { + $pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv); + } + if (!WireGuardPeerProvisioner::addClientPeer($pub, $addr)) { + error_log('RemoteAccess: wg peer re-apply failed for session ' . $sessionId); + } + return [ + 'client_private' => $priv, + 'client_public' => $pub, + 'client_address' => $addr, + 'server_public' => $serverPub, + 'allowed_ips' => $allowed !== '' ? $allowed : WireGuardAddressPool::serverAllowedIps(), + ]; + } + $pair = self::generateWireGuardKeyPair(); + $priv = $pair['private']; + $pub = $pair['public']; + if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) { + $pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv); + } + $clientIp = WireGuardAddressPool::allocateClientAddress($sessionId); + $serverPub = (string) cfg('remote_access.wg_server_public_key', ''); + if ($serverPub === '') { + $serverPair = self::generateWireGuardKeyPair(); + $serverPub = $serverPair['public']; + } + $allowed = WireGuardAddressPool::serverAllowedIps(); + Database::pdo()->prepare( + 'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_public_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?' + )->execute([$priv, $pub, $clientIp, $serverPub, $allowed, $sessionId]); + + if (!WireGuardPeerProvisioner::addClientPeer($pub, $clientIp)) { + error_log('RemoteAccess: wg peer add failed for session ' . $sessionId); + } + + return [ + 'client_private' => $priv, + 'client_public' => $pub, + 'client_address' => $clientIp, + 'server_public' => $serverPub, + 'allowed_ips' => $allowed, + ]; + } + + /** @return array{private:string,public:string} */ + public static function generateWireGuardKeyPair(): array { + $priv = trim((string) @shell_exec('wg genkey 2>/dev/null') ?: ''); + if ($priv !== '') { + $pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: ''); + if ($pub !== '') { + return ['private' => $priv, 'public' => $pub]; + } + } + if ((bool) cfg('remote_access.require_wg_tools', false)) { + throw new RuntimeException('wireguard-tools unavailable (wg genkey)'); + } + $raw = random_bytes(32); + $priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)]; + } + + private static function markSessionActive(string $sessionId): void { + $now = self::nowSql(); + Database::pdo()->prepare( + "UPDATE remote_access_sessions SET status = 'active', last_activity_at = ? WHERE session_id = ?" + )->execute([$now, $sessionId]); + } + + public static function closeActiveSessionsForDevice(string $deviceId, string $reason): void { + $st = Database::pdo()->prepare( + "SELECT session_id FROM remote_access_sessions WHERE device_id = ? AND status IN ('pending','active')" + ); + $st->execute([$deviceId]); + foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) { + self::closeSession((string) $sid, self::STATUS_CLOSED, $reason); + } + } + + public static function closeSession(string $sessionId, string $status, string $reason): void { + $st = Database::pdo()->prepare( + 'SELECT tunnel, wg_client_public_key, rssh_username FROM remote_access_sessions WHERE session_id = ? LIMIT 1' + ); + $st->execute([$sessionId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if (is_array($row)) { + if (($row['tunnel'] ?? 'wireguard') === 'wireguard') { + $pub = $row['wg_client_public_key'] ?? ''; + if (is_string($pub) && $pub !== '') { + WireGuardPeerProvisioner::removeClientPeer($pub); + } + } elseif (($row['tunnel'] ?? '') === 'ssh_reverse') { + RsshBastionProvisioner::removeUser((string) ($row['rssh_username'] ?? '')); + } + } + $now = self::nowSql(); + Database::pdo()->prepare( + 'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?' + )->execute([$status, $reason, $now, $now, $sessionId]); + } + + /** @return array{ok:bool,session_id?:string} */ + public static function openSession(string $deviceId, int $operatorUserId, ?int $companyId = null): array { + self::ensureSchema(); + $device = self::findDevice($deviceId); + if (!$device || !(int) ($device['whitelisted'] ?? 0)) { + return ['ok' => false, 'error' => 'device_not_whitelisted']; + } + $deviceCompanyId = (int) ($device['company_id'] ?? 0); + if (!Rbac::canAccessCompany($deviceCompanyId) || !Rbac::can('remote_access_operate', $deviceCompanyId)) { + return ['ok' => false, 'error' => 'forbidden']; + } + if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) === self::OPT_NONE) { + return ['ok' => false, 'error' => 'device_not_opted_in']; + } + $optIn = (string) ($device['opt_in_mode'] ?? self::OPT_NONE); + if (!in_array($optIn, [self::OPT_WIREGUARD, self::OPT_RSSH], true)) { + return ['ok' => false, 'error' => 'device_not_opted_in']; + } + self::closeActiveSessionsForDevice($deviceId, 'superseded'); + $sessionId = self::uuid4(); + $companyId = $companyId ?? (int) ($device['company_id'] ?? Rbac::defaultCompanyId()); + $tunnel = $optIn === self::OPT_RSSH ? 'ssh_reverse' : 'wireguard'; + if ($tunnel === 'ssh_reverse') { + $host = (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'); + $port = (int) cfg('remote_access.rssh.bastion_port', 443); + $endpoint = $port === 443 ? $host : $host . ':' . $port; + } else { + $endpoint = WireGuardAddressPool::defaultEndpoint(); + } + $expiresAt = time() + (int) cfg('remote_access.session_ttl_s', 3600); + $now = self::nowSql(); + Database::pdo()->prepare( + 'INSERT INTO remote_access_sessions (session_id, device_id, company_id, operator_user_id, status, tunnel, endpoint, expires_at, last_activity_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $sessionId, + $deviceId, + $companyId, + $operatorUserId, + self::STATUS_PENDING, + $tunnel, + $endpoint, + $expiresAt, + $now, + $now, + ]); + self::logEvent($deviceId, $sessionId, $operatorUserId, 'session_open', 'operator_requested', null, ''); + return ['ok' => true, 'session_id' => $sessionId]; + } + + /** @return list> */ + public static function listDevices(?int $companyId = null): array { + self::ensureSchema(); + $sql = 'SELECT * FROM remote_access_devices'; + $params = []; + if (!Rbac::isGlobalAdmin()) { + $allowed = Rbac::allowedCompanyIds(); + if ($allowed === []) { + return []; + } + if ($allowed !== null) { + $placeholders = implode(',', array_fill(0, count($allowed), '?')); + $sql .= ' WHERE company_id IN (' . $placeholders . ')'; + $params = $allowed; + } + } elseif ($companyId !== null) { + $sql .= ' WHERE company_id = ?'; + $params[] = $companyId; + } + $sql .= Database::isMysql() + ? ' ORDER BY (last_seen_at IS NULL), last_seen_at DESC, device_id ASC' + : ' ORDER BY datetime(COALESCE(last_seen_at, \'1970-01-01\')) DESC, device_id ASC'; + $st = Database::pdo()->prepare($sql); + $st->execute($params); + return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** @return list> */ + public static function listSessions(string $statusFilter = ''): array { + self::ensureSchema(); + $sql = 'SELECT s.*, d.opt_in_mode, d.app_version AS device_app_version FROM remote_access_sessions s + LEFT JOIN remote_access_devices d ON d.device_id = s.device_id'; + $where = []; + $params = []; + if ($statusFilter !== '') { + $where[] = 's.status = ?'; + $params[] = $statusFilter; + } + self::appendCompanyScopeSql('s.company_id', $where, $params); + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= ' ORDER BY s.id DESC LIMIT 200'; + $st = Database::pdo()->prepare($sql); + $st->execute($params); + return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** @return list> */ + public static function listEvents(int $limit = 100): array { + self::ensureSchema(); + $sql = 'SELECT e.* FROM remote_access_events e'; + $where = []; + $params = []; + if (!Rbac::isGlobalAdmin()) { + $allowed = Rbac::allowedCompanyIds(); + if ($allowed === []) { + return []; + } + if ($allowed !== null) { + $sql .= ' INNER JOIN remote_access_devices d ON d.device_id = e.device_id'; + self::appendCompanyScopeSql('d.company_id', $where, $params); + } + } + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= ' ORDER BY e.id DESC LIMIT ?'; + $params[] = max(1, min(500, $limit)); + $st = Database::pdo()->prepare($sql); + foreach ($params as $i => $v) { + $st->bindValue($i + 1, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $st->execute(); + return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + public static function setDeviceWhitelist(string $deviceId, bool $whitelisted, ?string $notes = null): bool { + self::ensureSchema(); + $device = self::findDevice($deviceId); + if (!$device) { + $companyId = Rbac::activeCompanyId() ?? Rbac::defaultCompanyId(); + if (!Rbac::can('remote_access_admin', $companyId)) { + return false; + } + Database::pdo()->prepare( + 'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + )->execute([ + $deviceId, + $companyId, + $whitelisted ? 1 : 0, + $notes, + self::nowSql(), + self::nowSql(), + ]); + return true; + } + $companyId = (int) ($device['company_id'] ?? 0); + if (!Rbac::canAccessCompany($companyId) || !Rbac::can('remote_access_admin', $companyId)) { + return false; + } + Database::pdo()->prepare( + 'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?' + )->execute([$whitelisted ? 1 : 0, $notes, self::nowSql(), $deviceId]); + return true; + } + + /** @return list */ + public static function activeWireGuardSessionPublicKeys(): array { + $peers = self::activeWireGuardSessionPeers(); + $keys = []; + foreach ($peers as $row) { + $pub = trim((string) ($row['public_key'] ?? '')); + if ($pub !== '') { + $keys[] = $pub; + } + } + return $keys; + } + + /** + * Pending/active WG sessions with client pubkey + tunnel address (for wg set reconcile). + * + * @return list + */ + public static function activeWireGuardSessionPeers(): array { + self::ensureSchema(); + $st = Database::pdo()->query( + "SELECT session_id, wg_client_public_key, wg_client_address FROM remote_access_sessions + WHERE status IN ('pending','active') AND tunnel = 'wireguard' + AND wg_client_public_key IS NOT NULL AND wg_client_public_key != '' + AND wg_client_address IS NOT NULL AND wg_client_address != ''" + ); + if ($st === false) { + return []; + } + $rows = []; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (!is_array($row)) { + continue; + } + $pub = trim((string) ($row['wg_client_public_key'] ?? '')); + $addr = trim((string) ($row['wg_client_address'] ?? '')); + if ($pub === '' || $addr === '') { + continue; + } + $rows[] = [ + 'session_id' => (string) ($row['session_id'] ?? ''), + 'public_key' => $pub, + 'client_address' => $addr, + ]; + } + return $rows; + } + + /** + * Ensure wg0 has allowed-ips for every pending/active WG session (idempotent). + * + * @return list + */ + public static function reconcileActiveWireGuardPeers(bool $dryRun = false): array { + $results = []; + foreach (self::activeWireGuardSessionPeers() as $row) { + $ok = true; + if (!$dryRun) { + $ok = WireGuardPeerProvisioner::addClientPeer( + $row['public_key'], + $row['client_address'] + ); + } + $results[] = [ + 'session_id' => $row['session_id'], + 'public_key' => $row['public_key'], + 'client_address' => $row['client_address'], + 'ok' => $ok, + ]; + } + return $results; + } + + /** + * Drop wg0 peers that are not referenced by pending/active sessions. + * + * @return list + */ + public static function pruneOrphanWireGuardPeers(bool $dryRun = false): array { + return WireGuardPeerProvisioner::pruneOrphanPeers( + self::activeWireGuardSessionPublicKeys(), + $dryRun + ); + } + + public static function purgeStale(int $maxAgeHours = 24): int { + self::ensureSchema(); + $cutoff = time() - max(1, $maxAgeHours) * 3600; + $pdo = Database::pdo(); + if (Database::isMysql()) { + $st = $pdo->prepare( + "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active') + AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND UNIX_TIMESTAMP(last_activity_at) < ?))" + ); + $st->execute([time(), $cutoff]); + } else { + $cutoffIso = gmdate('Y-m-d H:i:s', $cutoff); + $st = $pdo->prepare( + "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active') + AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND last_activity_at < ?))" + ); + $st->execute([time(), $cutoffIso]); + } + $n = 0; + foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) { + self::closeSession((string) $sid, self::STATUS_CLOSED_TIMEOUT, 'purge_stale'); + $n++; + } + return $n; + } + + public static function logEvent( + string $deviceId, + ?string $sessionId, + ?int $actorUserId, + string $action, + ?string $reason, + ?array $meta, + string $clientIp + ): void { + self::ensureSchema(); + Database::pdo()->prepare( + 'INSERT INTO remote_access_events (session_id, device_id, actor_user_id, action, reason, meta_json, client_ip, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $sessionId, + $deviceId, + $actorUserId, + $action, + $reason, + $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null, + $clientIp !== '' ? $clientIp : null, + self::nowSql(), + ]); + } + + /** @param array $extra */ + private static function raEnvelope(string $action, array $extra = []): array { + return array_merge([ + 'type' => 'ra', + 'action' => $action, + 'server_date' => time(), + ], $extra); + } + + private static function nowSql(): string { + return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s'); + } + + /** @return array{devices: list>, active_sessions: list>, inactive_sessions: list>, recent_events: list>} */ + public static function buildDashboardPayload(string $issuesBase, string $projectBase): array { + self::ensureSchema(); + $sessions = self::listSessions(''); + $active = array_values(array_filter( + $sessions, + static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true) + )); + $inactive = array_values(array_filter( + $sessions, + static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true) + )); + + return [ + 'devices' => self::listDevicesForDashboard($issuesBase, $projectBase), + 'active_sessions' => array_map([self::class, 'enrichSessionRow'], $active), + 'inactive_sessions' => array_map([self::class, 'enrichSessionRow'], array_slice($inactive, 0, 50)), + 'recent_events' => self::listEvents(30), + ]; + } + + /** @param array $row @return array */ + private static function enrichSessionRow(array $row): array { + if (($row['tunnel'] ?? '') === 'ssh_reverse') { + $cmds = RsshBastionProvisioner::operatorCommands($row); + if ($cmds !== []) { + $row['rssh_operator'] = $cmds; + } + $remote = (int) ($row['rssh_remote_port'] ?? 0); + if ($remote > 0) { + $row['rssh_remote_bind'] = '127.0.0.1:' . $remote; + } + } + return $row; + } + + /** @return list> */ + public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array { + $devices = self::listDevices(); + if ($devices === []) { + return []; + } + $deviceIds = array_values(array_filter(array_map( + static fn($row) => (string) ($row['device_id'] ?? ''), + $devices + ))); + $sessionRows = self::batchActiveSessionRows($deviceIds); + $wgPeers = WireGuardTrafficStats::peerMap(); + $out = []; + foreach ($devices as $row) { + $deviceId = (string) ($row['device_id'] ?? ''); + $out[] = self::enrichDeviceRow( + $row, + $issuesBase, + $projectBase, + $sessionRows[$deviceId] ?? null, + $wgPeers + ); + } + return $out; + } + + /** @param list $deviceIds @return array> */ + private static function batchActiveSessionRows(array $deviceIds): array { + $deviceIds = array_values(array_unique(array_filter(array_map('strval', $deviceIds)))); + if ($deviceIds === []) { + return []; + } + $placeholders = implode(',', array_fill(0, count($deviceIds), '?')); + $st = Database::pdo()->prepare( + "SELECT device_id, tunnel, wg_client_address, wg_client_public_key + FROM remote_access_sessions + WHERE status IN ('pending', 'active') AND device_id IN ($placeholders) + ORDER BY id DESC" + ); + $st->execute($deviceIds); + $map = []; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + $did = (string) ($row['device_id'] ?? ''); + if ($did !== '' && !isset($map[$did])) { + $map[$did] = $row; + } + } + return $map; + } + + /** @param array $row @param array|null $sessionRow @param array|null $wgPeers */ + private static function enrichDeviceRow( + array $row, + string $issuesBase, + string $projectBase, + ?array $sessionRow = null, + ?array $wgPeers = null + ): array { + $deviceId = (string) ($row['device_id'] ?? ''); + $companyId = (int) ($row['company_id'] ?? 1); + $storedMeta = self::decodeDeviceMeta($row['device_meta_json'] ?? null); + $ctx = self::lookupDeviceContextFromMeta($deviceId, $companyId, $storedMeta); + $row['needs_whitelist'] = self::deviceNeedsWhitelist($row); + $row['status_label'] = self::deviceStatusLabel($row); + $row['device_name'] = $ctx['device_name'] ?? ''; + $row['device_display'] = $ctx['device_display'] ?? ''; + $row['manufacturer'] = $ctx['manufacturer'] ?? ''; + $row['brand'] = $ctx['brand'] ?? ''; + $row['model'] = $ctx['model'] ?? ''; + $row['product'] = $ctx['product'] ?? ''; + $row['hardware_device'] = $ctx['hardware_device'] ?? ''; + $row['os_release'] = $ctx['os_release'] ?? ''; + $row['sdk_int'] = $ctx['sdk_int'] ?? null; + $row['abis'] = $ctx['abis'] ?? []; + $row['lan_ip'] = self::resolveDeviceLanIp($storedMeta); + $row['poll_source_ip'] = self::resolvePollSourceIp($row); + $row['poll_source_is_private'] = self::isPrivateIp($row['poll_source_ip']); + $row['device_wan_ip'] = trim((string) ($storedMeta['wan_ip'] ?? '')); + $row['vpn_route_scope'] = trim((string) ($storedMeta['vpn_route_scope'] ?? '')); + $row['vpn_app_scope'] = trim((string) ($storedMeta['vpn_app_scope'] ?? '')); + $net = self::sessionNetworkFromRow($sessionRow, $wgPeers); + $row['vpn_ip'] = $net['vpn_ip'] ?? ''; + $row['vpn_public_key'] = $net['vpn_public_key'] ?? ''; + $row['wg_rx_bytes'] = $net['wg_rx_bytes'] ?? 0; + $row['wg_tx_bytes'] = $net['wg_tx_bytes'] ?? 0; + $row['wg_latest_handshake'] = $net['wg_latest_handshake'] ?? 0; + $row['wg_endpoint'] = $net['wg_endpoint'] ?? ''; + $row['issue_count'] = $ctx['issue_count'] ?? 0; + $row['graph_session_count'] = $ctx['graph_session_count'] ?? 0; + $row['latest_issue_id'] = $ctx['latest_issue_id'] ?? null; + $row['links'] = self::deviceLinks($deviceId, $issuesBase, $projectBase, $ctx); + if (($row['app_version'] ?? '') === '' && ($ctx['app_version'] ?? '') !== '') { + $row['app_version'] = $ctx['app_version']; + } + return $row; + } + + /** @return array */ + private static function decodeDeviceMeta(mixed $raw): array { + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** @param array $row */ + private static function deviceNeedsWhitelist(array $row): bool { + if ((int) ($row['whitelisted'] ?? 0) === 1) { + return false; + } + $mode = (string) ($row['opt_in_mode'] ?? 'none'); + if ($mode === self::OPT_NONE) { + return false; + } + return self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400); + } + + /** @param array $row */ + private static function deviceStatusLabel(array $row): string { + if ((int) ($row['whitelisted'] ?? 0) === 1) { + return 'whitelisted'; + } + if (self::deviceNeedsWhitelist($row)) { + return 'needs_whitelist'; + } + $mode = (string) ($row['opt_in_mode'] ?? 'none'); + if ($mode === self::OPT_NONE) { + return 'not_opted_in'; + } + if (!self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400)) { + return 'stale'; + } + return 'polling'; + } + + private static function isRecentTimestamp(?string $ts, int $maxAgeSec): bool { + if ($ts === null || trim($ts) === '') { + return false; + } + $t = strtotime($ts); + if ($t === false) { + return false; + } + return (time() - $t) <= $maxAgeSec; + } + + /** @param array $row */ + private static function resolvePollSourceIp(array $row): string { + $fromRow = trim((string) ($row['last_client_ip'] ?? '')); + if ($fromRow !== '') { + return self::normalizeClientIp($fromRow); + } + $deviceId = (string) ($row['device_id'] ?? ''); + if ($deviceId === '') { + return ''; + } + $st = Database::pdo()->prepare( + "SELECT client_ip FROM remote_access_events + WHERE device_id = ? AND client_ip IS NOT NULL AND client_ip != '' + ORDER BY id DESC LIMIT 1" + ); + $st->execute([$deviceId]); + $ip = trim((string) ($st->fetchColumn() ?: '')); + + return self::normalizeClientIp($ip); + } + + private static function isPrivateIp(string $ip): bool { + if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return true; + } + + return false; + } + + /** @param array $storedMeta */ + private static function resolveDeviceLanIp(array $storedMeta): string { + return trim((string) ($storedMeta['lan_ip'] ?? '')); + } + + private static function normalizeClientIp(string $raw): string { + $raw = trim($raw); + if ($raw === '') { + return ''; + } + if (str_contains($raw, ',')) { + $raw = trim(explode(',', $raw)[0]); + } + + return $raw; + } + + /** @param array|null $sessionRow @param array|null $wgPeers @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */ + private static function sessionNetworkFromRow(?array $sessionRow, ?array $wgPeers): array { + $empty = [ + 'vpn_ip' => '', + 'vpn_public_key' => '', + 'wg_rx_bytes' => 0, + 'wg_tx_bytes' => 0, + 'wg_latest_handshake' => 0, + 'wg_endpoint' => '', + ]; + if (!is_array($sessionRow)) { + return $empty; + } + $tunnel = (string) ($sessionRow['tunnel'] ?? ''); + if ($tunnel !== '' && $tunnel !== 'wireguard') { + return $empty; + } + $addr = trim((string) ($sessionRow['wg_client_address'] ?? '')); + $vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : ''; + $pub = trim((string) ($sessionRow['wg_client_public_key'] ?? '')); + $stats = ($wgPeers !== null && $pub !== '') ? ($wgPeers[$pub] ?? null) : null; + if ($stats === null && $pub !== '') { + $stats = WireGuardTrafficStats::forPublicKey($pub); + } + + return [ + 'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '', + 'vpn_public_key' => $pub, + 'wg_rx_bytes' => (int) ($stats['rx_bytes'] ?? 0), + 'wg_tx_bytes' => (int) ($stats['tx_bytes'] ?? 0), + 'wg_latest_handshake' => (int) ($stats['latest_handshake'] ?? 0), + 'wg_endpoint' => (string) ($stats['endpoint'] ?? ''), + ]; + } + + /** Fast dashboard enrichment from heartbeat meta (no reports/graph table scans). */ + private static function lookupDeviceContextFromMeta( + string $deviceId, + int $companyId, + array $storedMeta = [] + ): array { + if ($deviceId === '') { + return []; + } + unset($companyId); + $deviceName = trim((string) ($storedMeta['name'] ?? '')); + $manufacturer = trim((string) ($storedMeta['manufacturer'] ?? '')); + $brand = trim((string) ($storedMeta['brand'] ?? '')); + $model = trim((string) ($storedMeta['model'] ?? '')); + $hardwareDevice = trim((string) ($storedMeta['device'] ?? '')); + $product = trim((string) ($storedMeta['product'] ?? '')); + $osRelease = trim((string) ($storedMeta['release'] ?? '')); + $sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null; + $abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : []; + $appVersion = trim((string) ($storedMeta['app_version'] ?? '')); + $deviceDisplay = $deviceName !== '' + ? $deviceName + : trim($manufacturer . ' ' . $model); + + return [ + 'issue_count' => 0, + 'latest_issue_id' => null, + 'graph_session_count' => 0, + 'device_name' => $deviceName, + 'device_display' => $deviceDisplay, + 'manufacturer' => $manufacturer, + 'brand' => $brand, + 'model' => $model, + 'hardware_device' => $hardwareDevice, + 'product' => $product, + 'os_release' => $osRelease, + 'sdk_int' => $sdkInt, + 'abis' => $abis, + 'app_version' => $appVersion, + ]; + } + + /** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */ + private static function lookupDeviceSessionNetwork(string $deviceId): array { + if ($deviceId === '') { + return [ + 'vpn_ip' => '', + 'vpn_public_key' => '', + 'wg_rx_bytes' => 0, + 'wg_tx_bytes' => 0, + 'wg_latest_handshake' => 0, + 'wg_endpoint' => '', + ]; + } + $st = Database::pdo()->prepare( + "SELECT tunnel, wg_client_address, wg_client_public_key + FROM remote_access_sessions + WHERE device_id = ? AND status IN ('pending', 'active') + ORDER BY id DESC LIMIT 1" + ); + $st->execute([$deviceId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + + return self::sessionNetworkFromRow(is_array($row) ? $row : null, null); + } + + /** @param array $storedMeta @return array */ + private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array { + if ($deviceId === '') { + return []; + } + $pdo = Database::pdo(); + $deviceName = trim((string) ($storedMeta['name'] ?? '')); + $manufacturer = trim((string) ($storedMeta['manufacturer'] ?? '')); + $brand = trim((string) ($storedMeta['brand'] ?? '')); + $model = trim((string) ($storedMeta['model'] ?? '')); + $hardwareDevice = trim((string) ($storedMeta['device'] ?? '')); + $product = trim((string) ($storedMeta['product'] ?? '')); + $osRelease = trim((string) ($storedMeta['release'] ?? '')); + $sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null; + $abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : []; + $appVersion = ''; + + $issueCount = 0; + $latestIssueId = null; + $graphCount = 0; + + if (Database::tableExists($pdo, 'graph_sessions')) { + $gSt = $pdo->prepare( + 'SELECT app_version, sdk_int FROM graph_sessions WHERE device_id = ? ORDER BY started_at_ms DESC LIMIT 1' + ); + $gSt->execute([$deviceId]); + $gRow = $gSt->fetch(PDO::FETCH_ASSOC); + if (is_array($gRow)) { + if ($appVersion === '') { + $appVersion = trim((string) ($gRow['app_version'] ?? '')); + } + if ($sdkInt === null && isset($gRow['sdk_int'])) { + $sdkInt = (int) $gRow['sdk_int']; + } + } + $cntSt = $pdo->prepare('SELECT COUNT(*) FROM graph_sessions WHERE device_id = ?'); + $cntSt->execute([$deviceId]); + $graphCount = (int) $cntSt->fetchColumn(); + } + + if (Database::tableExists($pdo, 'reports')) { + $reportsDeviceId = self::resolveDeviceDbId($deviceId, $companyId); + if ($reportsDeviceId !== null) { + $cntSt = $pdo->prepare( + 'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.device_id = ?' + ); + $cntSt->execute([$companyId, $reportsDeviceId]); + $issueCount = (int) $cntSt->fetchColumn(); + + $latestSt = $pdo->prepare( + 'SELECT r.id, r.device_model, r.app_version, r.payload_json + FROM reports r + WHERE r.company_id = ? AND r.device_id = ? + ORDER BY r.received_at_ms DESC LIMIT 1' + ); + $latestSt->execute([$companyId, $reportsDeviceId]); + $latest = $latestSt->fetch(PDO::FETCH_ASSOC); + } else { + // Avoid payload_json LIKE scans (full table) on unregistered devices. + $issueCount = 0; + $latest = null; + } + if (is_array($latest)) { + $latestIssueId = (int) ($latest['id'] ?? 0) ?: null; + if ($appVersion === '') { + $appVersion = trim((string) ($latest['app_version'] ?? '')); + } + $payload = json_decode((string) ($latest['payload_json'] ?? '{}'), true); + if (is_array($payload)) { + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + if ($manufacturer === '') { + $manufacturer = trim((string) ($device['manufacturer'] ?? '')); + } + if ($brand === '') { + $brand = trim((string) ($device['brand'] ?? '')); + } + if ($model === '') { + $model = trim((string) ($device['model'] ?? $latest['device_model'] ?? '')); + } + if ($hardwareDevice === '') { + $hardwareDevice = trim((string) ($device['device'] ?? '')); + } + if ($product === '') { + $product = trim((string) ($device['product'] ?? '')); + } + if ($osRelease === '') { + $osRelease = trim((string) ($device['release'] ?? '')); + } + if ($sdkInt === null && isset($device['sdk_int'])) { + $sdkInt = (int) $device['sdk_int']; + } + if ($abis === [] && is_array($device['abis'] ?? null)) { + $abis = $device['abis']; + } + if ($appVersion === '' && is_array($payload['app'] ?? null)) { + $appVersion = trim((string) ($payload['app']['version_name'] ?? '')); + } + } + } + } + + $deviceDisplay = $deviceName !== '' + ? $deviceName + : trim($manufacturer . ' ' . $model); + + return [ + 'issue_count' => $issueCount, + 'latest_issue_id' => $latestIssueId, + 'graph_session_count' => $graphCount, + 'device_name' => $deviceName, + 'device_display' => $deviceDisplay, + 'manufacturer' => $manufacturer, + 'brand' => $brand, + 'model' => $model, + 'hardware_device' => $hardwareDevice, + 'product' => $product, + 'os_release' => $osRelease, + 'sdk_int' => $sdkInt, + 'abis' => $abis, + 'app_version' => $appVersion, + ]; + } + + private static function resolveDeviceDbId(string $deviceId, int $companyId): ?int { + static $cache = []; + $key = $companyId . ':' . $deviceId; + if (array_key_exists($key, $cache)) { + return $cache[$key]; + } + $pdo = Database::pdo(); + if (!Database::tableExists($pdo, 'devices')) { + $cache[$key] = null; + + return null; + } + $st = $pdo->prepare( + 'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1' + ); + $st->execute([$companyId, $deviceId]); + $id = $st->fetchColumn(); + $cache[$key] = ($id !== false && $id !== null && $id !== '') ? (int) $id : null; + + return $cache[$key]; + } + + /** @param array $ctx @return list */ + private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array { + if ($deviceId === '') { + return []; + } + $links = []; + $issueCount = (int) ($ctx['issue_count'] ?? 0); + $issuesLabel = $issueCount > 0 ? 'Issues (' . $issueCount . ')' : 'Issues'; + $links[] = [ + 'label' => $issuesLabel, + 'href' => $issuesBase . '/?view=reports&q=' . rawurlencode($deviceId), + ]; + if (!empty($ctx['latest_issue_id'])) { + $links[] = [ + 'label' => 'Latest issue #' . (int) $ctx['latest_issue_id'], + 'href' => $issuesBase . '/?view=report&id=' . (int) $ctx['latest_issue_id'], + ]; + } + $graphCount = (int) ($ctx['graph_session_count'] ?? 0); + if ($graphCount > 0) { + $links[] = [ + 'label' => 'Graph sessions (' . $graphCount . ')', + 'href' => rtrim($projectBase, '/') . '/graphs/', + ]; + } + return $links; + } + + private static function uuid4(): string { + $b = random_bytes(16); + $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); + $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); + $h = bin2hex($b); + return substr($h, 0, 8) . '-' . substr($h, 8, 4) . '-' . substr($h, 12, 4) + . '-' . substr($h, 16, 4) . '-' . substr($h, 20, 12); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/ReportRepository.php b/sim/cluster0/lab-seeds/backend/src/ReportRepository.php new file mode 100644 index 0000000..dc5e9ec --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/ReportRepository.php @@ -0,0 +1,707 @@ +prepare( + 'INSERT INTO reports (company_id, device_id, report_id, fingerprint, crash_type, generated_at_ms, + received_at_ms, device_model, app_version, payload_json, tags_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $companyId, + $deviceId > 0 ? $deviceId : null, + $payload['report_id'] ?? uniqid('rpt_', true), + $payload['fingerprint'] ?? 'unknown', + $payload['crash_type'] ?? 'java', + (int) ($payload['generated_at_epoch_ms'] ?? 0), + (int) round(microtime(true) * 1000), + $device['model'] ?? null, + $app['version_name'] ?? null, + $json, + $tags ?: '[]', + ]); + } + + public static function listReports(bool $grouped): array { + return self::listPage($grouped, 1, 500, 'generated_at_ms', 'desc')['items']; + } + + public static function listPage( + bool $grouped, + int $page, + int $perPage, + string $sort, + string $dir, + int $sinceMs = 0, + array $tagFilters = [], + string $tagMode = 'and' + ): array { + if ($tagFilters !== []) { + return self::listFiltered( + ['tags' => $tagFilters, 'tag_mode' => $tagMode], + $page, + $perPage, + $sort, + $dir, + $sinceMs + ); + } + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC'; + $offset = ($page - 1) * $perPage; + $userId = (int) (Auth::user()['id'] ?? 0); + + if ($grouped) { + $sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt'; + $gWhere = ['1=1']; + $gParams = []; + self::appendCompanyScope('company_id', $gWhere, $gParams); + $gWhereSql = implode(' AND ', $gWhere); + $countStmt = $pdo->prepare( + "SELECT COUNT(*) FROM ( + SELECT 1 FROM reports WHERE $gWhereSql GROUP BY fingerprint, crash_type + ) t" + ); + $countStmt->execute($gParams); + $total = (int) $countStmt->fetchColumn(); + $sql = "SELECT fingerprint, crash_type, + MIN(device_model) AS device_model, + MAX(app_version) AS app_version, + COUNT(*) AS cnt, + MAX(generated_at_ms) AS last_generated_ms, + MAX(received_at_ms) AS last_received_ms + FROM reports + WHERE $gWhereSql + GROUP BY fingerprint, crash_type + ORDER BY $sortCol $dir + LIMIT $perPage OFFSET $offset"; + $stmt = $pdo->prepare($sql); + $stmt->execute($gParams); + $items = $stmt->fetchAll(); + foreach ($items as &$row) { + $row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0); + $row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs + ? (int) $row['cnt'] : 0; + $row['rating'] = report_rating_score((int) $row['cnt'], (int) $row['fingerprint_recent_cnt'], (int) $row['cnt']); + $row['os_name'] = ''; + $row['os_version'] = ''; + $row['brief'] = report_brief_lines($row, true); + } + unset($row); + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sortCol, + 'dir' => strtolower($dir), + 'grouped' => true, + ]; + } + + $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority'; + if ($sortCol === 'rating') { + $sortCol = 'fingerprint_cnt'; + } + $orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir); + $where = ['1=1']; + $scopeParams = []; + self::appendCompanyScope('r.company_id', $where, $scopeParams); + $whereSql = implode(' AND ', $where); + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql"); + $countStmt->execute($scopeParams); + $total = (int) $countStmt->fetchColumn(); + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY ' . $orderBy . ' + LIMIT ' . $perPage . ' OFFSET ' . $offset; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $scopeParams)); + $items = $stmt->fetchAll(); + self::finishListRows($items, $sinceMs, false); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sort === 'priority' ? 'priority' : $sortCol, + 'dir' => strtolower($dir), + 'grouped' => false, + ]; + } + + /** + * @param array{similar_to?:int,device?:string,abi?:string,os_sdk?:int,os_release?:string} $filters + */ + public static function listFiltered( + array $filters, + int $page, + int $perPage, + string $sort, + string $dir, + int $sinceMs = 0 + ): array { + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC'; + $userId = (int) (Auth::user()['id'] ?? 0); + $where = ['1=1']; + $params = []; + self::appendCompanyScope('r.company_id', $where, $params); + $device = trim((string) ($filters['device'] ?? '')); + if ($device !== '') { + $like = '%' . $device . '%'; + $where[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)'; + $params[] = $like; + $params[] = $like; + } + $abi = trim((string) ($filters['abi'] ?? '')); + if ($abi !== '') { + $where[] = 'r.payload_json LIKE ?'; + $params[] = '%' . $abi . '%'; + } + $sdk = (int) ($filters['os_sdk'] ?? 0); + if ($sdk > 0) { + $where[] = 'r.payload_json LIKE ?'; + $params[] = '%"sdk_int":' . $sdk . '%'; + } + $release = trim((string) ($filters['os_release'] ?? '')); + if ($release !== '') { + $where[] = '(r.payload_json LIKE ? OR r.payload_json LIKE ?)'; + $params[] = '%"release":"' . $release . '"%'; + $params[] = '%"release": "' . $release . '"%'; + } + $appVersion = trim((string) ($filters['app_version'] ?? '')); + if ($appVersion !== '') { + $like = '%' . $appVersion . '%'; + $where[] = '(r.app_version = ? OR r.app_version LIKE ? OR r.payload_json LIKE ?)'; + $params[] = $appVersion; + $params[] = $like; + $params[] = '%"version_name":"' . $appVersion . '"%'; + } + $build = trim((string) ($filters['build'] ?? '')); + if ($build !== '') { + $where[] = 'r.payload_json LIKE ?'; + $params[] = '%' . $build . '%'; + } + self::appendTagFilters($where, $params, $filters); + $whereSql = implode(' AND ', $where); + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql"); + $countStmt->execute($params); + $total = (int) $countStmt->fetchColumn(); + $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority'; + if ($sortCol === 'rating') { + $sortCol = 'fingerprint_cnt'; + } + $orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir); + $offset = ($page - 1) * $perPage; + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY ' . $orderBy . ' + LIMIT ' . $perPage . ' OFFSET ' . $offset; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); + $items = $stmt->fetchAll(); + self::finishListRows($items, $sinceMs, false); + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sort === 'priority' ? 'priority' : $sortCol, + 'dir' => strtolower($dir), + 'grouped' => false, + 'filter' => $filters, + ]; + } + + public static function listSimilar( + int $anchorId, + int $page, + int $perPage, + int $sinceMs = 0 + ): array { + $anchor = self::getById($anchorId); + if ($anchor === null) { + return [ + 'items' => [], + 'total' => 0, + 'page' => 1, + 'per_page' => $perPage, + 'sort' => 'similarity', + 'dir' => 'desc', + 'grouped' => false, + 'similar_to' => $anchorId, + ]; + } + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $userId = (int) (Auth::user()['id'] ?? 0); + $payload = is_array($anchor['payload'] ?? null) ? $anchor['payload'] : []; + $keys = report_similarity_keys($payload); + $fp = $keys['fingerprint']; + $ct = $keys['crash_type']; + $familyLike = $keys['device_family'] !== '' ? '%' . $keys['device_family'] . '%' : null; + $excLike = $keys['exception'] !== '' ? '%' . $keys['exception'] . '%' : null; + $sigLike = $keys['signal'] !== '' ? '%' . $keys['signal'] . '%' : null; + + $where = ['r.id != ?']; + $params = [$anchorId]; + self::appendCompanyScope('r.company_id', $where, $params); + $or = []; + if ($fp !== '') { + $or[] = 'r.fingerprint = ?'; + $params[] = $fp; + } + if ($ct !== '') { + $or[] = 'r.crash_type = ?'; + $params[] = $ct; + } + if ($familyLike !== null) { + $or[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)'; + $params[] = $familyLike; + $params[] = $familyLike; + } + if ($excLike !== null) { + $or[] = 'r.payload_json LIKE ?'; + $params[] = $excLike; + } + if ($sigLike !== null) { + $or[] = 'r.payload_json LIKE ?'; + $params[] = $sigLike; + } + if ($or === []) { + $or[] = '1=1'; + } + $where[] = '(' . implode(' OR ', $or) . ')'; + $whereSql = implode(' AND ', $where); + + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY r.received_at_ms DESC + LIMIT 1200'; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); + $rows = $stmt->fetchAll(); + $scored = []; + foreach ($rows as $row) { + $p = json_decode($row['payload_json'] ?? '', true); + if (!is_array($p)) { + $p = []; + } + $score = report_similarity_score($keys, $row, $p); + if ($score <= 0) { + continue; + } + $row['similarity_score'] = $score; + $row['similarity_tier'] = $score >= 1000 ? 'exact' : ($score >= 200 ? 'strong' : ($score >= 80 ? 'likely' : 'guess')); + $scored[] = $row; + } + usort($scored, static function (array $a, array $b): int { + $sa = (int) ($a['similarity_score'] ?? 0); + $sb = (int) ($b['similarity_score'] ?? 0); + if ($sa !== $sb) { + return $sb <=> $sa; + } + return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0)); + }); + $total = count($scored); + $offset = ($page - 1) * $perPage; + $slice = array_values(array_slice($scored, $offset, $perPage)); + if ($page === 1) { + $anchorRow = null; + foreach ($scored as $row) { + if ((int) $row['id'] === $anchorId) { + $anchorRow = $row; + break; + } + } + if ($anchorRow === null) { + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + $app = is_array($payload['app'] ?? null) ? $payload['app'] : []; + $anchorRow = [ + 'id' => $anchorId, + 'report_id' => $anchor['report_id'] ?? '', + 'fingerprint' => $anchor['fingerprint'] ?? ($payload['fingerprint'] ?? ''), + 'crash_type' => $anchor['crash_type'] ?? ($payload['crash_type'] ?? ''), + 'generated_at_ms' => (int) ($anchor['generated_at_ms'] ?? 0), + 'received_at_ms' => (int) ($anchor['received_at_ms'] ?? 0), + 'device_model' => $anchor['device_model'] ?? ($device['model'] ?? null), + 'app_version' => $anchor['app_version'] ?? ($app['version_name'] ?? null), + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}', + 'tags_json' => json_encode($anchor['tags'] ?? [], JSON_UNESCAPED_UNICODE) ?: '[]', + 'viewed' => !empty($anchor['viewed']) ? 1 : 0, + 'fingerprint_cnt' => 1, + 'fingerprint_recent_cnt' => 0, + ]; + } + $anchorRow['similarity_score'] = 10000; + $anchorRow['similarity_tier'] = 'anchor'; + $slice = array_values(array_filter( + $slice, + static fn (array $r): bool => (int) ($r['id'] ?? 0) !== $anchorId + )); + array_unshift($slice, $anchorRow); + if (count($slice) > $perPage) { + $slice = array_slice($slice, 0, $perPage); + } + } + self::finishListRows($slice, $sinceMs, false, true); + return [ + 'items' => $slice, + 'total' => $total + 1, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => 'similarity', + 'dir' => 'desc', + 'grouped' => false, + 'similar_to' => $anchorId, + ]; + } + + /** @return list */ + public static function searchSuggestions(string $q, int $limit = 10): array { + $tokens = search_query_tokens($q); + $prefix = $tokens[0] ?? mb_strtolower(trim($q)); + if (strlen($prefix) < 2) { + return []; + } + $pdo = Database::pdo(); + $where = ['1=1']; + $params = []; + self::appendCompanyScope('company_id', $where, $params); + $whereSql = implode(' AND ', $where); + $stmt = $pdo->prepare( + "SELECT payload_json, device_model, app_version, crash_type FROM reports + WHERE $whereSql ORDER BY received_at_ms DESC LIMIT 500" + ); + $stmt->execute($params); + $freq = []; + while ($row = $stmt->fetch()) { + $payload = json_decode($row['payload_json'] ?? '', true); + if (!is_array($payload)) { + $payload = []; + } + $java = is_array($payload['java'] ?? null) ? $payload['java'] : []; + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + $candidates = array_filter([ + $java['exception'] ?? null, + $row['crash_type'] ?? null, + $row['device_model'] ?? null, + $device['manufacturer'] ?? null, + $device['model'] ?? null, + $row['app_version'] ?? null, + ]); + foreach ($candidates as $term) { + $term = trim((string) $term); + if ($term === '' || strlen($term) < 3) { + continue; + } + $low = mb_strtolower($term); + if (!str_contains($low, $prefix)) { + continue; + } + $freq[$term] = ($freq[$term] ?? 0) + 1; + } + if (!empty($java['message'])) { + if (preg_match_all('/[A-Za-z][A-Za-z0-9_]{3,}(?:Exception|Error)/', (string) $java['message'], $m)) { + foreach ($m[0] as $term) { + $low = mb_strtolower($term); + if (str_contains($low, $prefix)) { + $freq[$term] = ($freq[$term] ?? 0) + 2; + } + } + } + } + } + arsort($freq); + return array_slice(array_keys($freq), 0, max(1, min(20, $limit))); + } + + public static function search(string $q, int $page, int $perPage, int $sinceMs = 0): array { + $tokens = search_query_tokens($q); + if ($tokens === []) { + return [ + 'items' => [], + 'total' => 0, + 'page' => 1, + 'per_page' => $perPage, + 'sort' => 'relevance', + 'dir' => 'desc', + 'grouped' => false, + 'search' => $q, + ]; + } + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $userId = (int) (Auth::user()['id'] ?? 0); + $where = ['1=1']; + $params = []; + self::appendCompanyScope('r.company_id', $where, $params); + $orParts = []; + foreach ($tokens as $token) { + $like = '%' . $token . '%'; + $orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ? + OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)'; + array_push($params, $like, $like, $like, $like, $like); + } + $where[] = '(' . implode(' OR ', $orParts) . ')'; + $whereSql = implode(' AND ', $where); + $sql = 'SELECT ' . self::listSelectColumns() . ' + FROM reports r + ' . self::fingerprintStatsJoin() . ' + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE ' . $whereSql . ' + ORDER BY r.received_at_ms DESC + LIMIT 1500'; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); + $scored = []; + while ($row = $stmt->fetch()) { + $payload = json_decode($row['payload_json'] ?? '', true); + if (!is_array($payload)) { + $payload = []; + } + report_enrich_list_row($row, $sinceMs); + $score = report_search_score($tokens, $row, $payload); + if ($score <= 0) { + continue; + } + $row['search_score'] = $score; + $scored[] = $row; + } + usort($scored, static function (array $a, array $b): int { + $sa = (int) ($a['search_score'] ?? 0); + $sb = (int) ($b['search_score'] ?? 0); + if ($sa !== $sb) { + return $sb <=> $sa; + } + return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0)); + }); + $total = count($scored); + $offset = ($page - 1) * $perPage; + $slice = array_slice($scored, $offset, $perPage); + self::finishListRows($slice, $sinceMs, false, false, true); + return [ + 'items' => $slice, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => 'relevance', + 'dir' => 'desc', + 'grouped' => false, + 'search' => $q, + ]; + } + + /** @param list> $items */ + private static function finishListRows( + array &$items, + int $sinceMs, + bool $grouped, + bool $similar = false, + bool $search = false + ): void { + foreach ($items as &$row) { + $row['viewed'] = (bool) ($row['viewed'] ?? false); + $row['tags'] = parse_report_tags($row['tags_json'] ?? '[]'); + report_enrich_list_row($row, $sinceMs); + $brief = report_brief_lines($row, $grouped); + if ($similar && !empty($row['similarity_tier'])) { + $tier = (string) $row['similarity_tier']; + $labels = [ + 'anchor' => 'This report', + 'exact' => 'Same fingerprint', + 'strong' => 'Strong match', + 'likely' => 'Likely similar', + 'guess' => 'Possible match', + ]; + array_unshift($brief, ($labels[$tier] ?? $tier) . ' · score ' . (int) ($row['similarity_score'] ?? 0)); + } + if ($search && !empty($row['search_score'])) { + array_unshift($brief, 'Search match · relevance ' . (int) $row['search_score']); + } + $row['brief'] = $brief; + unset($row['payload_json'], $row['tags_json']); + } + unset($row); + } + + private static function buildListOrder(string $sort, string $dir): string { + $dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC'; + $fpCnt = 'COALESCE(fp.fingerprint_cnt, 1)'; + if ($sort === 'priority') { + return "$fpCnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC"; + } + if ($sort === 'fingerprint_cnt') { + return "$fpCnt $dir, r.received_at_ms DESC"; + } + return "r.$sort $dir"; + } + + private static function listSelectColumns(): string { + return 'r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms, + r.device_model, r.app_version, r.payload_json, r.tags_json, + CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, + COALESCE(fp.fingerprint_cnt, 1) AS fingerprint_cnt, + COALESCE(fp.fingerprint_recent_cnt, 0) AS fingerprint_recent_cnt'; + } + + /** One grouped scan for fingerprint counts (replaces per-row correlated subqueries). */ + private static function fingerprintStatsJoin(): string { + return 'LEFT JOIN ( + SELECT company_id, fingerprint, + COUNT(*) AS fingerprint_cnt, + SUM(CASE WHEN received_at_ms > ? THEN 1 ELSE 0 END) AS fingerprint_recent_cnt + FROM reports + GROUP BY company_id, fingerprint + ) fp ON fp.company_id = r.company_id AND fp.fingerprint = r.fingerprint'; + } + + public static function setCustomTags(int $reportId, array $tags): bool { + if (self::getById($reportId) === null) { + return false; + } + $pdo = Database::pdo(); + $normalized = normalize_report_tags($tags); + $json = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $stmt = $pdo->prepare('UPDATE reports SET tags_json = ? WHERE id = ?'); + $stmt->execute([$json ?: '[]', $reportId]); + return true; + } + + /** Distinct custom tags used across reports (for suggestions). */ + public static function listTagPresets(int $limit = 40): array { + $pdo = Database::pdo(); + $where = ['tags_json IS NOT NULL', "tags_json != '[]'"]; + $params = []; + self::appendCompanyScope('company_id', $where, $params); + $whereSql = implode(' AND ', $where); + $stmt = $pdo->prepare("SELECT tags_json FROM reports WHERE $whereSql LIMIT 500"); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + $map = []; + foreach ($rows as $row) { + foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) { + $map[$tag['id']] = $tag; + } + } + return array_values($map); + } + + public static function markViewed(int $reportId, int $userId): void { + Database::upsertReportView( + $userId, + $reportId, + (int) round(microtime(true) * 1000) + ); + } + + public static function getById(int $id): ?array { + $pdo = Database::pdo(); + $userId = (int) (Auth::user()['id'] ?? 0); + $stmt = $pdo->prepare( + 'SELECT r.*, CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed + FROM reports r + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE r.id = ?' + ); + $stmt->execute([$userId, $id]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + if (!Rbac::canAccessReport($row)) { + return null; + } + $decoded = json_decode($row['payload_json'], true); + $row['payload'] = is_array($decoded) ? $decoded : []; + $row['tags'] = parse_report_tags($row['tags_json'] ?? '[]'); + $row['viewed'] = (bool) ($row['viewed'] ?? false); + return $row; + } + + /** + * @param list $where + * @param list $params + */ + private static function appendCompanyScope(string $column, array &$where, array &$params): void { + Rbac::appendCompanyScope($column, $where, $params); + } + + /** + * @param list $where + * @param list $params + * @param array $filters + */ + private static function appendTagFilters(array &$where, array &$params, array $filters): void { + $tags = $filters['tags'] ?? []; + if (!is_array($tags) || $tags === []) { + return; + } + $ids = TagCatalog::normalizeFilterIds($tags); + if ($ids === []) { + return; + } + $mode = strtolower((string) ($filters['tag_mode'] ?? 'and')) === 'or' ? 'or' : 'and'; + $clauses = []; + foreach ($ids as $id) { + $clauses[] = '(r.tags_json LIKE ? OR r.tags_json LIKE ?)'; + $params[] = '%"id":"' . $id . '"%'; + $params[] = '%"id": "' . $id . '"%'; + } + if ($mode === 'or') { + $where[] = '(' . implode(' OR ', $clauses) . ')'; + } else { + foreach ($clauses as $clause) { + $where[] = $clause; + } + } + } + +} diff --git a/sim/cluster0/lab-seeds/backend/src/RsshBastionProvisioner.php b/sim/cluster0/lab-seeds/backend/src/RsshBastionProvisioner.php new file mode 100644 index 0000000..8f24a59 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/RsshBastionProvisioner.php @@ -0,0 +1,89 @@ + false, 'error' => 'invalid_username']; + } + if (!self::isEnabled()) { + return ['ok' => true]; + } + $code = self::runScript('add', $user, $password); + if ($code !== 0) { + error_log('RsshBastionProvisioner add failed for ' . $user . ' exit=' . $code); + return ['ok' => false, 'error' => 'bastion_user_add_failed']; + } + return ['ok' => true]; + } + + public static function removeUser(?string $username): void { + $user = self::normalizeUsername((string) $username); + if ($user === '' || !self::isEnabled()) { + return; + } + $code = self::runScript('remove', $user, ''); + if ($code !== 0) { + error_log('RsshBastionProvisioner remove failed for ' . $user . ' exit=' . $code); + } + } + + /** Operator command lines once device has connected reverse forward. */ + public static function operatorCommands(array $session): array { + $username = trim((string) ($session['rssh_username'] ?? '')); + $remotePort = (int) ($session['rssh_remote_port'] ?? 0); + if ($username === '' || $remotePort <= 0) { + return []; + } + $host = '127.0.0.1'; + return [ + 'shell' => sprintf('ssh -p %d %s@%s', $remotePort, $username, $host), + 'sftp' => sprintf('sftp -P %d %s@%s', $remotePort, $username, $host), + 'scp_example' => sprintf('scp -P %d %s@%s:/path/on/device ./', $remotePort, $username, $host), + ]; + } + + private static function normalizeUsername(string $username): string { + $user = trim($username); + if ($user === '' || !preg_match('/^ra-[a-zA-Z0-9_-]{1,30}$/', $user)) { + return ''; + } + return $user; + } + + private static function runScript(string $action, string $username, string $password): int { + $script = self::scriptPath(); + if (!is_file($script) || !is_executable($script)) { + error_log('RsshBastionProvisioner: script missing or not executable: ' . $script); + return 127; + } + $cmd = escapeshellarg($script) . ' ' . escapeshellarg($action) . ' ' . escapeshellarg($username); + if ($action === 'add') { + $cmd .= ' ' . escapeshellarg($password); + } + $out = []; + exec($cmd . ' 2>&1', $out, $code); + if ($code !== 0 && $out !== []) { + error_log('RsshBastionProvisioner: ' . implode("\n", $out)); + } + return (int) $code; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/RsshSessionProvisioner.php b/sim/cluster0/lab-seeds/backend/src/RsshSessionProvisioner.php new file mode 100644 index 0000000..b097162 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/RsshSessionProvisioner.php @@ -0,0 +1,50 @@ + 32) { + $username = substr($username, 0, 32); + } + $password = bin2hex(random_bytes(16)); + $remotePort = self::allocateRemotePort($sessionId); + $localPort = max(1, min(65535, (int) cfg('remote_access.rssh.local_forward_port', 8022))); + return [ + 'bastion_host' => $host, + 'bastion_port' => $port, + 'username' => $username, + 'password' => $password, + 'remote_bind_port' => $remotePort, + 'local_forward_port' => $localPort, + ]; + } + + public static function allocateRemotePort(string $sessionId): int { + $min = (int) cfg('remote_access.rssh.remote_port_min', 18000); + $max = (int) cfg('remote_access.rssh.remote_port_max', 18999); + if ($max <= $min) { + $max = $min + 999; + } + $span = $max - $min + 1; + return $min + (abs(crc32($sessionId . '|' . time())) % $span); + } + + /** @param array $session */ + public static function endpointFromSession(array $session): string { + $host = (string) ($session['rssh_bastion_host'] ?? cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org')); + $port = (int) ($session['rssh_bastion_port'] ?? cfg('remote_access.rssh.bastion_port', 443)); + if ($port === 443) { + return $host; + } + return $host . ':' . $port; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php new file mode 100644 index 0000000..6e8997d --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php @@ -0,0 +1,481 @@ + false, 'error' => 'disabled']; + } + if (!UrlShortenerDatabase::ping()) { + return ['ok' => false, 'error' => 'db_unreachable']; + } + return ['ok' => true]; + } + + /** @return list> */ + public static function listBearers(): array { + $pdo = UrlShortenerDatabase::pdo(); + $rows = $pdo->query( + 'SELECT b.id, b.label, b.trusted, b.can_temporary, b.can_permanent, + b.rate_limit_per_hour, b.revoked_at, b.created_at, + COUNT(l.id) AS link_count, + SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at < NOW() THEN 1 ELSE 0 END) AS expired_link_count + FROM bearer_tokens b + LEFT JOIN links l ON l.bearer_id = b.id + GROUP BY b.id + ORDER BY b.id DESC' + )->fetchAll(); + return array_map(static fn(array $r) => self::formatBearerRow($r), $rows); + } + + /** @return list> */ + public static function listLinks(int $bearerId, int $limit = 100): array { + $pdo = UrlShortenerDatabase::pdo(); + $limit = max(1, min(500, $limit)); + $stmt = $pdo->prepare( + 'SELECT id, slug, origin, ttl_seconds, expires_at, created_at, accessed_at, access_count + FROM links WHERE bearer_id = ? ORDER BY id DESC LIMIT ' . $limit + ); + $stmt->execute([$bearerId]); + $base = UrlShortenerDatabase::publicBase(); + $out = []; + foreach ($stmt->fetchAll() as $row) { + $slug = (string) $row['slug']; + $expired = self::rowExpired($row); + $out[] = [ + 'id' => (int) $row['id'], + 'slug' => $slug, + 'short_url' => $base . '/' . $slug, + 'qr_url' => $base . '/api/v1/qr/' . $slug . '.png', + 'origin' => (string) $row['origin'], + 'ttl_seconds' => (int) $row['ttl_seconds'], + 'expires_at' => $row['expires_at'], + 'expired' => $expired, + 'created_at' => $row['created_at'], + 'accessed_at' => $row['accessed_at'], + 'access_count' => (int) $row['access_count'], + ]; + } + return $out; + } + + /** @return list> */ + public static function listAudit(int $limit = 50): array { + $pdo = UrlShortenerDatabase::pdo(); + $limit = max(1, min(200, $limit)); + $rows = $pdo->query( + 'SELECT id, event, bearer_id, slug, ip, detail, created_at + FROM audit_log ORDER BY id DESC LIMIT ' . $limit + )->fetchAll(); + return array_map(static function (array $r): array { + return [ + 'id' => (int) $r['id'], + 'event' => (string) $r['event'], + 'bearer_id' => $r['bearer_id'] !== null ? (int) $r['bearer_id'] : null, + 'slug' => $r['slug'], + 'ip' => (string) $r['ip'], + 'detail' => (string) ($r['detail'] ?? ''), + 'created_at' => $r['created_at'], + ]; + }, $rows); + } + + /** + * @return array{ok: true, bearer_id: int, token: string, signer_key?: string, signer_id?: int}|array{ok: false, error: string} + */ + public static function mintBearer( + string $label, + bool $canTemporary = true, + bool $canPermanent = false, + int $rateLimit = 1000, + bool $mintSigner = false + ): array { + $label = trim($label); + if ($label === '') { + return ['ok' => false, 'error' => 'missing_label']; + } + if (strlen($label) > 128) { + return ['ok' => false, 'error' => 'label_too_long']; + } + $token = bin2hex(random_bytes(24)); + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO bearer_tokens (token_hash, label, trusted, can_temporary, can_permanent, rate_limit_per_hour) + VALUES (?, ?, 1, ?, ?, ?)' + ); + $stmt->execute([ + hash('sha256', $token), + $label, + $canTemporary ? 1 : 0, + $canPermanent ? 1 : 0, + max(1, min(100000, $rateLimit)), + ]); + $id = (int) $pdo->lastInsertId(); + self::audit('admin_mint', $id, null, 'label=' . $label); + $out = ['ok' => true, 'bearer_id' => $id, 'token' => $token]; + if ($canPermanent && $mintSigner) { + $signer = self::mintSigner($label . '-signer'); + $out['signer_id'] = $signer['id']; + $out['signer_key'] = $signer['key']; + } + return $out; + } + + /** @return list> */ + public static function listSigners(): array { + $pdo = UrlShortenerDatabase::pdo(); + $rows = $pdo->query( + 'SELECT id, label, can_permanent, revoked_at, created_at + FROM signer_keys ORDER BY id DESC' + )->fetchAll(); + return array_map(static function (array $r): array { + return [ + 'id' => (int) $r['id'], + 'label' => (string) $r['label'], + 'can_permanent' => (int) ($r['can_permanent'] ?? 0) === 1, + 'revoked_at' => $r['revoked_at'], + 'active' => empty($r['revoked_at']), + 'created_at' => $r['created_at'], + ]; + }, $rows); + } + + /** + * @return array{ok: true, id: int, key: string}|array{ok: false, error: string} + */ + public static function mintSigner(string $label): array { + $label = trim($label); + if ($label === '') { + return ['ok' => false, 'error' => 'missing_label']; + } + if (strlen($label) > 128) { + return ['ok' => false, 'error' => 'label_too_long']; + } + $key = bin2hex(random_bytes(24)); + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO signer_keys (key_hash, label, can_permanent) VALUES (?, ?, 1)' + ); + $stmt->execute([hash('sha256', $key), $label]); + $id = (int) $pdo->lastInsertId(); + self::audit('admin_mint_signer', null, null, 'signer_id=' . $id . ' label=' . $label); + return ['ok' => true, 'id' => $id, 'key' => $key]; + } + + public static function revokeSigner(int $signerId): bool { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'UPDATE signer_keys SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL' + ); + $stmt->execute([$signerId]); + if ($stmt->rowCount() < 1) { + return false; + } + self::audit('admin_revoke_signer', null, null, 'signer_id=' . $signerId); + return true; + } + + public static function revokeBearer(int $bearerId): bool { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'UPDATE bearer_tokens SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL' + ); + $stmt->execute([$bearerId]); + if ($stmt->rowCount() < 1) { + return false; + } + self::audit('admin_revoke', $bearerId, null, 'revoked'); + return true; + } + + /** @return array{links: int, audit: int} */ + public static function purgeExpired(): array { + $pdo = UrlShortenerDatabase::pdo(); + $links = (int) $pdo->exec( + 'DELETE FROM links WHERE expires_at IS NOT NULL AND expires_at < NOW()' + ); + $audit = (int) $pdo->exec( + 'DELETE FROM audit_log WHERE created_at < (NOW() - INTERVAL 90 DAY)' + ); + self::audit('admin_purge', null, null, "links=$links audit=$audit"); + return ['links' => $links, 'audit' => $audit]; + } + + /** + * @return array{ok: true, short_url: string, slug: string, qr_url: string, ttl: string, url?: string}|array{ok: false, error: string, message?: string} + */ + public static function createLink( + int $bearerId, + string $rawUrl, + int $ttlSeconds = 86400, + string $signerKey = '' + ): array { + $bearer = self::findBearerById($bearerId); + if ($bearer === null) { + return ['ok' => false, 'error' => 'unknown_bearer']; + } + if ($bearer['revoked_at'] !== null && $bearer['revoked_at'] !== '') { + return ['ok' => false, 'error' => 'bearer_revoked']; + } + if ((int) ($bearer['trusted'] ?? 0) !== 1) { + return ['ok' => false, 'error' => 'bearer_untrusted']; + } + + try { + $normalized = self::normalizeUrl($rawUrl); + } catch (InvalidArgumentException $e) { + return ['ok' => false, 'error' => 'invalid_url', 'message' => $e->getMessage()]; + } + $originHash = hash('sha256', $normalized); + + $cached = self::findActiveLink($bearerId, $originHash); + if ($cached !== null) { + return self::linkSuccess($normalized, $cached); + } + if (self::findExpiredLink($bearerId, $originHash) !== null) { + return ['ok' => false, 'error' => 'ttl_expired']; + } + + if ($ttlSeconds < 0) { + $ttlSeconds = 86400; + } + + $signerId = null; + if ($ttlSeconds === 0) { + $signer = self::findValidSigner($signerKey); + if ($signer === null) { + return ['ok' => false, 'error' => 'signer_required']; + } + if ((int) ($bearer['can_permanent'] ?? 0) !== 1) { + return ['ok' => false, 'error' => 'bearer_no_permanent']; + } + $signerId = (int) $signer['id']; + } elseif ((int) ($bearer['can_temporary'] ?? 0) !== 1) { + return ['ok' => false, 'error' => 'bearer_no_temporary']; + } + + $limit = max(1, (int) ($bearer['rate_limit_per_hour'] ?? 1000)); + if (self::countCreatesLastHour($bearerId) >= $limit) { + return ['ok' => false, 'error' => 'rate_limited']; + } + + $slug = self::allocateSlug($bearerId, $normalized); + $expiresAt = $ttlSeconds <= 0 ? null : date('Y-m-d H:i:s', time() + $ttlSeconds); + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO links (bearer_id, signer_id, slug, origin, origin_hash, ttl_seconds, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $bearerId, + $signerId, + $slug, + $normalized, + $originHash, + max(0, $ttlSeconds), + $expiresAt, + ]); + self::audit('admin_shorten', $bearerId, $slug, $ttlSeconds === 0 ? 'permanent' : 'created'); + + $row = self::findLinkBySlug($slug); + if ($row === null) { + return ['ok' => false, 'error' => 'insert_failed']; + } + return self::linkSuccess($normalized, $row); + } + + /** @param array $row */ + private static function formatBearerRow(array $row): array { + return [ + 'id' => (int) $row['id'], + 'label' => (string) $row['label'], + 'trusted' => (int) ($row['trusted'] ?? 0) === 1, + 'can_temporary' => (int) ($row['can_temporary'] ?? 0) === 1, + 'can_permanent' => (int) ($row['can_permanent'] ?? 0) === 1, + 'rate_limit_per_hour' => (int) ($row['rate_limit_per_hour'] ?? 1000), + 'revoked_at' => $row['revoked_at'], + 'active' => empty($row['revoked_at']), + 'created_at' => $row['created_at'], + 'link_count' => (int) ($row['link_count'] ?? 0), + 'expired_link_count' => (int) ($row['expired_link_count'] ?? 0), + ]; + } + + /** @return array|null */ + private static function findValidSigner(string $signerKey): ?array { + if (trim($signerKey) === '') { + return null; + } + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'SELECT * FROM signer_keys WHERE key_hash = ? AND revoked_at IS NULL AND can_permanent = 1 LIMIT 1' + ); + $stmt->execute([hash('sha256', $signerKey)]); + $row = $stmt->fetch(); + return $row === false ? null : $row; + } + + /** @return array|null */ + private static function findBearerById(int $id): ?array { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare('SELECT * FROM bearer_tokens WHERE id = ? LIMIT 1'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row === false ? null : $row; + } + + /** @return array|null */ + private static function findActiveLink(int $bearerId, string $originHash): ?array { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1' + ); + $stmt->execute([$bearerId, $originHash]); + $row = $stmt->fetch(); + if ($row === false || self::rowExpired($row)) { + return null; + } + return $row; + } + + /** @return array|null */ + private static function findExpiredLink(int $bearerId, string $originHash): ?array { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1' + ); + $stmt->execute([$bearerId, $originHash]); + $row = $stmt->fetch(); + if ($row === false || !self::rowExpired($row)) { + return null; + } + return $row; + } + + /** @return array|null */ + private static function findLinkBySlug(string $slug): ?array { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare('SELECT * FROM links WHERE slug = ? LIMIT 1'); + $stmt->execute([$slug]); + $row = $stmt->fetch(); + return $row === false ? null : $row; + } + + /** @param array $row */ + private static function rowExpired(array $row): bool { + $exp = $row['expires_at'] ?? null; + if ($exp === null || $exp === '') { + return false; + } + return strtotime((string) $exp) <= time(); + } + + private static function countCreatesLastHour(int $bearerId): int { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'SELECT COUNT(*) AS c FROM links WHERE bearer_id = ? AND created_at >= (NOW() - INTERVAL 1 HOUR)' + ); + $stmt->execute([$bearerId]); + $row = $stmt->fetch(); + return (int) ($row['c'] ?? 0); + } + + private static function allocateSlug(int $bearerId, string $normalized): string { + $material = $bearerId . "\x1f" . $normalized; + $candidates = [substr(hash('sha256', $material), 0, 12)]; + for ($i = 1; $i <= 15; $i++) { + $candidates[] = substr(hash('sha256', $material . "\x1e" . $i), 0, 12); + } + $pdo = UrlShortenerDatabase::pdo(); + foreach ($candidates as $slug) { + $stmt = $pdo->prepare('SELECT 1 FROM links WHERE slug = ? LIMIT 1'); + $stmt->execute([$slug]); + if ($stmt->fetch() === false) { + return $slug; + } + } + throw new RuntimeException('slug exhaustion'); + } + + /** @param array $row */ + private static function linkSuccess(string $normalized, array $row): array { + $slug = (string) $row['slug']; + $base = UrlShortenerDatabase::publicBase(); + $exp = $row['expires_at'] ?? null; + $ttl = '0'; + if ($exp !== null && $exp !== '') { + $ttl = (string) max(0, strtotime((string) $exp) - time()); + } + return [ + 'ok' => true, + 'url' => $normalized, + 'short_url' => $base . '/' . $slug, + 'slug' => $slug, + 'qr_url' => $base . '/api/v1/qr/' . $slug . '.png', + 'ttl' => $ttl, + ]; + } + + private static function normalizeUrl(string $raw): string { + $url = trim($raw); + if ($url === '') { + throw new InvalidArgumentException('empty url'); + } + if (strlen($url) > self::MAX_URL_LEN) { + throw new InvalidArgumentException('url too long'); + } + $parts = parse_url($url); + if ($parts === false || !isset($parts['scheme'], $parts['host'])) { + throw new InvalidArgumentException('malformed url'); + } + $scheme = strtolower((string) $parts['scheme']); + if ($scheme !== 'http' && $scheme !== 'https') { + throw new InvalidArgumentException('unsupported scheme'); + } + $host = strtolower((string) $parts['host']); + self::assertHostAllowed($host); + $port = isset($parts['port']) ? ':' . (int) $parts['port'] : ''; + $path = $parts['path'] ?? ''; + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return $scheme . '://' . $host . $port . $path . $query . $fragment; + } + + private static function assertHostAllowed(string $host): void { + if ($host === 'localhost' || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) { + throw new InvalidArgumentException('blocked host'); + } + if ($host === 'metadata.google.internal' || $host === 'metadata') { + throw new InvalidArgumentException('blocked host'); + } + if (filter_var($host, FILTER_VALIDATE_IP)) { + self::assertIpAllowed($host); + return; + } + $resolved = @gethostbyname($host); + if ($resolved !== $host && filter_var($resolved, FILTER_VALIDATE_IP)) { + self::assertIpAllowed($resolved); + } + } + + private static function assertIpAllowed(string $ip): void { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + throw new InvalidArgumentException('blocked host'); + } + } + + private static function audit(string $event, ?int $bearerId, ?string $slug, string $detail): void { + $pdo = UrlShortenerDatabase::pdo(); + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + $stmt = $pdo->prepare( + 'INSERT INTO audit_log (event, bearer_id, slug, ip, detail) VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([$event, $bearerId, $slug, $ip, $detail]); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/TagCatalog.php b/sim/cluster0/lab-seeds/backend/src/TagCatalog.php new file mode 100644 index 0000000..7ef1088 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/TagCatalog.php @@ -0,0 +1,84 @@ +|null */ + private static ?array $workflow = null; + + /** @return list */ + public static function workflowPresets(): array { + if (self::$workflow !== null) { + return self::$workflow; + } + self::$workflow = [ + ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3d8bfd'], + ['id' => 'fixed', 'label' => 'fixed', 'bg' => '#047857'], + ['id' => 'rejected', 'label' => 'rejected', 'bg' => '#dc2626'], + ['id' => 'done', 'label' => 'done', 'bg' => '#5c6b82'], + ['id' => 'not-reproducible', 'label' => 'not reproducible', 'bg' => '#d97706'], + ['id' => 'not-a-bug', 'label' => 'not a bug', 'bg' => '#6b7280'], + ['id' => 'duplicate', 'label' => 'duplicate', 'bg' => '#6d28d9'], + ]; + return self::$workflow; + } + + /** @return array */ + public static function workflowIds(): array { + $map = []; + foreach (self::workflowPresets() as $tag) { + $map[$tag['id']] = true; + } + return $map; + } + + public static function isWorkflowId(string $id): bool { + return isset(self::workflowIds()[$id]); + } + + /** + * @param list $raw from query (?tag=fixed&tag=duplicate) + * @return list + */ + public static function normalizeFilterIds(array $raw): array { + $out = []; + $seen = []; + foreach ($raw as $item) { + $id = strtolower(trim((string) $item)); + $id = preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? ''; + $id = trim($id, '-'); + if ($id === '' || strlen($id) > 32 || isset($seen[$id])) { + continue; + } + if (isset(array_flip(report_reserved_tag_ids())[$id])) { + continue; + } + $seen[$id] = true; + $out[] = $id; + if (count($out) >= 8) { + break; + } + } + return $out; + } + + /** + * Workflow presets first, then distinct custom tags from DB (excluding workflow ids). + * + * @return list + */ + public static function editorPresets(int $historicalLimit = 32): array { + $map = []; + foreach (self::workflowPresets() as $tag) { + $map[$tag['id']] = $tag; + } + foreach (ReportRepository::listTagPresets($historicalLimit + 20) as $tag) { + $id = (string) ($tag['id'] ?? ''); + if ($id === '' || isset($map[$id])) { + continue; + } + $map[$id] = $tag; + } + return array_values($map); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/TicketAttachmentProvider.php b/sim/cluster0/lab-seeds/backend/src/TicketAttachmentProvider.php new file mode 100644 index 0000000..c40e795 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/TicketAttachmentProvider.php @@ -0,0 +1,62 @@ + */ + public static function labels(): array { + return [ + self::GENERIC => ['id' => self::GENERIC, 'label' => 'Link'], + self::GOOGLE_DOCS => ['id' => self::GOOGLE_DOCS, 'label' => 'Google Docs'], + self::GOOGLE_SHEETS => ['id' => self::GOOGLE_SHEETS, 'label' => 'Google Sheets'], + self::GOOGLE_SLIDES => ['id' => self::GOOGLE_SLIDES, 'label' => 'Google Slides'], + self::GOOGLE_DRIVE => ['id' => self::GOOGLE_DRIVE, 'label' => 'Google Drive'], + self::GOOGLE_MEET => ['id' => self::GOOGLE_MEET, 'label' => 'Google Meet'], + self::GOOGLE_CHAT => ['id' => self::GOOGLE_CHAT, 'label' => 'Google Chat'], + self::GOOGLE_FORMS => ['id' => self::GOOGLE_FORMS, 'label' => 'Google Forms'], + ]; + } + + public static function labelFor(string $provider): string { + $labels = self::labels(); + return $labels[$provider]['label'] ?? 'Link'; + } + + public static function detectFromUrl(string $url): string { + $host = strtolower(parse_url($url, PHP_URL_HOST) ?? ''); + if ($host === '') { + return self::GENERIC; + } + if (str_contains($host, 'docs.google.com')) { + return self::GOOGLE_DOCS; + } + if (str_contains($host, 'sheets.google.com')) { + return self::GOOGLE_SHEETS; + } + if (str_contains($host, 'slides.google.com')) { + return self::GOOGLE_SLIDES; + } + if (str_contains($host, 'drive.google.com')) { + return self::GOOGLE_DRIVE; + } + if (str_contains($host, 'meet.google.com')) { + return self::GOOGLE_MEET; + } + if (str_contains($host, 'chat.google.com')) { + return self::GOOGLE_CHAT; + } + if (str_contains($host, 'forms.gle') || str_contains($host, 'forms.google.com')) { + return self::GOOGLE_FORMS; + } + return self::GENERIC; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/TicketAttachmentRepository.php b/sim/cluster0/lab-seeds/backend/src/TicketAttachmentRepository.php new file mode 100644 index 0000000..20a125b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/TicketAttachmentRepository.php @@ -0,0 +1,205 @@ + */ + private static function allowedExtensions(): array { + return [ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', + 'pdf', 'txt', 'log', 'md', 'json', 'xml', 'zip', + 'mp4', 'webm', 'mp3', 'wav', + ]; + } + + public static function storageRoot(): string { + $dir = dirname(__DIR__) . '/storage/ticket_attachments'; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + return $dir; + } + + public static function listForTicket(int $ticketId): array { + // Do not call TicketRepository::getById here — avoids recursion from mapDetailRow. + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT * FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC' + ); + $stmt->execute([$ticketId]); + $bp = Auth::basePath(); + $out = []; + foreach ($stmt->fetchAll() as $row) { + $out[] = self::mapRow($row, $bp); + } + return $out; + } + + public static function getById(int $id): ?array { + $pdo = Database::pdo(); + $stmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE id = ? LIMIT 1'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + $ticket = TicketRepository::getById((int) $row['ticket_id']); + if (!$ticket) { + return null; + } + return self::mapRow($row, Auth::basePath()); + } + + public static function addLink(int $ticketId, string $author, string $url, string $title = ''): array { + $ticket = TicketRepository::getById($ticketId); + if (!$ticket) { + throw new RuntimeException('not found'); + } + $url = trim($url); + if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) { + throw new InvalidArgumentException('valid url required'); + } + $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''); + if (!in_array($scheme, ['http', 'https'], true)) { + throw new InvalidArgumentException('http or https url required'); + } + $provider = TicketAttachmentProvider::detectFromUrl($url); + if ($title === '') { + $title = TicketAttachmentProvider::labelFor($provider); + } + $now = (int) round(microtime(true) * 1000); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider, + storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms) + VALUES (?, \'link\', ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?)' + ); + $stmt->execute([ + $ticketId, + mb_substr($title, 0, 256), + $url, + $provider, + mb_substr(trim($author), 0, 64), + $now, + ]); + self::touchTicket($ticketId, $now); + return self::getById((int) $pdo->lastInsertId()) ?? []; + } + + /** @param array{name:string,type:string,tmp_name:string,error:int,size:int} $file */ + public static function addFile(int $ticketId, string $author, array $file, string $title = ''): array { + $ticket = TicketRepository::getById($ticketId); + if (!$ticket) { + throw new RuntimeException('not found'); + } + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + throw new InvalidArgumentException('upload failed'); + } + $size = (int) ($file['size'] ?? 0); + if ($size <= 0 || $size > self::MAX_BYTES) { + throw new InvalidArgumentException('file too large (max 16 MiB)'); + } + $original = basename((string) ($file['name'] ?? 'file')); + $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION)); + if ($ext === '' || !in_array($ext, self::allowedExtensions(), true)) { + throw new InvalidArgumentException('file type not allowed'); + } + $mime = (string) ($file['type'] ?? 'application/octet-stream'); + $storageName = bin2hex(random_bytes(16)) . '.' . $ext; + $dir = self::storageRoot() . '/' . $ticketId; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + $dest = $dir . '/' . $storageName; + if (!move_uploaded_file((string) $file['tmp_name'], $dest)) { + throw new RuntimeException('could not store file'); + } + if ($title === '') { + $title = $original; + } + $now = (int) round(microtime(true) * 1000); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider, + storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms) + VALUES (?, \'file\', ?, NULL, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $ticketId, + mb_substr($title, 0, 256), + TicketAttachmentProvider::GENERIC, + $storageName, + mb_substr($original, 0, 256), + mb_substr($mime, 0, 128), + $size, + mb_substr(trim($author), 0, 64), + $now, + ]); + self::touchTicket($ticketId, $now); + return self::getById((int) $pdo->lastInsertId()) ?? []; + } + + public static function delete(int $id, string $username): void { + $att = self::getById($id); + if (!$att) { + throw new RuntimeException('not found'); + } + $ticket = TicketRepository::getById((int) $att['ticket_id']); + if (!$ticket) { + throw new RuntimeException('not found'); + } + $can = !empty($ticket['can_edit']) + || strcasecmp((string) ($att['created_by'] ?? ''), $username) === 0 + || Auth::canEditTags(); + if (!$can) { + throw new RuntimeException('forbidden'); + } + if ($att['kind'] === 'file' && !empty($att['storage_name'])) { + $path = self::storageRoot() . '/' . $att['ticket_id'] . '/' . $att['storage_name']; + if (is_file($path)) { + @unlink($path); + } + } + $pdo = Database::pdo(); + $pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([$id]); + self::touchTicket((int) $att['ticket_id'], (int) round(microtime(true) * 1000)); + } + + public static function filePath(array $attachment): ?string { + if (($attachment['kind'] ?? '') !== 'file' || empty($attachment['storage_name'])) { + return null; + } + $path = self::storageRoot() . '/' . (int) $attachment['ticket_id'] . '/' . $attachment['storage_name']; + return is_file($path) ? $path : null; + } + + private static function touchTicket(int $ticketId, int $now): void { + Database::pdo()->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]); + } + + /** @param array $row */ + private static function mapRow(array $row, string $basePath): array { + $id = (int) $row['id']; + $kind = (string) $row['kind']; + $provider = (string) ($row['provider'] ?? TicketAttachmentProvider::GENERIC); + $item = [ + 'id' => $id, + 'ticket_id' => (int) $row['ticket_id'], + 'kind' => $kind, + 'title' => (string) ($row['title'] ?? ''), + 'external_url' => (string) ($row['external_url'] ?? ''), + 'provider' => $provider, + 'provider_label' => TicketAttachmentProvider::labelFor($provider), + 'original_name' => (string) ($row['original_name'] ?? ''), + 'mime_type' => (string) ($row['mime_type'] ?? ''), + 'size_bytes' => (int) ($row['size_bytes'] ?? 0), + 'created_by' => (string) ($row['created_by'] ?? ''), + 'created_at_ms' => (int) ($row['created_at_ms'] ?? 0), + 'download_url' => $kind === 'file' + ? $basePath . '/api/ticket_attachment.php?id=' . $id + : null, + ]; + return $item; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/TicketCommentRepository.php b/sim/cluster0/lab-seeds/backend/src/TicketCommentRepository.php new file mode 100644 index 0000000..1f82d80 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/TicketCommentRepository.php @@ -0,0 +1,58 @@ +prepare( + 'SELECT id, ticket_id, author_username, body, created_at_ms, updated_at_ms + FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC' + ); + $stmt->execute([$ticketId]); + $out = []; + foreach ($stmt->fetchAll() as $row) { + $out[] = [ + 'id' => (int) $row['id'], + 'ticket_id' => (int) $row['ticket_id'], + 'author_username' => (string) $row['author_username'], + 'body' => (string) $row['body'], + 'created_at_ms' => (int) $row['created_at_ms'], + 'updated_at_ms' => (int) $row['updated_at_ms'], + ]; + } + return $out; + } + + public static function add(int $ticketId, string $author, string $body): array { + $ticket = TicketRepository::getById($ticketId); + if (!$ticket) { + throw new RuntimeException('not found'); + } + $body = trim($body); + if ($body === '') { + throw new InvalidArgumentException('comment body required'); + } + $author = mb_substr(trim($author), 0, 64); + if ($author === '') { + throw new InvalidArgumentException('author required'); + } + $now = (int) round(microtime(true) * 1000); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO ticket_comments (ticket_id, author_username, body, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([$ticketId, $author, $body, $now, $now]); + $id = (int) $pdo->lastInsertId(); + $pdo->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]); + return [ + 'id' => $id, + 'ticket_id' => $ticketId, + 'author_username' => $author, + 'body' => $body, + 'created_at_ms' => $now, + 'updated_at_ms' => $now, + ]; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/TicketRepository.php b/sim/cluster0/lab-seeds/backend/src/TicketRepository.php new file mode 100644 index 0000000..05104a1 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/TicketRepository.php @@ -0,0 +1,371 @@ +prepare( + 'INSERT INTO tickets (company_id, device_id, ticket_id, title, brief, body, + opened_at_ms, created_at_ms, updated_at_ms, owner_username, verified_by_username, + device_model, app_package, app_version, os_name, os_version, rating, tags_json, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $companyId, + $deviceId > 0 ? $deviceId : null, + $payload['ticket_id'] ?? uniqid('tkt_', true), + mb_substr($title, 0, 256), + $brief !== '' ? $brief : null, + $body, + $opened, + $now, + $now, + mb_substr($owner, 0, 64), + $verified !== '' ? mb_substr($verified, 0, 64) : null, + $device['model'] ?? null, + $app['package'] ?? 'com.foxx.androidcast', + $app['version_name'] ?? null, + $osName !== '' ? $osName : null, + $osVer !== '' ? $osVer : null, + $rating, + $tagsJson, + $json, + ]); + return (int) $pdo->lastInsertId(); + } + + public static function listPage(int $page, int $perPage, string $sort, string $dir, string $tagFilter = ''): array { + $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC'; + $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'opened_at_ms'; + $offset = ($page - 1) * $perPage; + $where = ['1=1']; + $params = []; + self::appendCompanyScope('company_id', $where, $params); + if ($tagFilter !== '') { + $where[] = 'tags_json LIKE ?'; + $params[] = '%"id":"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagFilter) . '"%'; + } + $whereSql = implode(' AND ', $where); + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE $whereSql"); + $countStmt->execute($params); + $total = (int) $countStmt->fetchColumn(); + $select = 'id, ticket_id, title, brief, opened_at_ms, updated_at_ms, created_at_ms, + owner_username, verified_by_username, device_model, app_package, app_version, + os_name, os_version, rating, tags_json'; + if (self::hasTicketColumn('workflow_state')) { + $select .= ', workflow_state'; + } + if (self::hasTicketColumn('assignees_json')) { + $select .= ', assignees_json'; + } + $sql = "SELECT $select + FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset"; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $items = []; + foreach ($stmt->fetchAll() as $row) { + $items[] = self::mapListRow($row); + } + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sortCol, + 'dir' => strtolower($dir), + ]; + } + + public static function getById(int $id): ?array { + $pdo = Database::pdo(); + $where = ['id = ?']; + $params = [$id]; + self::appendCompanyScope('company_id', $where, $params); + $stmt = $pdo->prepare( + 'SELECT * FROM tickets WHERE ' . implode(' AND ', $where) . ' LIMIT 1' + ); + $stmt->execute($params); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + return self::mapDetailRow($row); + } + + public static function setCustomTags(int $id, array $tagsIn): void { + $ticket = self::getById($id); + if (!$ticket) { + throw new RuntimeException('not found'); + } + $err = TicketTagCatalog::validate($tagsIn); + if ($err !== null) { + throw new InvalidArgumentException($err); + } + $tags = TicketTagCatalog::normalize($tagsIn); + $now = (int) round(microtime(true) * 1000); + $pdo = Database::pdo(); + $stmt = $pdo->prepare('UPDATE tickets SET tags_json = ?, updated_at_ms = ? WHERE id = ?'); + $stmt->execute([ + json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + $now, + $id, + ]); + } + + /** @param array $fields */ + public static function update(int $id, array $fields): void { + $ticket = self::getById($id); + if (!$ticket) { + throw new RuntimeException('not found'); + } + $sets = []; + $params = []; + if (isset($fields['title'])) { + $title = trim((string) $fields['title']); + if ($title === '') { + throw new InvalidArgumentException('title required'); + } + $sets[] = 'title = ?'; + $params[] = mb_substr($title, 0, 256); + } + if (array_key_exists('brief', $fields)) { + $sets[] = 'brief = ?'; + $params[] = trim((string) $fields['brief']) ?: null; + } + if (isset($fields['body'])) { + $sets[] = 'body = ?'; + $params[] = (string) $fields['body']; + } + if (isset($fields['rating'])) { + $sets[] = 'rating = ?'; + $params[] = max(0, min(5, (int) $fields['rating'])); + } + if (isset($fields['owner_username'])) { + $sets[] = 'owner_username = ?'; + $params[] = mb_substr(trim((string) $fields['owner_username']), 0, 64); + } + if (array_key_exists('verified_by_username', $fields)) { + $v = trim((string) $fields['verified_by_username']); + $sets[] = 'verified_by_username = ?'; + $params[] = $v !== '' ? mb_substr($v, 0, 64) : null; + } + if (isset($fields['workflow_state']) && self::hasTicketColumn('workflow_state')) { + $state = TicketWorkflow::normalize((string) $fields['workflow_state']); + $sets[] = 'workflow_state = ?'; + $params[] = $state; + $tags = TicketWorkflow::mergeTagsForState( + is_array($ticket['tags'] ?? null) ? $ticket['tags'] : [], + $state + ); + $sets[] = 'tags_json = ?'; + $params[] = json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + if (array_key_exists('assignees', $fields) && self::hasTicketColumn('assignees_json')) { + $assignees = self::normalizeAssignees($fields['assignees']); + $err = UserRepository::validateUsernames( + array_map(static fn (array $a): string => $a['username'], $assignees) + ); + if ($err !== null) { + throw new InvalidArgumentException($err); + } + $json = json_encode($assignees, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new RuntimeException('json_encode failed'); + } + $sets[] = 'assignees_json = ?'; + $params[] = $json; + } + if ($sets === []) { + return; + } + $now = (int) round(microtime(true) * 1000); + $sets[] = 'updated_at_ms = ?'; + $params[] = $now; + $params[] = $id; + $pdo = Database::pdo(); + $stmt = $pdo->prepare('UPDATE tickets SET ' . implode(', ', $sets) . ' WHERE id = ?'); + $stmt->execute($params); + } + + public static function listTagPresets(): array { + return TicketTagCatalog::presets(); + } + + private static function appendCompanyScope(string $column, array &$where, array &$params): void { + $allowed = Rbac::allowedCompanyIds(); + if ($allowed === null) { + return; + } + if ($allowed === []) { + $where[] = '0=1'; + return; + } + $placeholders = implode(',', array_fill(0, count($allowed), '?')); + $where[] = "$column IN ($placeholders)"; + foreach ($allowed as $id) { + $params[] = $id; + } + } + + private static function hasTicketColumn(string $column): bool { + if (self::$ticketColumns === null) { + self::$ticketColumns = Database::columnNames(Database::pdo(), 'tickets'); + } + return in_array($column, self::$ticketColumns, true); + } + + /** @param array $row */ + private static function mapListRow(array $row): array { + $tags = parse_report_tags($row['tags_json'] ?? '[]'); + $pkg = (string) ($row['app_package'] ?? 'com.foxx.androidcast'); + $ver = (string) ($row['app_version'] ?? ''); + $os = trim(((string) ($row['os_name'] ?? '')) . ' ' . ((string) ($row['os_version'] ?? ''))); + return [ + 'id' => (int) $row['id'], + 'ticket_id' => (string) $row['ticket_id'], + 'title' => (string) $row['title'], + 'brief' => (string) ($row['brief'] ?? ''), + 'opened_at_ms' => (int) $row['opened_at_ms'], + 'updated_at_ms' => (int) $row['updated_at_ms'], + 'owner_username' => (string) ($row['owner_username'] ?? ''), + 'verified_by_username' => (string) ($row['verified_by_username'] ?? ''), + 'device_model' => (string) ($row['device_model'] ?? ''), + 'app_package' => $pkg, + 'app_version' => $ver, + 'os_name' => (string) ($row['os_name'] ?? ''), + 'os_version' => (string) ($row['os_version'] ?? ''), + 'env_brief' => ticket_env_brief($pkg, $ver, (string) ($row['os_name'] ?? ''), (string) ($row['os_version'] ?? '')), + 'rating' => (int) ($row['rating'] ?? 0), + 'tags' => $tags, + 'workflow_state' => TicketWorkflow::normalize((string) ($row['workflow_state'] ?? 'triage')), + 'assignees' => self::parseAssigneesJson($row['assignees_json'] ?? '[]'), + ]; + } + + /** @param array $row */ + private static function mapDetailRow(array $row): array { + $list = self::mapListRow($row); + $payload = json_decode($row['payload_json'] ?? '', true); + $list['body'] = (string) ($row['body'] ?? ''); + $list['created_at_ms'] = (int) ($row['created_at_ms'] ?? 0); + $list['payload'] = is_array($payload) ? $payload : []; + $list['can_edit'] = Auth::canEditTicket($list); + try { + $list['comments'] = TicketCommentRepository::listForTicket((int) $row['id']); + } catch (Throwable $e) { + $list['comments'] = []; + } + try { + $list['attachments'] = TicketAttachmentRepository::listForTicket((int) $row['id']); + } catch (Throwable $e) { + $list['attachments'] = []; + } + return $list; + } + + /** @return list */ + public static function parseAssigneesJson(string $json): array { + $data = json_decode($json, true); + if (!is_array($data)) { + return []; + } + return self::normalizeAssignees($data); + } + + /** + * @param mixed $raw + * @return list + */ + public static function normalizeAssignees($raw): array { + if (!is_array($raw)) { + return []; + } + $out = []; + $seen = []; + foreach ($raw as $item) { + if (is_string($item)) { + $item = ['username' => $item, 'role' => '']; + } + if (!is_array($item)) { + continue; + } + $user = trim((string) ($item['username'] ?? '')); + if ($user === '') { + continue; + } + $key = strtolower($user); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $out[] = [ + 'username' => mb_substr($user, 0, 64), + 'role' => mb_substr(trim((string) ($item['role'] ?? '')), 0, 32), + ]; + } + return $out; + } +} + +function ticket_format_os_version(array $device): string { + $rel = (string) ($device['release'] ?? ''); + $sdk = (int) ($device['sdk_int'] ?? 0); + if ($rel !== '' && $sdk > 0) { + return $rel . ' (API ' . $sdk . ')'; + } + if ($rel !== '') { + return $rel; + } + if ($sdk > 0) { + return 'API ' . $sdk; + } + return ''; +} + +function ticket_env_brief(string $pkg, string $version, string $osName, string $osVersion): string { + $app = str_contains($pkg, 'androidcast') ? 'Android Cast' : ($pkg !== '' ? $pkg : 'app'); + $line = trim($app . ($version !== '' ? ' ' . $version : '')); + $os = trim($osName . ($osVersion !== '' ? ' · ' . $osVersion : '')); + return trim($line . ($os !== '' ? ' · ' . $os : '')); +} diff --git a/sim/cluster0/lab-seeds/backend/src/TicketTagCatalog.php b/sim/cluster0/lab-seeds/backend/src/TicketTagCatalog.php new file mode 100644 index 0000000..1c9758e --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/TicketTagCatalog.php @@ -0,0 +1,109 @@ + */ + public const META_IDS = ['ticket', 'issue']; + + /** @var array */ + private const META = [ + 'ticket' => ['label' => 'ticket', 'bg' => '#6366f1'], + 'issue' => ['label' => 'issue', 'bg' => '#0d9488'], + ]; + + /** @var array */ + private const STATUS = [ + 'open' => ['label' => 'open', 'bg' => '#22c55e'], + 'in-progress' => ['label' => 'in progress', 'bg' => '#3b82f6'], + 'blocked' => ['label' => 'blocked', 'bg' => '#b45309'], + 'wont-fix' => ['label' => "won't fix", 'bg' => '#6b7280'], + 'closed' => ['label' => 'closed', 'bg' => '#6b7280'], + 'rejected' => ['label' => 'rejected', 'bg' => '#b91c1c'], + 'confirmed' => ['label' => 'confirmed', 'bg' => '#047857'], + 'postponed' => ['label' => 'postponed', 'bg' => '#a16207'], + 'urgent' => ['label' => 'urgent', 'bg' => '#dc2626'], + ]; + + /** @return list */ + public static function statusIds(): array { + return array_keys(self::STATUS); + } + + /** @return list */ + public static function presets(): array { + $out = []; + foreach (self::META as $id => $def) { + $out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']]; + } + foreach (self::STATUS as $id => $def) { + $out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']]; + } + return $out; + } + + public static function isMetaId(string $id): bool { + return isset(self::META[$id]); + } + + /** + * @param list $tags + * @return list + */ + public static function normalize(array $tags): array { + $normalized = normalize_report_tags($tags); + $metaId = null; + $hasStatus = false; + $rest = []; + foreach ($normalized as $t) { + if (self::isMetaId($t['id'])) { + $metaId = $t['id']; + continue; + } + if (isset(self::STATUS[$t['id']])) { + $hasStatus = true; + } + $rest[] = $t; + } + if ($metaId === null) { + $metaId = self::META_ID; + } + $meta = self::META[$metaId]; + $out = [ + ['id' => $metaId, 'label' => $meta['label'], 'bg' => $meta['bg']], + ]; + foreach ($rest as $t) { + $out[] = $t; + } + if (!$hasStatus) { + $out[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']; + } + return $out; + } + + public static function validate(array $tags): ?string { + $normalized = normalize_report_tags($tags); + $metaCount = 0; + $hasStatus = false; + foreach ($normalized as $t) { + if (self::isMetaId($t['id'])) { + $metaCount++; + } + if (isset(self::STATUS[$t['id']])) { + $hasStatus = true; + } + } + if ($metaCount === 0) { + return 'tags must include a type tag (ticket or issue)'; + } + if ($metaCount > 1) { + return 'only one type tag allowed: ticket or issue'; + } + if (!$hasStatus) { + return 'tags must include a status tag (open, in-progress, closed, …)'; + } + return null; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/TicketWorkflow.php b/sim/cluster0/lab-seeds/backend/src/TicketWorkflow.php new file mode 100644 index 0000000..7954480 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/TicketWorkflow.php @@ -0,0 +1,84 @@ + */ + public static function all(): array { + return [ + self::TRIAGE, + self::IN_PROGRESS, + self::BLOCKED, + self::WONT_FIX, + self::DONE, + self::CANCELLED, + ]; + } + + public static function normalize(string $state): string { + $s = strtolower(trim($state)); + return in_array($s, self::all(), true) ? $s : self::TRIAGE; + } + + /** @return array{id:string,label:string,bg:string} */ + public static function labelDef(string $state): array { + $map = [ + self::TRIAGE => ['id' => self::TRIAGE, 'label' => 'triage', 'bg' => '#64748b'], + self::IN_PROGRESS => ['id' => self::IN_PROGRESS, 'label' => 'in progress', 'bg' => '#3b82f6'], + self::BLOCKED => ['id' => self::BLOCKED, 'label' => 'blocked', 'bg' => '#b45309'], + self::WONT_FIX => ['id' => self::WONT_FIX, 'label' => "won't fix", 'bg' => '#6b7280'], + self::DONE => ['id' => self::DONE, 'label' => 'done', 'bg' => '#047857'], + self::CANCELLED => ['id' => self::CANCELLED, 'label' => 'cancelled', 'bg' => '#b91c1c'], + ]; + return $map[self::normalize($state)] ?? $map[self::TRIAGE]; + } + + /** Map workflow to legacy status tag id for tag bar sync. */ + public static function statusTagId(string $state): string { + return match (self::normalize($state)) { + self::IN_PROGRESS => 'in-progress', + self::BLOCKED => 'blocked', + self::WONT_FIX => 'wont-fix', + self::DONE => 'closed', + self::CANCELLED => 'rejected', + default => 'open', + }; + } + + /** + * Replace workflow/status tags; keep meta + custom tags. + * + * @param list $tags + * @return list + */ + public static function mergeTagsForState(array $tags, string $state): array { + $keep = []; + $statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all()); + foreach (normalize_report_tags($tags) as $t) { + if (TicketTagCatalog::isMetaId($t['id'])) { + $keep[] = $t; + continue; + } + if (in_array($t['id'], $statusIds, true)) { + continue; + } + $keep[] = $t; + } + $sid = self::statusTagId($state); + $preset = TicketTagCatalog::presets(); + foreach ($preset as $p) { + if ($p['id'] === $sid) { + $keep[] = $p; + break; + } + } + return TicketTagCatalog::normalize($keep); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/UrlShortenerDatabase.php b/sim/cluster0/lab-seeds/backend/src/UrlShortenerDatabase.php new file mode 100644 index 0000000..552f466 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/UrlShortenerDatabase.php @@ -0,0 +1,133 @@ +|null|false */ + private static $resolvedConfig = null; + + /** @return array|null */ + private static function resolveConfig(): ?array { + if (self::$resolvedConfig !== null) { + return self::$resolvedConfig === false ? null : self::$resolvedConfig; + } + $block = cfg('url_shortener', null); + if (is_array($block)) { + if (!empty($block['enabled']) && is_array($block['db']['mysql'] ?? null)) { + self::$resolvedConfig = $block; + return $block; + } + if (is_array($block['db']['mysql'] ?? null) && ($block['db']['mysql']['database'] ?? '') !== '') { + $block['enabled'] = true; + self::$resolvedConfig = $block; + return $block; + } + } + $crashDb = cfg('db.mysql', []); + if (!is_array($crashDb) || ($crashDb['password'] ?? '') === '') { + self::$resolvedConfig = false; + return null; + } + $m = $crashDb; + $m['database'] = 'url_shortener'; + $candidate = [ + 'enabled' => true, + 'public_base' => 'https://s.f0xx.org', + 'db' => ['mysql' => $m], + ]; + if (!self::pingMysql($m)) { + self::$resolvedConfig = false; + return null; + } + self::$resolvedConfig = $candidate; + return $candidate; + } + + /** @param array $m */ + private static function pingMysql(array $m): bool { + try { + $db = (string) ($m['database'] ?? ''); + if ($db === '') { + return false; + } + $charset = (string) ($m['charset'] ?? 'utf8mb4'); + $socket = trim((string) ($m['socket'] ?? '')); + if ($socket !== '') { + $dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset); + } else { + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $m['host'] ?? '127.0.0.1', + (int) ($m['port'] ?? 3306), + $db, + $charset + ); + } + $pdo = new PDO($dsn, (string) ($m['username'] ?? ''), (string) ($m['password'] ?? ''), [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + $pdo->query('SELECT 1'); + return true; + } catch (Throwable) { + return false; + } + } + + public static function enabled(): bool { + return self::resolveConfig() !== null; + } + + public static function publicBase(): string { + $block = self::resolveConfig(); + if ($block !== null) { + return rtrim((string) ($block['public_base'] ?? 'https://s.f0xx.org'), '/'); + } + return rtrim((string) cfg('url_shortener.public_base', 'https://s.f0xx.org'), '/'); + } + + public static function pdo(): PDO { + if (!self::enabled()) { + throw new RuntimeException('url_shortener not configured'); + } + if (self::$pdo !== null) { + return self::$pdo; + } + $block = self::resolveConfig(); + $m = is_array($block) ? ($block['db']['mysql'] ?? []) : []; + if (!is_array($m) || ($m['database'] ?? '') === '') { + throw new RuntimeException('url_shortener.db.mysql not configured'); + } + $db = (string) $m['database']; + $charset = (string) ($m['charset'] ?? 'utf8mb4'); + $socket = trim((string) ($m['socket'] ?? '')); + if ($socket !== '') { + $dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset); + } else { + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $m['host'] ?? '127.0.0.1', + (int) ($m['port'] ?? 3306), + $db, + $charset + ); + } + self::$pdo = new PDO($dsn, (string) ($m['username'] ?? ''), (string) ($m['password'] ?? ''), [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + return self::$pdo; + } + + public static function ping(): bool { + if (!self::enabled()) { + return false; + } + try { + self::pdo()->query('SELECT 1'); + return true; + } catch (Throwable) { + return false; + } + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/UserRepository.php b/sim/cluster0/lab-seeds/backend/src/UserRepository.php new file mode 100644 index 0000000..d72d808 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/UserRepository.php @@ -0,0 +1,44 @@ + */ + public static function listForAssignees(): array { + $pdo = Database::pdo(); + $stmt = $pdo->query('SELECT username, role FROM users ORDER BY username ASC'); + $out = []; + foreach ($stmt->fetchAll() as $row) { + $out[] = [ + 'username' => (string) $row['username'], + 'role' => (string) ($row['role'] ?? 'viewer'), + ]; + } + return $out; + } + + /** @param list $usernames */ + public static function validateUsernames(array $usernames): ?string { + $want = []; + foreach ($usernames as $u) { + $u = trim((string) $u); + if ($u !== '') { + $want[strtolower($u)] = $u; + } + } + if ($want === []) { + return null; + } + $pdo = Database::pdo(); + $stmt = $pdo->query('SELECT username FROM users'); + $have = []; + foreach ($stmt->fetchAll() as $row) { + $have[strtolower((string) $row['username'])] = true; + } + foreach ($want as $lower => $orig) { + if (!isset($have[$lower])) { + return 'unknown user: ' . $orig; + } + } + return null; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/WireGuardAddressPool.php b/sim/cluster0/lab-seeds/backend/src/WireGuardAddressPool.php new file mode 100644 index 0000000..8abaeca --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/WireGuardAddressPool.php @@ -0,0 +1,25 @@ +/dev/null') ?: ''); + return $pub; + } + + public static function addClientPeer(string $clientPublicKey, string $clientAddressCidr): bool { + if (!self::isEnabled()) { + return true; + } + $pub = trim($clientPublicKey); + if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) { + return false; + } + $iface = self::interfaceName(); + $allowed = trim($clientAddressCidr); + if ($allowed === '') { + $allowed = WireGuardAddressPool::allocateClientAddress('default'); + } + $code = self::runWg( + 'set ' . escapeshellarg($iface) + . ' peer ' . escapeshellarg($pub) + . ' allowed-ips ' . escapeshellarg($allowed) + ); + return $code === 0; + } + + public static function removeClientPeer(?string $clientPublicKey): void { + if (!self::isEnabled()) { + return; + } + $pub = trim((string) $clientPublicKey); + if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) { + return; + } + $iface = self::interfaceName(); + self::runWg( + 'set ' . escapeshellarg($iface) + . ' peer ' . escapeshellarg($pub) + . ' remove' + ); + } + + /** @return list */ + public static function listPeerDumpRows(): array { + if (!self::isEnabled()) { + return []; + } + $iface = self::interfaceName(); + $prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t"); + $cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg show ' . escapeshellarg($iface) . ' dump 2>/dev/null'; + $raw = @shell_exec($cmd); + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $rows = []; + foreach (preg_split('/\r\n|\n|\r/', trim($raw)) as $line) { + if ($line === '' || str_starts_with($line, 'interface:')) { + continue; + } + $cols = explode("\t", $line); + if (count($cols) < 4) { + continue; + } + $pub = trim($cols[0]); + if ($pub === '') { + continue; + } + $rows[] = [ + 'public_key' => $pub, + 'allowed_ips' => trim((string) ($cols[3] ?? '')), + ]; + } + return $rows; + } + + /** + * Remove live wg peers not tied to pending/active sessions (or with empty AllowedIPs). + * + * @param list $keepPublicKeys + * @return list + */ + public static function pruneOrphanPeers(array $keepPublicKeys, bool $dryRun = false): array { + if (!self::isEnabled()) { + return []; + } + $keep = []; + foreach ($keepPublicKeys as $key) { + $pub = trim((string) $key); + if ($pub !== '') { + $keep[$pub] = true; + } + } + $pruned = []; + foreach (self::listPeerDumpRows() as $row) { + $pub = $row['public_key']; + $allowed = trim($row['allowed_ips']); + $broken = ($allowed === '' || $allowed === '(none)'); + $orphan = !isset($keep[$pub]); + if (!$orphan && !$broken) { + continue; + } + if (!$dryRun) { + self::removeClientPeer($pub); + } + $pruned[] = [ + 'public_key' => $pub, + 'allowed_ips' => $allowed, + 'reason' => $broken ? 'broken_allowed_ips' : 'orphan', + ]; + } + return $pruned; + } + + private static function runWg(string $args): int { + $prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t"); + $cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg ' . $args; + exec($cmd . ' 2>&1', $out, $code); + if ($code !== 0) { + error_log('WireGuardPeerProvisioner: ' . $cmd . ' failed: ' . implode(' ', $out)); + } + return $code; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/WireGuardTrafficStats.php b/sim/cluster0/lab-seeds/backend/src/WireGuardTrafficStats.php new file mode 100644 index 0000000..8cf136f --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/WireGuardTrafficStats.php @@ -0,0 +1,73 @@ + */ + public static function peerMap(): array { + static $cache = null; + static $cacheAt = 0; + if ($cache !== null && (time() - $cacheAt) < 5) { + return $cache; + } + $cache = self::loadPeerMap(); + $cacheAt = time(); + return $cache; + } + + /** @return array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}|null */ + public static function forPublicKey(string $publicKey): ?array { + $pub = trim($publicKey); + if ($pub === '') { + return null; + } + return self::peerMap()[$pub] ?? null; + } + + /** @return array */ + private static function loadPeerMap(): array { + $iface = (string) cfg('remote_access.wg_interface', 'wg0'); + $prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t"); + $cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg show ' . escapeshellarg($iface) . ' dump 2>/dev/null'; + $raw = @shell_exec($cmd); + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $out = []; + foreach (preg_split('/\r\n|\n|\r/', trim($raw)) as $line) { + if ($line === '' || str_starts_with($line, 'interface:')) { + continue; + } + $cols = explode("\t", $line); + if (count($cols) < 7) { + continue; + } + $pub = trim($cols[0]); + if ($pub === '') { + continue; + } + $out[$pub] = [ + 'rx_bytes' => (int) ($cols[5] ?? 0), + 'tx_bytes' => (int) ($cols[6] ?? 0), + 'latest_handshake' => (int) ($cols[4] ?? 0), + 'endpoint' => trim((string) ($cols[2] ?? '')), + ]; + } + return $out; + } + + public static function formatBytes(int $bytes): string { + if ($bytes < 1024) { + return $bytes . ' B'; + } + if ($bytes < 1024 * 1024) { + return round($bytes / 1024, 1) . ' KiB'; + } + if ($bytes < 1024 * 1024 * 1024) { + return round($bytes / (1024 * 1024), 2) . ' MiB'; + } + return round($bytes / (1024 * 1024 * 1024), 2) . ' GiB'; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/bootstrap.php b/sim/cluster0/lab-seeds/backend/src/bootstrap.php new file mode 100644 index 0000000..ea17cf0 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/bootstrap.php @@ -0,0 +1,618 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 45 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 13f47a5da844f6ab0e4de9a934ec4e3a3892401c5b8b43123aa1539131b1ab48 + */ +declare(strict_types=1); + +require_once dirname(__DIR__, 3) . '/platform/shared_session.php'; +require_once dirname(__DIR__, 3) . '/platform/footer.php'; + +$configPath = __DIR__ . '/../config/config.php'; +if (!is_file($configPath)) { + $configPath = __DIR__ . '/../config/config.example.php'; +} +$config = require $configPath; + +$sessionName = $config['session_name'] ?? 'ac_crash_sess'; +$sessionPath = $config['session_cookie_path'] ?? '/app/androidcast_project'; + +if (!function_exists('session_start')) { + if (PHP_SAPI !== 'cli') { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + $phpV = PHP_VERSION; + echo "PHP session extension is not loaded (SAPI=" . PHP_SAPI . ", PHP {$phpV}).\n" + . "CLI check: php -m | grep -i session\n" + . " php -v # match package major (php81-* vs php82-*)\n" + . "Alpine: apk add php81-session php81-pdo # or php82-session for PHP 8.2\n" + . " rc-service php-fpm81 restart\n" + . "Debian: apt install php-session\n"; + exit(1); + } + // CLI cron (purge_remote_access.php, verify): PDO only; no web login cookie. +} elseif (PHP_SAPI !== 'cli') { + platform_start_session($sessionName, $sessionPath); +} + +require_once __DIR__ . '/Database.php'; +require_once __DIR__ . '/Rbac.php'; +require_once __DIR__ . '/DeviceRepository.php'; +require_once __DIR__ . '/Auth.php'; +require_once __DIR__ . '/AuthEmailSchema.php'; +require_once __DIR__ . '/AuthMailer.php'; +require_once __DIR__ . '/AuthRegistration.php'; +require_once __DIR__ . '/AuthCrypto.php'; +require_once __DIR__ . '/AuthTotp.php'; +require_once __DIR__ . '/AuthFactors.php'; +require_once __DIR__ . '/AuthAttempts.php'; +require_once __DIR__ . '/ReportRepository.php'; +require_once __DIR__ . '/TagCatalog.php'; +require_once __DIR__ . '/TicketTagCatalog.php'; +require_once __DIR__ . '/TicketWorkflow.php'; +require_once __DIR__ . '/TicketAttachmentProvider.php'; +require_once __DIR__ . '/TicketAttachmentRepository.php'; +require_once __DIR__ . '/TicketCommentRepository.php'; +require_once __DIR__ . '/UserRepository.php'; +require_once __DIR__ . '/TicketRepository.php'; +require_once __DIR__ . '/GraphRepository.php'; +require_once __DIR__ . '/RemoteAccessRepository.php'; +require_once __DIR__ . '/LiveCastRepository.php'; +require_once __DIR__ . '/LiveCastSignalingRepository.php'; +require_once __DIR__ . '/LiveCastSignalingRepository.php'; +require_once __DIR__ . '/UrlShortenerDatabase.php'; +require_once __DIR__ . '/ShortLinksRepository.php'; +require_once __DIR__ . '/WireGuardPeerProvisioner.php'; +require_once __DIR__ . '/WireGuardAddressPool.php'; +require_once __DIR__ . '/WireGuardTrafficStats.php'; +require_once __DIR__ . '/RsshSessionProvisioner.php'; +require_once __DIR__ . '/RsshBastionProvisioner.php'; +require_once __DIR__ . '/AuthTwoFactorPage.php'; +require_once __DIR__ . '/AnalyticsHead.php'; + +function cfg(string $key, $default = null) { + global $config; + $parts = explode('.', $key); + $v = $config; + foreach ($parts as $p) { + if (!is_array($v) || !array_key_exists($p, $v)) { + return $default; + } + $v = $v[$p]; + } + return $v; +} + +function h(mixed $s): string { + if ($s === null || $s === false) { + return ''; + } + return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +/** json_encode that never returns false (substitutes bad UTF-8). */ +function safe_json_encode(mixed $data, int $flags = 0): string { + $flags |= JSON_INVALID_UTF8_SUBSTITUTE; + $json = json_encode($data, $flags); + return is_string($json) ? $json : 'null'; +} + +function json_out(array $data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; +} + +/** Resolve web route: auth pages at project root; console pages under base_path. */ +function resolve_console_route(string $uri): string { + $projectRoot = rtrim((string) cfg('project_base_path', '/app/androidcast_project'), '/'); + $base = rtrim((string) cfg('base_path', ''), '/'); + if ($projectRoot !== '' && str_starts_with($uri, $projectRoot)) { + $suffix = substr($uri, strlen($projectRoot)) ?: '/'; + $candidate = rtrim(strtok($suffix, '?') ?: '/', '/') ?: '/'; + if (preg_match('#^/(login|logout|register|two-factor|verify-email)(?:/|$)#', $candidate)) { + return $candidate; + } + } + if ($base !== '' && str_starts_with($uri, $base)) { + $uri = substr($uri, strlen($base)) ?: '/'; + } + return rtrim(strtok($uri, '?') ?: '/', '/') ?: '/'; +} + +/** Public URL prefix for the active console view (graphs vs crashes). */ +function console_base_path(?string $view = null): string { + $view = $view ?? (string) ($_GET['view'] ?? ''); + if ($view === 'graphs') { + $graphs = trim((string) cfg('graphs_base_path', '')); + if ($graphs !== '') { + return rtrim($graphs, '/'); + } + $crashes = rtrim((string) cfg('base_path', ''), '/'); + if (str_ends_with($crashes, '/issues')) { + return substr($crashes, 0, -strlen('/issues')) . '/graphs'; + } + if (str_ends_with($crashes, '/crashes')) { + return substr($crashes, 0, -strlen('/crashes')) . '/graphs'; + } + return '/app/androidcast_project/graphs'; + } + return rtrim((string) cfg('base_path', ''), '/'); +} + +/** + * Raw POST body for crash ingest. Supports plain JSON or Content-Encoding: gzip + * (when nginx gunzip is off or the client talks to PHP directly). + * + * @return string|null decoded JSON text, null if empty, false if gzip decode failed + */ +function read_crash_upload_body(): string|false|null { + $raw = file_get_contents('php://input'); + if ($raw === false || $raw === '') { + return null; + } + $encoding = $_SERVER['HTTP_CONTENT_ENCODING'] ?? ''; + if ($encoding !== '' && stripos($encoding, 'gzip') !== false) { + $decoded = @gzdecode($raw); + return $decoded === false ? false : $decoded; + } + return $raw; +} + +/** Short lines for expandable list preview (not full report detail). */ +function report_brief_lines(array $row, bool $grouped): array { + if ($grouped) { + return array_filter([ + sprintf('%d reports · %s', (int) ($row['cnt'] ?? 0), $row['crash_type'] ?? ''), + 'Fingerprint: ' . ($row['fingerprint'] ?? ''), + trim(sprintf( + '%s · app %s', + $row['device_model'] ?? '', + $row['app_version'] ?? '' + ), ' ·'), + ]); + } + $p = json_decode($row['payload_json'] ?? '', true); + if (!is_array($p)) { + $p = []; + } + $lines = [ + sprintf( + '%s · %s · %s', + $row['crash_type'] ?? ($p['crash_type'] ?? ''), + $p['scenario'] ?? '—', + substr($row['fingerprint'] ?? ($p['fingerprint'] ?? ''), 0, 16) + ), + trim(sprintf( + '%s %s · %s v%s', + $p['device']['manufacturer'] ?? '', + $row['device_model'] ?? ($p['device']['model'] ?? ''), + $p['app']['package'] ?? 'com.foxx.androidcast', + $row['app_version'] ?? ($p['app']['version_name'] ?? '') + )), + ]; + if (!empty($p['java'])) { + $j = $p['java']; + $exc = trim(($j['exception'] ?? '') . (!empty($j['message']) ? ': ' . $j['message'] : '')); + $lines[] = $exc !== '' ? $exc : 'Java crash'; + $frames = $j['stack_frames'] ?? []; + if (!empty($frames[0])) { + $lines[] = (string) $frames[0]; + } + } elseif (!empty($p['native'])) { + $lines[] = 'Native: ' . ($p['native']['signal'] ?? 'crash'); + $bt = $p['native']['backtrace'] ?? []; + if (!empty($bt[0])) { + $lines[] = (string) $bt[0]; + } + } + if (!empty($p['runtime_context']['last_activity'])) { + $lines[] = 'Activity: ' . $p['runtime_context']['last_activity']; + } + return array_values(array_filter($lines, static fn ($l) => trim($l) !== '' && trim($l) !== '·')); +} + +/** @return array{label:string,bg:string} */ +function crash_kind_tag(string $crashType): array { + $t = strtolower($crashType); + return match ($t) { + 'native' => ['label' => 'NDK', 'bg' => '#b45309'], + 'java' => ['label' => 'Java', 'bg' => '#6d28d9'], + 'android' => ['label' => 'Android', 'bg' => '#047857'], + default => ['label' => ucfirst($t ?: '?'), 'bg' => '#5c6b82'], + }; +} + +/** Human-readable ABI list from device payload. */ +function format_device_abis(mixed $abis): string { + if (!is_array($abis)) { + return is_scalar($abis) ? trim((string) $abis) : ''; + } + $parts = []; + foreach ($abis as $abi) { + if (is_scalar($abi)) { + $s = trim((string) $abi); + if ($s !== '') { + $parts[] = $s; + } + } + } + return implode(', ', $parts); +} + +function device_display_name(array $device): string { + return trim(((string) ($device['manufacturer'] ?? '')) . ' ' . ((string) ($device['model'] ?? ''))); +} + +/** Shorter family name for “similar device” (drops last token when 3+ words). */ +function device_model_family(string $manufacturer, string $model): string { + $full = trim($manufacturer . ' ' . $model); + if ($full === '') { + return ''; + } + $parts = preg_split('/\s+/', $full) ?: []; + if (count($parts) <= 2) { + return $full; + } + return implode(' ', array_slice($parts, 0, -1)); +} + +/** Keys used to rank similar crashes (from payload). */ +function report_similarity_keys(array $payload): array { + $java = is_array($payload['java'] ?? null) ? $payload['java'] : []; + $native = is_array($payload['native'] ?? null) ? $payload['native'] : []; + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + $exc = (string) ($java['exception'] ?? ''); + $msg = (string) ($java['message'] ?? ''); + $topic = ''; + if ($msg !== '' && preg_match('/\b([A-Za-z][A-Za-z0-9_]*(?:Exception|Error))\b/', $msg, $m)) { + $topic = $m[1]; + } + return [ + 'fingerprint' => (string) ($payload['fingerprint'] ?? ''), + 'crash_type' => strtolower((string) ($payload['crash_type'] ?? '')), + 'exception' => $exc, + 'exception_topic' => $topic, + 'signal' => (string) ($native['signal'] ?? ''), + 'device_family' => device_model_family( + (string) ($device['manufacturer'] ?? ''), + (string) ($device['model'] ?? '') + ), + 'device_model' => (string) ($device['model'] ?? ''), + ]; +} + +/** Higher = more similar. */ +function report_similarity_score(array $anchorKeys, array $row, array $rowPayload): int { + $score = 0; + $fp = (string) ($row['fingerprint'] ?? $rowPayload['fingerprint'] ?? ''); + $ct = strtolower((string) ($row['crash_type'] ?? $rowPayload['crash_type'] ?? '')); + if ($anchorKeys['fingerprint'] !== '' && $fp === $anchorKeys['fingerprint']) { + $score += 1000; + } + if ($anchorKeys['crash_type'] !== '' && $ct === $anchorKeys['crash_type']) { + $score += 80; + } + $java = is_array($rowPayload['java'] ?? null) ? $rowPayload['java'] : []; + $native = is_array($rowPayload['native'] ?? null) ? $rowPayload['native'] : []; + $exc = (string) ($java['exception'] ?? ''); + if ($anchorKeys['exception'] !== '' && $exc !== '' && $exc === $anchorKeys['exception']) { + $score += 120; + } + if ($anchorKeys['exception_topic'] !== '') { + $msg = (string) ($java['message'] ?? ''); + if ($msg !== '' && stripos($msg, $anchorKeys['exception_topic']) !== false) { + $score += 60; + } + } + $sig = (string) ($native['signal'] ?? ''); + if ($anchorKeys['signal'] !== '' && $sig !== '' && $sig === $anchorKeys['signal']) { + $score += 100; + } + $device = is_array($rowPayload['device'] ?? null) ? $rowPayload['device'] : []; + $family = device_model_family( + (string) ($device['manufacturer'] ?? ''), + (string) ($device['model'] ?? '') + ); + if ($anchorKeys['device_family'] !== '' && $family !== '') { + if ($family === $anchorKeys['device_family']) { + $score += 50; + } elseif (str_starts_with($family, $anchorKeys['device_family']) + || str_starts_with($anchorKeys['device_family'], $family)) { + $score += 25; + } + } + $dm = (string) ($row['device_model'] ?? $device['model'] ?? ''); + if ($anchorKeys['device_model'] !== '' && $dm !== '' && $dm === $anchorKeys['device_model']) { + $score += 30; + } + return $score; +} + +/** OS product name (Android, Linux, …) — not device manufacturer. */ +function device_os_name(array $device): string { + if (!empty($device['os_name'])) { + return trim((string) $device['os_name']); + } + if (!empty($device['os'])) { + return trim((string) $device['os']); + } + $sdk = (int) ($device['sdk_int'] ?? 0); + $blob = strtolower(implode(' ', array_filter([ + (string) ($device['product'] ?? ''), + (string) ($device['device'] ?? ''), + (string) ($device['model'] ?? ''), + ]))); + if ($sdk > 0 || str_contains($blob, 'android')) { + return 'Android'; + } + if (str_contains($blob, 'linux')) { + return 'Linux'; + } + if (($device['release'] ?? '') !== '') { + return 'Android'; + } + return 'Android'; +} + +/** Enrich list row with OS fields and rating inputs (keeps payload_json until unset). */ +function report_enrich_list_row(array &$row, int $sinceMs = 0): void { + $p = json_decode($row['payload_json'] ?? '', true); + if (!is_array($p)) { + $p = []; + } + $d = $p['device'] ?? []; + $row['os_name'] = device_os_name($d); + $rel = (string) ($d['release'] ?? ''); + $sdk = (int) ($d['sdk_int'] ?? 0); + if ($rel !== '' && $sdk > 0) { + $row['os_version'] = $rel . ' (API ' . $sdk . ')'; + } elseif ($rel !== '') { + $row['os_version'] = $rel; + } elseif ($sdk > 0) { + $row['os_version'] = 'API ' . $sdk; + } else { + $row['os_version'] = ''; + } + $row['scenario'] = (string) ($p['scenario'] ?? ''); + $total = (int) ($row['fingerprint_cnt'] ?? 1); + $recent = (int) ($row['fingerprint_recent_cnt'] ?? 0); + if ($sinceMs > 0 && $recent === 0 && !empty($row['received_at_ms'])) { + $recent = (int) $row['received_at_ms'] > $sinceMs ? 1 : 0; + } + $row['rating'] = report_rating_score($total, $recent, (int) ($row['cnt'] ?? 0)); +} + +function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int $groupCnt = 0): int { + $t = $groupCnt > 0 ? $groupCnt : max(1, $fingerprintTotal); + $r = $fingerprintRecent; + $score = 0; + if ($t >= 40) { + $score = 5; + } elseif ($t >= 20) { + $score = 4; + } elseif ($t >= 10) { + $score = 3; + } elseif ($t >= 4) { + $score = 2; + } elseif ($t >= 2) { + $score = 1; + } + if ($r >= 8) { + $score = min(5, $score + 2); + } elseif ($r >= 3) { + $score = min(5, $score + 1); + } + return max(0, min(5, $score)); +} + +/** @return list */ +function search_query_tokens(string $q): array { + $q = mb_strtolower(trim($q)); + if ($q === '') { + return []; + } + $parts = preg_split('/\s+/u', $q) ?: []; + $out = []; + foreach ($parts as $part) { + $t = preg_replace('/[^a-z0-9_.-]+/i', '', $part) ?? ''; + if (strlen($t) >= 2) { + $out[] = $t; + } + } + return array_values(array_unique($out)); +} + +/** Lowercase blob for keyword matching. */ +function report_search_blob(array $row, array $payload): string { + $java = is_array($payload['java'] ?? null) ? $payload['java'] : []; + $native = is_array($payload['native'] ?? null) ? $payload['native'] : []; + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + $app = is_array($payload['app'] ?? null) ? $payload['app'] : []; + $build = is_array($payload['build'] ?? null) ? $payload['build'] : []; + $parts = [ + $row['crash_type'] ?? '', + $row['fingerprint'] ?? '', + $row['device_model'] ?? '', + $row['app_version'] ?? '', + $payload['scenario'] ?? '', + $java['exception'] ?? '', + $java['message'] ?? '', + $java['thread'] ?? '', + $native['signal'] ?? '', + $device['manufacturer'] ?? '', + $device['brand'] ?? '', + $device['model'] ?? '', + $device['product'] ?? '', + $app['package'] ?? '', + $app['version_name'] ?? '', + $build['git_commit'] ?? '', + $build['git'] ?? '', + ]; + if (!empty($java['stack_frames']) && is_array($java['stack_frames'])) { + $parts[] = implode(' ', array_map('strval', array_slice($java['stack_frames'], 0, 3))); + } + return mb_strtolower(implode(' ', array_filter(array_map('strval', $parts)))); +} + +/** + * @param list $tokens + */ +function report_search_score(array $tokens, array $row, array $payload): int { + if ($tokens === []) { + return 0; + } + $blob = report_search_blob($row, $payload); + $java = is_array($payload['java'] ?? null) ? $payload['java'] : []; + $exc = mb_strtolower((string) ($java['exception'] ?? '')); + $msg = mb_strtolower((string) ($java['message'] ?? '')); + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + $mfr = mb_strtolower((string) ($device['manufacturer'] ?? '')); + $brand = mb_strtolower((string) ($device['brand'] ?? '')); + $model = mb_strtolower((string) ($device['model'] ?? '')); + $score = 0; + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + if ($exc !== '' && str_contains($exc, $token)) { + $score += 220; + } elseif ($msg !== '' && str_contains($msg, $token)) { + $score += 140; + } elseif ($mfr !== '' && str_contains($mfr, $token)) { + $score += 70; + } elseif ($brand !== '' && str_contains($brand, $token)) { + $score += 65; + } elseif ($model !== '' && str_contains($model, $token)) { + $score += 60; + } elseif (str_contains($blob, $token)) { + $score += 45; + } + } + $rating = (int) ($row['rating'] ?? 0); + if ($rating > 0 && $score > 0) { + $score += $rating * 12; + } + $fpCnt = (int) ($row['fingerprint_cnt'] ?? 1); + if ($fpCnt > 1 && $score > 0) { + $score += min(40, (int) log($fpCnt + 1) * 10); + } + return $score; +} + +/** System tags — not stored in tags_json (computed in UI). */ +function report_reserved_tag_ids(): array { + return ['new', 'java', 'ndk', 'android']; +} + +function normalize_tag_color(string $bg): string { + $bg = trim($bg); + if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $bg) !== 1) { + return '#5c6b82'; + } + if (strlen($bg) === 4) { + return '#' . $bg[1] . $bg[1] . $bg[2] . $bg[2] . $bg[3] . $bg[3]; + } + return strtolower($bg); +} + +/** + * @param list $tags + * @return list + */ +function normalize_report_tags(array $tags): array { + $reserved = array_flip(report_reserved_tag_ids()); + $out = []; + $seen = []; + foreach ($tags as $tag) { + if (!is_array($tag)) { + continue; + } + $label = trim((string) ($tag['label'] ?? $tag['id'] ?? '')); + if ($label === '' || strlen($label) > 40) { + continue; + } + $id = trim((string) ($tag['id'] ?? $label)); + $id = strtolower(preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? $id); + $id = trim($id, '-'); + if ($id === '' || isset($reserved[$id])) { + continue; + } + if (isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $out[] = [ + 'id' => $id, + 'label' => $label, + 'bg' => normalize_tag_color((string) ($tag['bg'] ?? '#5c6b82')), + ]; + if (count($out) >= 24) { + break; + } + } + return $out; +} + +/** @return list */ +/** + * @return list + */ +function request_tag_filter_ids(): array { + $raw = []; + if (isset($_GET['tag']) && is_array($_GET['tag'])) { + $raw = $_GET['tag']; + } elseif (isset($_GET['tag']) && is_string($_GET['tag']) && $_GET['tag'] !== '') { + $raw = [$_GET['tag']]; + } + return TagCatalog::normalizeFilterIds($raw); +} + +function request_tag_filter_mode(): string { + $mode = strtolower(trim((string) ($_GET['tag_mode'] ?? 'and'))); + return $mode === 'or' ? 'or' : 'and'; +} + +/** @return list */ +function parse_report_tags(?string $json): array { + if ($json === null || $json === '') { + return []; + } + $raw = json_decode($json, true); + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $tag) { + if (!is_array($tag)) { + continue; + } + $id = (string) ($tag['id'] ?? $tag['label'] ?? ''); + if ($id === '') { + continue; + } + $out[] = [ + 'id' => $id, + 'label' => (string) ($tag['label'] ?? $id), + 'bg' => (string) ($tag['bg'] ?? '#5c6b82'), + ]; + } + return $out; +} + +function log_crash_event(string $channel, string $message): void { + $dir = dirname(__DIR__) . '/storage'; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + $line = date('c') . " [$channel] $message\n"; + @file_put_contents($dir . '/crash.log', $line, FILE_APPEND | LOCK_EX); +} diff --git a/sim/cluster0/lab-seeds/backend/views/account_security.php b/sim/cluster0/lab-seeds/backend/views/account_security.php new file mode 100644 index 0000000..0978b9d --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/account_security.php @@ -0,0 +1,69 @@ + + + + + + + Account security — <?= h(cfg('app_name')) ?> + + + + + + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/layout.php b/sim/cluster0/lab-seeds/backend/views/layout.php new file mode 100644 index 0000000..e91d811 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/layout.php @@ -0,0 +1,825 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 86 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a + */ +?> + + + + + + <?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + > + +
    + +
    + + + + + + + +
    +
    +

    Tickets

    +
    + + + + +
    +
    +

    Loading…

    +
    + + + + + + + + + + + + +
    IssueOpenedApp / OSRatingTags
    +
    + +
    + +
    +
    +

    Issues

    +
    + + + + + + +
    +
    +
    + Filter by tag +
    +
    + +
    + +
    + + +
    + +
    +

    Loading…

    +
    + + + + +
    +
    + +
    + +
    +
    +

    AndroidCast analytics

    +
    + + + +
    +
    + + +

    Loading…

    + +
    +

    Your activity

    +
    +

    Sessions / day

    +

    Unique devices / day

    +

    Avg duration (s) / day

    +

    Send sessions / day

    +

    Receive sessions / day

    +

    Success ratio

    +

    Outbound kbps / day

    +

    Inbound kbps / day

    +

    Issues / day

    +
    +
    + + + + + +
    +

    Live cast

    +
    +

    Live casts / day

    +

    Avg cast length (s) / day

    +

    Cast platforms

    +

    Join platforms

    +

    Video codecs

    +

    Audio codecs

    +

    Bandwidth modes

    +

    Top casters

    +
    +
    + + +
    + +
    +
    +

    Live cast sessions

    +
    + + Education demo +
    +
    + +

    Loading…

    +

    Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).

    +
    + + + + + + + + + + + + + + +
    StatusUserPlatformVideoDurationLast heartbeatJoin opensLink
    +
    +
    + +
    +
    +

    Remote access

    +
    + +
    +
    + +

    Loading…

    + +
    +

    Devices

    +

    + Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by last seen — top rows are actively polling. + Whitelist rows marked Needs whitelist (opted in, not yet allowed). + Stale rows have not polled in 7+ days. + Poll source IP is the address BE sees on the HTTP request (often a router or proxy on 10.7.x.x) — not the device WAN or LAN. +

    +
    + + + + +
    +
    +
    + + + +
    +
    + +
    +

    Active / pending sessions

    +
    + + + + + + + + + + + + +
    SessionDeviceStatusTunnelEndpointActions
    +
    +
    + +
    +

    Inactive / closed sessions

    +
    + + + + + + + + + + + +
    SessionDeviceStatusClosedReason
    +
    +
    + +
    +

    Audit log

    +
    + + + + + + + + + + +
    TimeDeviceActionReason
    +
    +
    +
    + + + +
    +
    +

    Access control

    +
    +

    Loading…

    +

    +
    +

    Users & company roles

    +
    + + + + + + + + + + + + +
    UserGlobal roleCompanyCompany rolePrivilege setAuth lockouts
    +
    +
    +

    + API: /api/rbac.php · + Root edits global roles; company owner/admin edits memberships and remote-access privilege sets. +

    +
    + +
    +
    + + + + +
    +
    +

    New ticket

    + +
    +
    + + + +
    +
    +

    +
    + + +
    +
    +
    +
    + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/live_education.php b/sim/cluster0/lab-seeds/backend/views/live_education.php new file mode 100644 index 0000000..c63bd94 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/live_education.php @@ -0,0 +1,50 @@ + + + + + + + Education demo — <?= h(cfg('app_name')) ?> + + + + + + + + +
    +
    + ← Back to the console + Education demo +
    +
    +

    Try screen sharing in your browser

    +

    Free demo up to 5 minutes. No install required — great for first-time visitors.

    +

    Ready when you are.

    +

    Time left: 5:00

    + + + +
    + + + +
    +

    + WebRTC P2P (no SFU): share the viewer link or QR. Same REST signaling API supports future mobile cast to browser on LAN. +

    +
    +
    + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/live_join.php b/sim/cluster0/lab-seeds/backend/views/live_join.php new file mode 100644 index 0000000..954dc0b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/live_join.php @@ -0,0 +1,47 @@ + + + + + + + Join live cast — <?= h(cfg('app_name')) ?> + + + + + + + + +
    +
    + ← Back to the console + Live viewer +
    +
    +

    Join live cast

    +

    Loading session…

    +
      +
      + +
      +
      + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/login.php b/sim/cluster0/lab-seeds/backend/views/login.php new file mode 100644 index 0000000..ddc7932 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/login.php @@ -0,0 +1,58 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 27 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 9bef983f4d34cb790922cda355ccf5d14b565b29273d2ebfe48b3173cd2e8f9f + */ +$bp = Auth::basePath(); +$auth = Auth::authUrl(); +?> + + + + + + Login — <?= h(cfg('app_name')) ?> + + + + + + + + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/partials/console_home_landing.php b/sim/cluster0/lab-seeds/backend/views/partials/console_home_landing.php new file mode 100644 index 0000000..b8d660a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/partials/console_home_landing.php @@ -0,0 +1,58 @@ + +
      +
      +

      Console

      +
      + +
      +
      +

      Issue triage, tickets, and session metrics — pick a workspace below.

      + + + +

      All services

      + +
      diff --git a/sim/cluster0/lab-seeds/backend/views/partials/console_quick_links.php b/sim/cluster0/lab-seeds/backend/views/partials/console_quick_links.php new file mode 100644 index 0000000..9106fd4 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/partials/console_quick_links.php @@ -0,0 +1,37 @@ + + * @var bool $skip_home_link omit "Console home" (use on home view) + */ +$bp = Auth::basePath(); +$proj = '/app/androidcast_project'; +$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : ''); +$items = []; +if (empty($skip_home_link)) { + $items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home']; +} +$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports']; +$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets']; +if (Rbac::can('remote_access_view')) { + $items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote']; +} +if (Rbac::can('short_links_view')) { + $items[] = ['Short links', $bp . '/?view=short_links', 'nav-icon--link']; +} +if (Rbac::canManageRbac()) { + $items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets']; +} +$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs']; +$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder']; +$items[] = ['Hub', $proj . '/', 'nav-icon--home']; +?> + diff --git a/sim/cluster0/lab-seeds/backend/views/partials/cookie_consent.php b/sim/cluster0/lab-seeds/backend/views/partials/cookie_consent.php new file mode 100644 index 0000000..0a0e5ed --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/partials/cookie_consent.php @@ -0,0 +1,15 @@ + + diff --git a/sim/cluster0/lab-seeds/backend/views/register.php b/sim/cluster0/lab-seeds/backend/views/register.php new file mode 100644 index 0000000..100252b --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/register.php @@ -0,0 +1,53 @@ + + + + + + + Register — <?= h(cfg('app_name')) ?> + + + + + + + + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/report_detail.php b/sim/cluster0/lab-seeds/backend/views/report_detail.php new file mode 100644 index 0000000..e9cbd17 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/report_detail.php @@ -0,0 +1,185 @@ + 0 + ? $bp . '/?view=reports&filter_os_sdk=' . $sdk + : ($release !== '' ? $bp . '/?view=reports&filter_os_release=' . rawurlencode($release) : ''); +$abiList = []; +if (!empty($device['abis']) && is_array($device['abis'])) { + foreach ($device['abis'] as $abi) { + if (is_scalar($abi) && trim((string) $abi) !== '') { + $abiList[] = trim((string) $abi); + } + } +} +?> +
      + + + +
      +

      Issue report

      +

      Report ·

      +
      +
      + +
      +
      +

      Tags

      +

      Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.

      +
      +
      + +
        +
        + + + +
        + +
        + + +
        + +

        Sign in as admin to edit custom tags.

        + +
        + +
        +

        Device

        + +

        + +

        + + +

        Android (SDK )

        + +

        Android (SDK )

        + + +

        ABIs: + $abi): ?> + 0): ?>, + + +

        + +
        +

        App

        +

        + + +

        v ()

        + +

        v ()

        + + + +

        Build:

        + +

        Build:

        + + +
        +

        Timing

        +

        Generated:

        +

        Received:

        +

        Fingerprint:

        +
        +
        + +
        +
        +

        Java exception

        + Find similar reports +
        +

        +

        Thread:

        +
        +
        + + +
        +
        +

        Native issue

        + Find similar reports +
        +

        Signal:

        +
        +
        + + +
        + +
        +
        + + + + Session context (issue) +
        + +
        + +
        +
        + + + + Raw JSON +
        + +
        +
        diff --git a/sim/cluster0/lab-seeds/backend/views/ticket_detail.php b/sim/cluster0/lab-seeds/backend/views/ticket_detail.php new file mode 100644 index 0000000..b2c34e2 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/ticket_detail.php @@ -0,0 +1,328 @@ +'; +} +?> +
        + + + +
        +

        Ticket

        +

        + ID + + · + +

        +
        +
        + +
        +
        +

        Tags

        +

        Include one type tag (ticket or issue) and a status tag (open, in progress, closed, …). Swap type via suggestions — remove the current type tag, then add the other.

        +
        +
        + +
          +
          + + + +
          + +
          + + +
          + +

          Only the owner or an admin can edit tags.

          + +
          + + + +
          +
          +

          Issue

          + + + +
          +
          +

          Workflow

          + + + +
          + Assignees +
          +
          + + +
          +
          + +

          + +

          +
          + + +
          +
          +
          + + +
          +

          + +

          + +
          +
          + + +
          +

          Device

          + +

          + +

          + +

          Android 0 ? ' (SDK ' . $sdk . ')' : '' ?>

          + +

          ABIs:

          + +
          +

          App

          +

          +

          v + + () + +

          +

          +
          +

          Timing

          +

          Opened: +

          +

          Created: +

          +

          Updated: +

          +
          + +

          Workflow

          +

          Lifecycle: +

          +

          Assignees: + + + + + + () + + +

          +

          Owner:

          +

          Verified by: +

          +

          Rating: +

          +
          + +
          + +
          +

          Attachments

          +
            + +
          • + + + + + + + ( KB) + + + + + + + + +
          • + +
          + +
          + +
          +

          Upload file

          +

          Max 16 MiB — images, logs, pdf, zip, …

          + + + + +
          +
          + +
          + +
          +

          Comments

          +
            + +
          • +
            + + +
            +
            +
          • + +
          + +
          + + + +
          + +
          + +
          + +
          +
          + + + + Source / context +
          + +
          + +
          +
          + + + + Raw JSON +
          + +
          +
          diff --git a/sim/cluster0/lab-seeds/backend/views/two_factor_challenge.php b/sim/cluster0/lab-seeds/backend/views/two_factor_challenge.php new file mode 100644 index 0000000..e3a96ea --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/two_factor_challenge.php @@ -0,0 +1,53 @@ + + + + + + + Two-factor — <?= h(cfg('app_name')) ?> + + + + + + + + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/verify_email.php b/sim/cluster0/lab-seeds/backend/views/verify_email.php new file mode 100644 index 0000000..d6f0871 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/verify_email.php @@ -0,0 +1,33 @@ + + + + + + + Verify email — <?= h(cfg('app_name')) ?> + + + + + + + + + diff --git a/sim/cluster0/nginx/apps-port80.conf b/sim/cluster0/nginx/apps-port80.conf index 1af3b39..64e9585 100644 --- a/sim/cluster0/nginx/apps-port80.conf +++ b/sim/cluster0/nginx/apps-port80.conf @@ -130,6 +130,25 @@ server { fastcgi_read_timeout 1800s; } + # --- Gitea web UI (lab browse; mirrors configured separately) --- + location = /app/androidcast_project/git { + return 301 /app/androidcast_project/git/; + } + location ^~ /app/androidcast_project/git/ { + proxy_pass http://127.0.0.1:3000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 1800s; + proxy_send_timeout 1800s; + client_max_body_size 512m; + } + location ~ \.php$ { include /etc/nginx/fastcgi.conf; fastcgi_pass 127.0.0.1:9000; diff --git a/sim/cluster0/populate_lab_setup.sh b/sim/cluster0/populate_lab_setup.sh index 22edd53..0b6d04d 100644 --- a/sim/cluster0/populate_lab_setup.sh +++ b/sim/cluster0/populate_lab_setup.sh @@ -77,6 +77,9 @@ run_phase_app() { if [ "${POSTGRES_SEMI_ENABLE:-0}" = "1" ] && is_primary_node; then run_script "$ROOT/scripts/deploy-postgres-semi.sh" || journal_warn postgres_semi "install failed (non-fatal)" fi + if [ "${GITEA_ENABLE:-0}" = "1" ] && is_primary_node; then + run_script "$ROOT/scripts/deploy-gitea.sh" || journal_warn gitea "install failed (non-fatal)" + fi run_script "$ROOT/scripts/configure-nginx.sh" rc-service php-fpm83 restart 2>/dev/null || true rc-service nginx reload 2>/dev/null || rc-service nginx restart 2>/dev/null || true diff --git a/sim/cluster0/scripts/compose-lab-backend.sh b/sim/cluster0/scripts/compose-lab-backend.sh index 0e4d95b..f40b585 100755 --- a/sim/cluster0/scripts/compose-lab-backend.sh +++ b/sim/cluster0/scripts/compose-lab-backend.sh @@ -7,7 +7,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" load_cluster_env WS="${AC_WORKSPACE_ROOT:-/var/www/ac/workspace}" -LEGACY="${APP_ROOT}/android_cast/examples/crash_reporter/backend" +LAB_SEED="${ROOT}/lab-seeds/backend" +MONOLITH_BACKEND="${APP_ROOT}/android_cast/examples/crash_reporter/backend" +if [ -d "${LAB_SEED}/public" ]; then + LEGACY="$LAB_SEED" +elif [ "${COMPOSE_SKIP_MONOLITH:-0}" = "1" ]; then + die "lab-seeds missing at ${LAB_SEED} and COMPOSE_SKIP_MONOLITH=1" +else + LEGACY="$MONOLITH_BACKEND" +fi COMPOSED="${COMPOSE_BACKEND:-/var/www/ac/composed/backend}" PLATFORM_ROOT="${AC_PLATFORM_ROOT:-/var/www/ac/platform}" APP_PASS="$(read_cred mariadb_app password)" @@ -45,8 +53,8 @@ overlay_views() { } if [ ! -f "${COMPOSED}/public/index.php" ] || [ "${FORCE_COMPOSE_SEED:-0}" = "1" ]; then - [ -d "$LEGACY/public" ] || die "legacy backend missing at $LEGACY — run deploy-app.sh first" - log "seed composed backend from legacy ${LEGACY}" + [ -d "$LEGACY/public" ] || die "backend seed missing at $LEGACY (lab-seeds or deploy-app.sh monolith)" + log "seed composed backend from ${LEGACY}" mkdir -p "$(dirname "$COMPOSED")" rm -rf "$COMPOSED" mkdir -p "$COMPOSED" @@ -93,6 +101,17 @@ overlay_views ac-be-remote-access overlay_views ac-be-access ISSUES_BASE="${APP_BASE_PATH:-/app/androidcast_project/issues}" +PUBLIC_ORIGIN_CFG="${LAB_PUBLIC_ORIGIN:-https://acl0.f0xx.org}" +load_secrets_lab +AUTH_KEY="${AUTH_ENCRYPTION_KEY:-lab-cluster-dev-32-char-min-secret!!}" +MAIL_TRANSPORT="${MAIL_TRANSPORT:-smtp}" +MAIL_FROM="${MAIL_FROM:-Android Cast Lab }" +MAIL_REPLY="${MAIL_REPLY_TO:-info@apps.f0xx.org}" +SMTP_HOST="${SMTP_HOST:-127.0.0.1}" +SMTP_PORT="${SMTP_PORT:-587}" +SMTP_ENC="${SMTP_ENCRYPTION:-tls}" +SMTP_USER="${SMTP_USER:-}" +SMTP_PASS="${SMTP_PASS:-}" mkdir -p "${COMPOSED}/config" log "write composed config.php base_path=${ISSUES_BASE}" cat > "${COMPOSED}/config/config.php" < "${COMPOSED}/config/config.php" < 'Android Cast Issues', 'base_path' => '${ISSUES_BASE}', + 'public_origin' => '${PUBLIC_ORIGIN_CFG}', 'db' => [ 'driver' => 'mariadb', 'sqlite_path' => __DIR__ . '/../data/crashes.sqlite', @@ -125,10 +145,22 @@ return [ 'require_wg_tools' => false, ], 'auth' => [ - 'encryption_key' => 'lab-cluster-dev-32-char-min-secret!!', + 'encryption_key' => '${AUTH_KEY}', 'max_attempts' => 8, 'lockout_window_minutes' => 15, ], + 'mail' => [ + 'transport' => '${MAIL_TRANSPORT}', + 'from' => '${MAIL_FROM}', + 'reply_to' => '${MAIL_REPLY}', + 'smtp' => [ + 'host' => '${SMTP_HOST}', + 'port' => ${SMTP_PORT}, + 'encryption' => '${SMTP_ENC}', + 'username' => '${SMTP_USER}', + 'password' => '${SMTP_PASS}', + ], + ], 'url_shortener' => [ 'enabled' => true, 'public_base' => '${SHORT_LINKS_PUBLIC_BASE}', diff --git a/sim/cluster0/scripts/deploy-app.sh b/sim/cluster0/scripts/deploy-app.sh index 00fceea..ef2ddc1 100644 --- a/sim/cluster0/scripts/deploy-app.sh +++ b/sim/cluster0/scripts/deploy-app.sh @@ -6,6 +6,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" . "$ROOT/scripts/lib/common.sh" ensure_shared_mounted +if [ "${COMPOSE_SKIP_MONOLITH:-0}" = "1" ]; then + log "COMPOSE_SKIP_MONOLITH=1 — skip monolith git pull (use lab-seeds + compose-lab-backend.sh)" + exit 0 +fi + APP_PASS="$(read_cred mariadb_app password)" DB_HOST="$PRIMARY_DB_HOST" if is_primary_node; then diff --git a/sim/cluster0/scripts/deploy-gitea.sh b/sim/cluster0/scripts/deploy-gitea.sh new file mode 100755 index 0000000..f3f574f --- /dev/null +++ b/sim/cluster0/scripts/deploy-gitea.sh @@ -0,0 +1,128 @@ +#!/bin/sh +# Install and start Gitea on cluster primary (cast01) — config only, no repo mirrors yet. +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +load_cluster_env + +GITEA_DIR="${ROOT}/gitea" +TEMPLATE="${GITEA_DIR}/app.ini.template" +INI="/etc/gitea/app.ini" +GITEA_APP_DATA="${GITEA_APP_DATA:-/var/lib/gitea}" +GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}" +GITEA_DOMAIN="${GITEA_PUBLIC_HOST:-${ACL0_CNAME:-acl0.f0xx.org}}" +GITEA_ROOT_URL="${GITEA_ROOT_URL:-https://${GITEA_DOMAIN}/app/androidcast_project/git/}" +GITEA_DB_USER="${GITEA_DB_USER:-androidcast}" +GITEA_DB_PASS="$(read_cred mariadb_app password)" +GITEA_SECRETS="${GITEA_APP_DATA}/.lab-secrets.env" + +if [ "${GITEA_ENABLE:-0}" != "1" ]; then + log "GITEA_ENABLE!=1 — skip deploy-gitea" + exit 0 +fi + +if ! is_primary_node; then + log "deploy-gitea: skip on replica $(host_short) (Gitea runs on ${CAST01_HOST} only)" + exit 0 +fi + +gitea_stop_clean() { + rc-service gitea stop 2>/dev/null || true + sleep 2 + pkill -u gitea -f '/usr/bin/gitea web' 2>/dev/null || true + sleep 1 + rm -f "${GITEA_APP_DATA}/gitea.db-shm" "${GITEA_APP_DATA}/gitea.db-wal" 2>/dev/null || true +} + +gitea_wait_http() { + _port="${1:-$GITEA_HTTP_PORT}" + _i=0 + while [ "$_i" -lt 180 ]; do + if curl -fsS -m 2 -o /dev/null "http://127.0.0.1:${_port}/" 2>/dev/null; then + return 0 + fi + _i=$((_i + 1)) + sleep 2 + done + die "timeout waiting for gitea http://127.0.0.1:${_port}/" +} + +[ -f "$TEMPLATE" ] || die "missing $TEMPLATE" + +apk add --no-cache gitea 2>/dev/null || apk add --no-cache gitea + +mkdir -p "$GITEA_APP_DATA/data/gitea-repositories" "$GITEA_APP_DATA/lfs" /var/log/gitea /etc/gitea +chown -R gitea:www-data "$GITEA_APP_DATA" /var/log/gitea 2>/dev/null || true + +# Optional MariaDB backend (set GITEA_DB_TYPE=mysql in cluster.env). Default: sqlite3 for lab. +if [ "${GITEA_DB_TYPE:-sqlite3}" = "mysql" ]; then + mariadb -u root -e "CREATE DATABASE IF NOT EXISTS gitea CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null \ + || mariadb -e "CREATE DATABASE IF NOT EXISTS gitea CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mariadb -u root -e "GRANT ALL PRIVILEGES ON gitea.* TO '${GITEA_DB_USER}'@'localhost'; FLUSH PRIVILEGES;" 2>/dev/null \ + || mariadb -e "GRANT ALL PRIVILEGES ON gitea.* TO '${GITEA_DB_USER}'@'localhost'; FLUSH PRIVILEGES;" +fi + +gitea_rand_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -base64 48 | tr -d '/+=' | head -c 64 + return 0 + fi + head -c 48 /dev/urandom | base64 | tr -d '/+=' | head -c 64 +} + +if [ -f "$GITEA_SECRETS" ]; then + # shellcheck disable=SC1090 + . "$GITEA_SECRETS" +else + GITEA_INTERNAL_TOKEN="$(gitea_rand_secret)" + GITEA_OAUTH_JWT="$(gitea_rand_secret)" + GITEA_LFS_JWT="$(gitea_rand_secret)" + umask 077 + cat > "$GITEA_SECRETS" </dev/null || true +fi + +if [ ! -f "$INI" ] || [ "${FORCE_GITEA_CONFIG:-0}" = "1" ] || ! grep -q 'AndroidCast lab' "$INI" 2>/dev/null; then + log "render $INI from template (ROOT_URL=$GITEA_ROOT_URL)" + _tmp="$(mktemp)" + sed \ + -e "s|@GITEA_APP_DATA@|${GITEA_APP_DATA}|g" \ + -e "s|@GITEA_DOMAIN@|${GITEA_DOMAIN}|g" \ + -e "s|@GITEA_ROOT_URL@|${GITEA_ROOT_URL}|g" \ + -e "s|@GITEA_HTTP_PORT@|${GITEA_HTTP_PORT}|g" \ + -e "s|@GITEA_INTERNAL_TOKEN@|${GITEA_INTERNAL_TOKEN}|g" \ + -e "s|@GITEA_OAUTH_JWT@|${GITEA_OAUTH_JWT}|g" \ + -e "s|@GITEA_LFS_JWT@|${GITEA_LFS_JWT}|g" \ + "$TEMPLATE" > "$_tmp" + install -m 640 -o gitea -g www-data "$_tmp" "$INI" + rm -f "$_tmp" + if [ -d "${GITEA_APP_DATA}/custom/conf" ]; then + install -D -m 640 -o gitea -g www-data "$INI" "${GITEA_APP_DATA}/custom/conf/app.ini" + fi +fi + +gitea_stop_clean +rc-update add gitea default 2>/dev/null || true +rc-service gitea start 2>/dev/null || rc-service gitea restart 2>/dev/null || true +gitea_wait_http "$GITEA_HTTP_PORT" +# First admin (lab) — idempotent. +GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-admin}" +GITEA_ADMIN_PASS="$(read_cred gitea_admin password)" +if [ -z "$GITEA_ADMIN_PASS" ]; then + GITEA_ADMIN_PASS="$(read_cred mariadb_app password)" +fi +if ! su -s /bin/sh gitea -c "gitea -c '$INI' admin user list" 2>/dev/null | awk 'NR>1 && NF>=2 {found=1} END{exit !found}'; then + log "create Gitea admin user ${GITEA_ADMIN_USER}" + su -s /bin/sh gitea -c "gitea -c '$INI' admin user create \ + --username '${GITEA_ADMIN_USER}' \ + --password '${GITEA_ADMIN_PASS}' \ + --email 'admin@${GITEA_DOMAIN}' \ + --admin" 2>/dev/null || log "WARN: admin user create skipped (may already exist)" +fi + +log "deploy-gitea_ok $(host_short) ROOT_URL=${GITEA_ROOT_URL} port=${GITEA_HTTP_PORT}" diff --git a/sim/cluster0/scripts/lib/common.sh b/sim/cluster0/scripts/lib/common.sh index 9262531..88bd92d 100644 --- a/sim/cluster0/scripts/lib/common.sh +++ b/sim/cluster0/scripts/lib/common.sh @@ -185,6 +185,16 @@ read_cred() { ' "$_file" } +# Optional NFS secrets (not in git): secrets.lab.env +load_secrets_lab() { + _file="${CAST_CLUSTER_ROOT}/secrets.lab.env" + [ -f "$_file" ] || return 0 + set -a + # shellcheck source=/dev/null + . "$_file" + set +a +} + ssh_node() { _host="$1" shift diff --git a/sim/cluster0/scripts/mirror-gitea-ac-org.sh b/sim/cluster0/scripts/mirror-gitea-ac-org.sh new file mode 100755 index 0000000..bbbcc5d --- /dev/null +++ b/sim/cluster0/scripts/mirror-gitea-ac-org.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Mirror git://f0xx.org/ac/* (and legacy android_cast) into lab Gitea org ac on cast01. +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +load_cluster_env + +GITEA_SCRIPTS="${ROOT}/lab-seeds/backend/scripts/gitea" +REPOS_FILE="${ROOT}/gitea/ac-repos.list" +TOKEN_FILE="${GITEA_APP_DATA:-/var/lib/gitea}/.mirror-admin-token" +GITEA_ORG="${GITEA_ORG:-ac}" +AC_GIT_BASE="${AC_GIT_BASE:-git://f0xx.org/ac}" + +if [ "${GITEA_ENABLE:-0}" != "1" ]; then + log "GITEA_ENABLE!=1 — skip mirror-gitea-ac-org" + exit 0 +fi +if ! is_primary_node; then + log "mirror-gitea-ac-org: skip on $(host_short) (Gitea on ${CAST01_HOST})" + exit 0 +fi +[ -d "$GITEA_SCRIPTS" ] || die "missing $GITEA_SCRIPTS" +[ -f "$REPOS_FILE" ] || die "missing $REPOS_FILE" + +ensure_admin_token() { + if [ -n "${GITEA_ADMIN_TOKEN:-}" ]; then + return 0 + fi + if [ -r "$TOKEN_FILE" ]; then + GITEA_ADMIN_TOKEN="$(cat "$TOKEN_FILE")" + export GITEA_ADMIN_TOKEN + return 0 + fi + log "generate Gitea admin API token" + GITEA_ADMIN_TOKEN="$(su -s /bin/sh gitea -c \ + "gitea -c /etc/gitea/app.ini admin user generate-access-token --username admin --token-name mirror-ac --scopes all --raw" \ + | tail -1)" + [ -n "$GITEA_ADMIN_TOKEN" ] || die "failed to generate GITEA_ADMIN_TOKEN" + umask 077 + printf '%s' "$GITEA_ADMIN_TOKEN" > "$TOKEN_FILE" + chown gitea:www-data "$TOKEN_FILE" 2>/dev/null || true + export GITEA_ADMIN_TOKEN +} + +ensure_admin_token +export GITEA_ORG GITEA_OWNER="$GITEA_ORG" GITEA_ORG_OWNERS="${GITEA_ORG_OWNERS:-admin}" +export GITEA_ORG_FULL_NAME="${GITEA_ORG_FULL_NAME:-Android Cast (ac)}" +export GITEA_TRANSFER_FROM="${GITEA_TRANSFER_FROM:-admin}" +export GITEA_MIRROR_INTERVAL="${GITEA_MIRROR_INTERVAL:-10m}" +export GITEA_PRIVATE="${GITEA_PRIVATE:-false}" + +log "ensure Gitea org ${GITEA_ORG}" +sh "$GITEA_SCRIPTS/gitea_migrate_org_setup.sh" || die "org setup failed" + +OK=0 +SKIP=0 +FAIL=0 + +while IFS= read -r _line || [ -n "$_line" ]; do + _line="$(printf '%s' "$_line" | sed 's/#.*//;s/^[ \t]*//;s/[ \t]*$//')" + [ -n "$_line" ] || continue + _repo="$_line" + _url="" + case "$_line" in + *'|'*) + _repo="${_line%%|*}" + _url="${_line#*|}" + ;; + *) + _url="${AC_GIT_BASE}/${_repo}" + ;; + esac + log "mirror ${_repo} <= ${_url}" + if command -v sqlite3 >/dev/null 2>&1 && \ + sqlite3 /var/lib/gitea/gitea.db "SELECT 1 FROM repository r JOIN user u ON r.owner_id=u.id WHERE u.lower_name='$(printf '%s' "$GITEA_ORG" | tr '[:upper:]' '[:lower:]')' AND r.lower_name='$(printf '%s' "$_repo" | tr '[:upper:]' '[:lower:]')' LIMIT 1;" 2>/dev/null | grep -q 1; then + log "skip existing $_repo" + SKIP=$((SKIP + 1)) + continue + fi + if GITEA_OWNER="$GITEA_ORG" GITEA_REPO="$_repo" GITEA_MIRROR_URL="$_url" \ + sh "$GITEA_SCRIPTS/gitea_attach_remote.sh" "$_url"; then + OK=$((OK + 1)) + else + FAIL=$((FAIL + 1)) + log "WARN: mirror failed for $_repo" + fi +done < "$REPOS_FILE" + +log "mirror-gitea-ac-org summary ok=$OK skip=$SKIP fail=$FAIL org=$GITEA_ORG" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/sim/cluster0/scripts/verify-gitea-mirrors.sh b/sim/cluster0/scripts/verify-gitea-mirrors.sh new file mode 100755 index 0000000..82d8144 --- /dev/null +++ b/sim/cluster0/scripts/verify-gitea-mirrors.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# Verify lab Gitea org ac mirrors vs ac-repos.list (cast01). +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +load_cluster_env + +REPOS_FILE="${ROOT}/gitea/ac-repos.list" +DB="${GITEA_APP_DATA:-/var/lib/gitea}/gitea.db" +ORG="${GITEA_ORG:-ac}" + +if [ "${GITEA_ENABLE:-0}" != "1" ]; then + log "GITEA_ENABLE!=1 — skip" + exit 0 +fi +if ! is_primary_node; then + log "verify-gitea-mirrors: skip on $(host_short)" + exit 0 +fi + +FAIL=0 +rc-service gitea status 2>/dev/null | grep -qi started || { log "FAIL gitea not running"; FAIL=1; } +curl -fsS -m 5 -o /dev/null http://127.0.0.1:3000/ || { log "FAIL gitea http"; FAIL=1; } + +GITEA_COUNT="$(sqlite3 "$DB" "SELECT COUNT(*) FROM repository r JOIN user u ON r.owner_id=u.id WHERE u.lower_name='$(printf '%s' "$ORG" | tr '[:upper:]' '[:lower:]')';" 2>/dev/null || echo 0)" +log "gitea_org_${ORG}_repos=${GITEA_COUNT}" + +while IFS= read -r _line || [ -n "$_line" ]; do + _line="$(printf '%s' "$_line" | sed 's/#.*//;s/^[ \t]*//;s/[ \t]*$//')" + [ -n "$_line" ] || continue + _repo="${_line%%|*}" + _url="${_line#*|}" + [ "$_url" = "$_repo" ] && _url="${AC_GIT_BASE:-git://f0xx.org/ac}/${_repo}" + if ! git ls-remote "$_url" HEAD >/dev/null 2>&1 && \ + ! git ls-remote "$_url" refs/heads/next >/dev/null 2>&1 && \ + ! git ls-remote "$_url" 2>/dev/null | grep -q refs/heads/; then + log "WARN git_missing $_url (not exported on git server)" + FAIL=1 + continue + fi + if sqlite3 "$DB" "SELECT 1 FROM repository r JOIN user u ON r.owner_id=u.id WHERE u.lower_name='$(printf '%s' "$ORG" | tr '[:upper:]' '[:lower:]')' AND r.lower_name='$(printf '%s' "$_repo" | tr '[:upper:]' '[:lower:]')' LIMIT 1;" 2>/dev/null | grep -q 1; then + log "OK gitea_mirror ${ORG}/${_repo}" + else + log "FAIL gitea_missing ${ORG}/${_repo} (git remote OK)" + FAIL=1 + fi +done < "$REPOS_FILE" + +if [ "$FAIL" -eq 0 ]; then + log "verify-gitea-mirrors_ok" + exit 0 +fi +log "verify-gitea-mirrors_WARN (see git_missing / gitea_missing above)" +exit 1 diff --git a/sim/cluster0/scripts/verify-mail-lab.sh b/sim/cluster0/scripts/verify-mail-lab.sh new file mode 100755 index 0000000..8bf6be0 --- /dev/null +++ b/sim/cluster0/scripts/verify-mail-lab.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Lab SMTP smoke — requires secrets.lab.env SMTP_* or local relay on cast01. +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +load_cluster_env +load_secrets_lab + +TO="${1:-}" +[ -n "$TO" ] || die "usage: verify-mail-lab.sh recipient@example.com" + +COMPOSED="${COMPOSE_BACKEND:-/var/www/ac/composed/backend}" +[ -f "${COMPOSED}/config/config.php" ] || die "missing composed backend — run compose-lab-backend.sh" + +export MAIL_TEST_TO="$TO" +_out="$(php -r ' +require "'"${COMPOSED}"'/src/bootstrap.php"; +$to = getenv("MAIL_TEST_TO") ?: ""; +$origin = rtrim((string) cfg("public_origin", "https://acl0.f0xx.org"), "/"); +$base = rtrim((string) cfg("base_path", ""), "/"); +$ok = AuthMailer::sendVerifyEmail($to, $origin . $base . "/verify-email?token=lab-smoke"); +echo $ok ? "mail_ok" : "mail_fail"; +' 2>&1)" || _out="mail_fail" + +log "$_out to=$TO" +case "$_out" in + mail_ok) exit 0 ;; + *) exit 1 ;; +esac diff --git a/sim/cluster0/secrets.lab.env.example b/sim/cluster0/secrets.lab.env.example new file mode 100644 index 0000000..35fd4ff --- /dev/null +++ b/sim/cluster0/secrets.lab.env.example @@ -0,0 +1,30 @@ +# Lab cluster secrets — COPY to secrets.lab.env on NFS (/shared/cluster/secrets.lab.env) +# Mode 0600, owner root or deploy user. NOT committed to git. +# +# Recoverable via procedures in ac-docs/docs/SECRETS_AND_RECOVERY.md + +# PHP auth (issues backend) — min 32 chars; changing invalidates sessions +AUTH_ENCRYPTION_KEY=lab-cluster-dev-32-char-min-secret!! + +# Gitea mirror automation (optional; else regenerate on cast01) +# GITEA_MIRROR_ADMIN_TOKEN= + +# WireGuard lab server (generate new for lab — do not copy prod) +# WG_SERVER_PRIVATE_KEY= +# WG_SERVER_PUBLIC_KEY= +# WG_ENDPOINT=cast01.intra.raptor.org:51820 + +# RSSH bastion (lab) +# RSSH_BASTION_HOST=cast01.intra.raptor.org +# RSSH_BASTION_PORT=22 + +# SMTP (when lab mail is configured — prefer email recovery) +# Fill on NFS /shared/cluster/secrets.lab.env (mode 600); re-run compose-lab-backend.sh +MAIL_TRANSPORT=smtp +MAIL_FROM=Android Cast Lab +MAIL_REPLY_TO=info@apps.f0xx.org +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_ENCRYPTION=tls +# SMTP_USER= +# SMTP_PASS= diff --git a/sim/cluster0/verify_global_setup.sh b/sim/cluster0/verify_global_setup.sh index cddcf0e..2d91b2d 100644 --- a/sim/cluster0/verify_global_setup.sh +++ b/sim/cluster0/verify_global_setup.sh @@ -11,7 +11,8 @@ export JOURNAL_PHASE=verify_global ensure_shared_mounted FAIL=0 -ORIGIN="${PUBLIC_ORIGIN%/}" +ORIGIN="${LAB_PUBLIC_ORIGIN:-${PUBLIC_ORIGIN}}" +ORIGIN="${ORIGIN%/}" check_url() { _path="$1" diff --git a/sim/cluster0/verify_lab_setup.sh b/sim/cluster0/verify_lab_setup.sh index eb8b051..cf4e364 100644 --- a/sim/cluster0/verify_lab_setup.sh +++ b/sim/cluster0/verify_lab_setup.sh @@ -56,6 +56,24 @@ check_local_node() { sh "$ROOT/scripts/validate-ac-repos.sh" || FAIL=1 fi + if [ "${GITEA_ENABLE:-0}" = "1" ] && is_primary_node; then + rc-service gitea status 2>/dev/null | grep -qi started || { log "FAIL gitea not running"; FAIL=1; } + GCODE="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ 2>/dev/null || echo 000)" + case "$GCODE" in + 200|302) log "gitea_direct=${GCODE}" ;; + *) log "FAIL gitea_direct=${GCODE}"; FAIL=1 ;; + esac + GIT_UI="$(curl -sS -o /dev/null -w '%{http_code}' \ + "http://127.0.0.1/app/androidcast_project/git/" 2>/dev/null || echo 000)" + case "$GIT_UI" in + 200|302) log "gitea_nginx=${GIT_UI}" ;; + *) log "WARN gitea_nginx=${GIT_UI} (want 200/302)" ;; + esac + if [ -x "$ROOT/scripts/verify-gitea-mirrors.sh" ]; then + sh "$ROOT/scripts/verify-gitea-mirrors.sh" || log "WARN gitea mirror inventory incomplete" + fi + fi + CRASH_N="$(mariadb -u androidcast -p"$(read_cred mariadb_app password)" -h127.0.0.1 -N -e \ "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='androidcast_crashes'" 2>/dev/null || echo 0)" [ "$CRASH_N" = "$EXPECTED_CRASHES_TABLES" ] || { log "FAIL crashes tables=$CRASH_N want $EXPECTED_CRASHES_TABLES"; FAIL=1; }