mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 02:57:38 +03:00
cluster0: lab seeds, Gitea mirrors, compose mail, verify tooling
Freeze lab-seeds backend for COMPOSE_SKIP_MONOLITH; fix Gitea mirror scripts for 1.25 API; add secrets.lab.env template, verify-mail-lab, LAB_PUBLIC_ORIGIN, and credentials pointers for mirror token recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
65
sim/cluster0/gitea/README.md
Normal file
65
sim/cluster0/gitea/README.md
Normal file
@@ -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.
|
||||
36
sim/cluster0/gitea/ac-repos.list
Normal file
36
sim/cluster0/gitea/ac-repos.list
Normal file
@@ -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
|
||||
84
sim/cluster0/gitea/app.ini.template
Normal file
84
sim/cluster0/gitea/app.ini.template
Normal file
@@ -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
|
||||
9
sim/cluster0/lab-seeds/README.md
Normal file
9
sim/cluster0/lab-seeds/README.md
Normal file
@@ -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.
|
||||
343
sim/cluster0/lab-seeds/backend/README.md
Normal file
343
sim/cluster0/lab-seeds/backend/README.md
Normal file
@@ -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`).
|
||||
46
sim/cluster0/lab-seeds/backend/THIRD_PARTY_LICENSES.md
Normal file
46
sim/cluster0/lab-seeds/backend/THIRD_PARTY_LICENSES.md
Normal file
@@ -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).
|
||||
124
sim/cluster0/lab-seeds/backend/config/config.example.php
Normal file
124
sim/cluster0/lab-seeds/backend/config/config.example.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/*
|
||||
* package examples/crash_reporter/backend/config/config.example.php
|
||||
* config.example.php
|
||||
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (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 <noreply@f0xx.org>',
|
||||
'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',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
59
sim/cluster0/lab-seeds/backend/config/config.php.alpine
Normal file
59
sim/cluster0/lab-seeds/backend/config/config.php.alpine
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/*
|
||||
* package examples/crash_reporter/backend/config/config.php
|
||||
* config.php
|
||||
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (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,
|
||||
],
|
||||
];
|
||||
17
sim/cluster0/lab-seeds/backend/nginx.apps-builder.frag
Normal file
17
sim/cluster0/lab-seeds/backend/nginx.apps-builder.frag
Normal file
@@ -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;
|
||||
}
|
||||
134
sim/cluster0/lab-seeds/backend/nginx.apps-port80.fragment
Normal file
134
sim/cluster0/lab-seeds/backend/nginx.apps-port80.fragment
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
117
sim/cluster0/lab-seeds/backend/nginx.apps.conf.fragment
Normal file
117
sim/cluster0/lab-seeds/backend/nginx.apps.conf.fragment
Normal file
@@ -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;
|
||||
}
|
||||
42
sim/cluster0/lab-seeds/backend/nginx.conf
Normal file
42
sim/cluster0/lab-seeds/backend/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
13
sim/cluster0/lab-seeds/backend/nginx.fe-build-inline.snippet
Normal file
13
sim/cluster0/lab-seeds/backend/nginx.fe-build-inline.snippet
Normal file
@@ -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).
|
||||
13
sim/cluster0/lab-seeds/backend/nginx.fe-ota.snippet
Normal file
13
sim/cluster0/lab-seeds/backend/nginx.fe-ota.snippet
Normal file
@@ -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;
|
||||
}
|
||||
109
sim/cluster0/lab-seeds/backend/nginx.vm.conf
Normal file
109
sim/cluster0/lab-seeds/backend/nginx.vm.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
137
sim/cluster0/lab-seeds/backend/nginx/fe/apps.conf
Normal file
137
sim/cluster0/lab-seeds/backend/nginx/fe/apps.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
42
sim/cluster0/lab-seeds/backend/patches/fe-nginx-APPLY.md
Normal file
42
sim/cluster0/lab-seeds/backend/patches/fe-nginx-APPLY.md
Normal file
@@ -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/
|
||||
```
|
||||
@@ -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;
|
||||
- }
|
||||
-
|
||||
- }
|
||||
|
||||
}
|
||||
26
sim/cluster0/lab-seeds/backend/public/api/auth_register.php
Normal file
26
sim/cluster0/lab-seeds/backend/public/api/auth_register.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_out(['ok' => 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);
|
||||
77
sim/cluster0/lab-seeds/backend/public/api/diag.php
Normal file
77
sim/cluster0/lab-seeds/backend/public/api/diag.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!cfg('debug')) {
|
||||
json_out(['ok' => 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);
|
||||
32
sim/cluster0/lab-seeds/backend/public/api/graph_upload.php
Normal file
32
sim/cluster0/lab-seeds/backend/public/api/graph_upload.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
22
sim/cluster0/lab-seeds/backend/public/api/graphs.php
Normal file
22
sim/cluster0/lab-seeds/backend/public/api/graphs.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
Auth::check();
|
||||
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
if ($days <= 0) {
|
||||
$days = 14;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = GraphRepository::dashboardData($days);
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
65
sim/cluster0/lab-seeds/backend/public/api/heartbeat.php
Normal file
65
sim/cluster0/lab-seeds/backend/public/api/heartbeat.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => '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);
|
||||
146
sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php
Normal file
146
sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = strtolower(trim((string)($_GET['action'] ?? '')));
|
||||
$section = strtolower(trim((string)($_GET['section'] ?? '')));
|
||||
|
||||
function hub_docs_toc(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => '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 '<p>Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.</p>';
|
||||
case 'intro-revision':
|
||||
return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>';
|
||||
case 'deploy-prep':
|
||||
return '<p>Prerequisites: docker + docker compose plugin, git checkout of android cast on branch <code>next</code>.</p>'
|
||||
. '<p>Run one command from repo: <code>cd orchestration && ./deploy.sh</code></p>';
|
||||
case 'deploy-packages':
|
||||
return '<table><thead><tr><th>Distro</th><th>Install steps</th></tr></thead><tbody>'
|
||||
. '<tr><td>Alpine</td><td><code>apk add docker docker-cli-compose bash git curl</code><br><code>rc-update add docker default && service docker start</code></td></tr>'
|
||||
. '<tr><td>Debian / Ubuntu</td><td><code>apt update && apt install -y docker.io docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
|
||||
. '<tr><td>Fedora</td><td><code>dnf install -y docker docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
|
||||
. '<tr><td>Arch</td><td><code>pacman -S --noconfirm docker docker-compose git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
|
||||
. '</tbody></table>';
|
||||
case 'deploy-checks':
|
||||
return '<ul>'
|
||||
. '<li><code>http://localhost:8080/app/androidcast_project/</code> (hub)</li>'
|
||||
. '<li><code>http://localhost:8080/app/androidcast_project/crashes/</code> (crashes/tasks)</li>'
|
||||
. '<li><code>http://localhost:8080/app/androidcast_project/git/</code> (redirect to gitea FE)</li>'
|
||||
. '</ul>';
|
||||
case 'orchestration':
|
||||
return '<p>All infra runs in docker with shared source bind-mount where possible.</p>'
|
||||
. '<ul>'
|
||||
. '<li>landing FE (nginx): serves hub and routes subpaths</li>'
|
||||
. '<li>gitea FE (nginx) + gitea app</li>'
|
||||
. '<li>crashes FE (nginx) + php-fpm + mariadb</li>'
|
||||
. '<li>single command create/update: <code>orchestration/deploy.sh</code></li>'
|
||||
. '</ul>'
|
||||
. '<p>Base images: nginx, php-fpm, mariadb. This stays minimal and reproducible on laptop devices.</p>';
|
||||
case 'routing':
|
||||
return '<p>The Compass click opens a modal. Content is requested via XMLHttpRequest/fetch from BE endpoint '
|
||||
. '<code>/app/androidcast_project/crashes/api/hub_deploy_docs.php</code>.</p>'
|
||||
. '<p>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.</p>'
|
||||
. '<p>Static assets remain at original paths and are not rewritten in this view.</p>';
|
||||
case 'ops':
|
||||
return '<ol>'
|
||||
. '<li>Pull latest <code>next</code></li>'
|
||||
. '<li>Run <code>orchestration/deploy.sh</code></li>'
|
||||
. '<li>Verify hub/crashes/gitea endpoints</li>'
|
||||
. '<li>For app changes: edit source in repo, rerun deploy script</li>'
|
||||
. '</ol>';
|
||||
case 'intro':
|
||||
return '<p>Select a topic in the table of contents to load section content from backend.</p>';
|
||||
default:
|
||||
return '<p class="muted">Section not found.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
114
sim/cluster0/lab-seeds/backend/public/api/live_cast.php
Normal file
114
sim/cluster0/lab-seeds/backend/public/api/live_cast.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$action = '';
|
||||
$body = [];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$action = trim((string) ($_GET['action'] ?? 'active'));
|
||||
} else {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => 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);
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||
$sinceId = (int) ($_GET['since_id'] ?? 0);
|
||||
$msgType = trim((string) ($_GET['msg_type'] ?? ''));
|
||||
$fromRole = trim((string) ($_GET['from_role'] ?? ''));
|
||||
$body = [];
|
||||
|
||||
if ($method === 'POST') {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => 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]);
|
||||
70
sim/cluster0/lab-seeds/backend/public/api/rbac.php
Normal file
70
sim/cluster0/lab-seeds/backend/public/api/rbac.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/RbacAdminRepository.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::canManageRbac()) {
|
||||
json_out(['ok' => 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);
|
||||
119
sim/cluster0/lab-seeds/backend/public/api/remote_access.php
Normal file
119
sim/cluster0/lab-seeds/backend/public/api/remote_access.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::can('remote_access_view')) {
|
||||
json_out(['ok' => 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);
|
||||
73
sim/cluster0/lab-seeds/backend/public/api/report_tags.php
Normal file
73
sim/cluster0/lab-seeds/backend/public/api/report_tags.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
25
sim/cluster0/lab-seeds/backend/public/api/report_viewed.php
Normal file
25
sim/cluster0/lab-seeds/backend/public/api/report_viewed.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
87
sim/cluster0/lab-seeds/backend/public/api/reports.php
Normal file
87
sim/cluster0/lab-seeds/backend/public/api/reports.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
18
sim/cluster0/lab-seeds/backend/public/api/sfu_health.php
Normal file
18
sim/cluster0/lab-seeds/backend/public/api/sfu_health.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$sfu = cfg('sfu', []);
|
||||
$enabled = (bool) ($sfu['enabled'] ?? false);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => 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);
|
||||
167
sim/cluster0/lab-seeds/backend/public/api/short_links.php
Normal file
167
sim/cluster0/lab-seeds/backend/public/api/short_links.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::can('short_links_view')) {
|
||||
json_out(['ok' => 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);
|
||||
15
sim/cluster0/lab-seeds/backend/public/api/tag_catalog.php
Normal file
15
sim/cluster0/lab-seeds/backend/public/api/tag_catalog.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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',
|
||||
]);
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
http_response_code(401);
|
||||
echo 'Unauthorized';
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
} catch (RuntimeException $e) {
|
||||
http_response_code(503);
|
||||
echo 'Tickets unavailable';
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$att = TicketAttachmentRepository::getById($id);
|
||||
if (!$att || ($att['kind'] ?? '') !== 'file') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = TicketAttachmentRepository::filePath($att);
|
||||
if ($path === null) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$name = (string) ($att['original_name'] ?? 'download');
|
||||
$mime = (string) ($att['mime_type'] ?? 'application/octet-stream');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string) filesize($path));
|
||||
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
109
sim/cluster0/lab-seeds/backend/public/api/ticket_attachments.php
Normal file
109
sim/cluster0/lab-seeds/backend/public/api/ticket_attachments.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
65
sim/cluster0/lab-seeds/backend/public/api/ticket_create.php
Normal file
65
sim/cluster0/lab-seeds/backend/public/api/ticket_create.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
75
sim/cluster0/lab-seeds/backend/public/api/ticket_tags.php
Normal file
75
sim/cluster0/lab-seeds/backend/public/api/ticket_tags.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
56
sim/cluster0/lab-seeds/backend/public/api/ticket_update.php
Normal file
56
sim/cluster0/lab-seeds/backend/public/api/ticket_update.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
46
sim/cluster0/lab-seeds/backend/public/api/ticket_upload.php
Normal file
46
sim/cluster0/lab-seeds/backend/public/api/ticket_upload.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
49
sim/cluster0/lab-seeds/backend/public/api/tickets.php
Normal file
49
sim/cluster0/lab-seeds/backend/public/api/tickets.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => 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);
|
||||
}
|
||||
53
sim/cluster0/lab-seeds/backend/public/api/upload.php
Normal file
53
sim/cluster0/lab-seeds/backend/public/api/upload.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/*
|
||||
* package examples/crash_reporter/backend/public/api/upload.php
|
||||
* upload.php
|
||||
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (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);
|
||||
}
|
||||
9
sim/cluster0/lab-seeds/backend/public/api/users.php
Normal file
9
sim/cluster0/lab-seeds/backend/public/api/users.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
json_out(['ok' => true, 'users' => UserRepository::listForAssignees()]);
|
||||
2423
sim/cluster0/lab-seeds/backend/public/assets/css/app.css
Normal file
2423
sim/cluster0/lab-seeds/backend/public/assets/css/app.css
Normal file
File diff suppressed because it is too large
Load Diff
218
sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json
Normal file
218
sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json
Normal file
@@ -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"
|
||||
}
|
||||
196
sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json
Normal file
196
sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json
Normal file
@@ -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": "Файл"
|
||||
}
|
||||
157
sim/cluster0/lab-seeds/backend/public/assets/js/analytics.js
Normal file
157
sim/cluster0/lab-seeds/backend/public/assets/js/analytics.js
Normal file
@@ -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);
|
||||
2067
sim/cluster0/lab-seeds/backend/public/assets/js/app.js
Normal file
2067
sim/cluster0/lab-seeds/backend/public/assets/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
1093
sim/cluster0/lab-seeds/backend/public/assets/js/graphs.js
Normal file
1093
sim/cluster0/lab-seeds/backend/public/assets/js/graphs.js
Normal file
File diff suppressed because it is too large
Load Diff
207
sim/cluster0/lab-seeds/backend/public/assets/js/i18n.js
Normal file
207
sim/cluster0/lab-seeds/backend/public/assets/js/i18n.js
Normal file
@@ -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<string, { flag: string, code: string }>} */
|
||||
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<string, string|number>|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);
|
||||
@@ -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);
|
||||
@@ -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 = '<h2>Share this demo</h2><p class="muted">Viewers open this link in another browser (same LAN or online).</p>';
|
||||
html += '<p><a href="' + joinUrl + '" target="_blank" rel="noopener">' + joinUrl + '</a></p>';
|
||||
if (qrUrl) {
|
||||
html += '<figure class="twofa-qr-wrap"><img src="' + qrUrl + '" width="160" height="160" alt="Viewer QR code"><figcaption class="muted">Scan to open viewer page</figcaption></figure>';
|
||||
}
|
||||
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();
|
||||
}
|
||||
})();
|
||||
224
sim/cluster0/lab-seeds/backend/public/assets/js/live_join.js
Normal file
224
sim/cluster0/lab-seeds/backend/public/assets/js/live_join.js
Normal file
@@ -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, '>')
|
||||
.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 =
|
||||
'<strong>Statistics for nerds</strong><br>'
|
||||
+ 'ICE: ' + escapeHtml(stats.ice_state || '—')
|
||||
+ ' · sig: ' + escapeHtml(stats.signaling_state || '—')
|
||||
+ '<br>In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps'
|
||||
+ '<br>RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0)
|
||||
+ (stats.frame_w ? '<br>Frame: ' + stats.frame_w + '×' + stats.frame_h : '');
|
||||
}
|
||||
|
||||
function startViewer(sessionId, session, playerEl, statusEl, statsEl) {
|
||||
if (!window.RTCPeerConnection || !window.LiveCastWebRtc) {
|
||||
playerEl.innerHTML = '<p class="muted">WebRTC is not available in this browser.</p>';
|
||||
return null;
|
||||
}
|
||||
playerEl.innerHTML =
|
||||
'<video id="live-remote-video" class="live-preview" playsinline autoplay controls></video>'
|
||||
+ '<div id="live-join-stats" class="live-stats-nerds" hidden></div>';
|
||||
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 '<li>' + escapeHtml(l) + '</li>';
|
||||
}).join('');
|
||||
|
||||
if (direct && /android|ios/i.test(detectPlatform())) {
|
||||
playerEl.innerHTML =
|
||||
'<p class="muted">Open in the AndroidCast app for direct P2P on LAN.</p>' +
|
||||
'<p><code>androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1</code></p>' +
|
||||
'<p class="muted">Mobile WebRTC signaling uses the same REST API when the app transport is wired.</p>';
|
||||
postJoinEvent(sessionId, 'join', true);
|
||||
} else if (live) {
|
||||
webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl);
|
||||
postJoinEvent(sessionId, 'join', false);
|
||||
} else {
|
||||
playerEl.innerHTML = '<p class="muted">Session is not live.</p>';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
113
sim/cluster0/lab-seeds/backend/public/assets/js/live_sessions.js
Normal file
113
sim/cluster0/lab-seeds/backend/public/assets/js/live_sessions.js
Normal file
@@ -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, '>')
|
||||
.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 = '<tr><td colspan="8" class="muted">No sessions in this window.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = sessions.map(function (s) {
|
||||
var joinUrl = s.join_short_url || '';
|
||||
var joinCell = joinUrl
|
||||
? '<a href="' + escapeHtml(joinUrl) + '" target="_blank" rel="noopener">Open</a>'
|
||||
: '—';
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td><span class="' + statusClass(s.status) + '">' + escapeHtml(s.status) + '</span></td>' +
|
||||
'<td>' + escapeHtml(s.owner_username || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.platform || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.codec_video || '—') + '</td>' +
|
||||
'<td>' + durationS(s.started_at_ms, s.ended_at_ms) + '</td>' +
|
||||
'<td>' + formatMs(s.last_heartbeat_ms) + '</td>' +
|
||||
'<td>' + Number(s.join_opens || 0) + '</td>' +
|
||||
'<td>' + joinCell + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).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();
|
||||
}
|
||||
})();
|
||||
83
sim/cluster0/lab-seeds/backend/public/assets/js/nav_shell.js
Normal file
83
sim/cluster0/lab-seeds/backend/public/assets/js/nav_shell.js
Normal file
@@ -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();
|
||||
}
|
||||
})();
|
||||
196
sim/cluster0/lab-seeds/backend/public/assets/js/rbac_admin.js
Normal file
196
sim/cluster0/lab-seeds/backend/public/assets/js/rbac_admin.js
Normal file
@@ -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()
|
||||
? '<select class="rbac-global-role" data-user-id="' + u.id + '">' +
|
||||
globalRoles.map((r) => '<option value="' + esc(r) + '"' + (u.global_role === r ? ' selected' : '') + '>' + esc(r) + '</option>').join('') +
|
||||
'</select>'
|
||||
: '<code>' + esc(u.global_role) + '</code>';
|
||||
|
||||
const roleSelect =
|
||||
'<select class="rbac-company-role" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
|
||||
companyRoles
|
||||
.map((r) => '<option value="' + esc(r) + '"' + (m.role === r ? ' selected' : '') + '>' + esc(r) + '</option>')
|
||||
.join('') +
|
||||
'</select>';
|
||||
|
||||
const setSelect =
|
||||
'<select class="rbac-privilege-set" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
|
||||
sets
|
||||
.map((s) => {
|
||||
const key = s.key != null ? s.key : '';
|
||||
const label = s.label || key || '(none)';
|
||||
const sel = (m.privilege_set || '') === key ? ' selected' : '';
|
||||
return '<option value="' + esc(key) + '"' + sel + '>' + esc(label) + '</option>';
|
||||
})
|
||||
.join('') +
|
||||
(m.privilege_set === 'custom' ? '<option value="" disabled>custom grants (edit via API)</option>' : '') +
|
||||
'</select>';
|
||||
|
||||
let authCell = '';
|
||||
if (!seenAuth.has(u.id)) {
|
||||
seenAuth.add(u.id);
|
||||
const fails = Number(u.auth_failures || 0);
|
||||
if (canEditGlobal() && fails > 0) {
|
||||
authCell =
|
||||
'<span class="muted">' + fails + ' fail</span> ' +
|
||||
'<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts</button>';
|
||||
} else if (fails > 0) {
|
||||
authCell = '<span class="muted">' + fails + ' recent fail</span>';
|
||||
} else {
|
||||
authCell = '<span class="muted">—</span>';
|
||||
}
|
||||
}
|
||||
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(u.username) + '</td>' +
|
||||
'<td>' + globalSelect + '</td>' +
|
||||
'<td><code>' + esc(m.slug) + '</code> ' + esc(m.name) + '</td>' +
|
||||
'<td>' + roleSelect + '</td>' +
|
||||
'<td>' + setSelect + '</td>' +
|
||||
'<td>' + authCell + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (!(u.memberships || []).length && canEditGlobal()) {
|
||||
const tr = document.createElement('tr');
|
||||
const fails = Number(u.auth_failures || 0);
|
||||
let authCell = fails > 0
|
||||
? '<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts (' + fails + ')</button>'
|
||||
: '<span class="muted">—</span>';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(u.username) + '</td>' +
|
||||
'<td><code>' + esc(u.global_role) + '</code></td>' +
|
||||
'<td colspan="3" class="muted">No company membership</td>' +
|
||||
'<td>' + authCell + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
});
|
||||
|
||||
if (!tbody.children.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No users in scope.</td></tr>';
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
})();
|
||||
801
sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js
Normal file
801
sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js
Normal file
@@ -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 =
|
||||
'<col class="col-expand" style="width:' + (widths.expand || 40) + 'px" />';
|
||||
order.forEach((k) => {
|
||||
html +=
|
||||
'<col data-col="' +
|
||||
esc(k) +
|
||||
'" style="width:' +
|
||||
(widths[k] || 100) +
|
||||
'px" />';
|
||||
});
|
||||
html +=
|
||||
'<col class="col-actions" style="width:' + (widths.actions || 220) + 'px" />';
|
||||
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 =
|
||||
'<tr><th class="report-tree-head" aria-label="Expand"><span class="col-resizer" data-resize-col="expand"></span></th>';
|
||||
cols.forEach((c) => {
|
||||
const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : '';
|
||||
html +=
|
||||
'<th class="th-draggable"' +
|
||||
' draggable="true" data-col-key="' +
|
||||
esc(c.key) +
|
||||
'" title="Drag to reorder"' +
|
||||
w +
|
||||
' scope="col"><span class="th-inner">' +
|
||||
'<span class="th-drag" aria-hidden="true" title="Drag column">⋮⋮</span>' +
|
||||
'<span class="th-label">' +
|
||||
esc(c.label) +
|
||||
'</span></span>' +
|
||||
'<span class="col-resizer" data-resize-col="' +
|
||||
esc(c.key) +
|
||||
'"></span></th>';
|
||||
});
|
||||
html +=
|
||||
'<th class="col-actions" scope="col" style="width:' +
|
||||
(widths.actions || 220) +
|
||||
'px">Actions<span class="col-resizer" data-resize-col="actions"></span></th></tr>';
|
||||
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 '<span class="tag-pill tag-pill--warn">Needs whitelist</span>';
|
||||
}
|
||||
if (label === 'whitelisted') {
|
||||
return '<span class="tag-pill">Whitelisted</span>';
|
||||
}
|
||||
if (label === 'stale') {
|
||||
return '<span class="muted">Stale</span>';
|
||||
}
|
||||
if (label === 'not_opted_in') {
|
||||
return '<span class="muted">Not opted in</span>';
|
||||
}
|
||||
return '<span class="muted">Polling</span>';
|
||||
}
|
||||
|
||||
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 (
|
||||
'<code class="ra-device-id" title="' +
|
||||
esc(d.device_id) +
|
||||
'">' +
|
||||
esc(deviceTitle(d)) +
|
||||
'</code>'
|
||||
);
|
||||
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 = '<div class="ra-detail-links">';
|
||||
links.forEach((link) => {
|
||||
html +=
|
||||
'<a class="btn btn-sm" href="' +
|
||||
esc(link.href) +
|
||||
'">' +
|
||||
esc(link.label) +
|
||||
'</a> ';
|
||||
});
|
||||
html += '</div>';
|
||||
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 =
|
||||
'<tr><td colspan="' +
|
||||
span +
|
||||
'" class="muted">No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll.</td></tr>';
|
||||
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 += '<tr class="' + rowClass + '" data-device-id="' + esc(d.device_id) + '" tabindex="0">';
|
||||
html +=
|
||||
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="' +
|
||||
(isOpen ? 'true' : 'false') +
|
||||
'" aria-controls="' +
|
||||
briefId +
|
||||
'" title="Show device details"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
|
||||
|
||||
cols.forEach((c) => {
|
||||
html += '<td data-col="' + esc(c.key) + '">' + formatCell(c.key, d) + '</td>';
|
||||
});
|
||||
|
||||
html +=
|
||||
'<td class="ra-device-actions col-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary"' +
|
||||
openDisabled +
|
||||
' data-open-session="' +
|
||||
esc(d.device_id) +
|
||||
'" title="' +
|
||||
esc(openHint) +
|
||||
'">Open session</button> ' +
|
||||
'<button type="button" class="btn btn-sm"' +
|
||||
wlDisabled +
|
||||
' data-toggle-wl="' +
|
||||
esc(d.device_id) +
|
||||
'" data-wl="' +
|
||||
(wl ? '0' : '1') +
|
||||
'">' +
|
||||
(wl ? 'Revoke WL' : 'Whitelist') +
|
||||
'</button> ' +
|
||||
'<button type="button" class="btn btn-sm" data-copy-id="' +
|
||||
esc(d.device_id) +
|
||||
'">Copy ID</button>' +
|
||||
'</td>';
|
||||
html += '</tr>';
|
||||
|
||||
html +=
|
||||
'<tr class="report-brief-row" id="' +
|
||||
briefId +
|
||||
'"' +
|
||||
(isOpen ? '' : ' hidden') +
|
||||
'><td colspan="' +
|
||||
span +
|
||||
'" class="report-brief-cell"><div class="report-brief-panel"><div class="report-brief">';
|
||||
detailLines(d).forEach((line) => {
|
||||
html += '<p>' + esc(line) + '</p>';
|
||||
});
|
||||
html += detailLinks(d);
|
||||
html += '</div></div></td></tr>';
|
||||
});
|
||||
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 = '<tr><td colspan="6" class="muted">No pending or active sessions.</td></tr>';
|
||||
}
|
||||
(active || []).forEach((s) => {
|
||||
const tr = document.createElement('tr');
|
||||
const closeBtn = canOperate()
|
||||
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
|
||||
: '';
|
||||
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 =
|
||||
'<div class="ra-rssh-cmds muted"><code>' +
|
||||
esc(s.rssh_operator.shell || '') +
|
||||
'</code></div>';
|
||||
}
|
||||
tr.innerHTML =
|
||||
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||
'<td>' + esc(s.status) + '</td>' +
|
||||
'<td>' + esc(s.tunnel) + '</td>' +
|
||||
'<td>' + endpoint + operator + '</td>' +
|
||||
'<td>' + closeBtn + '</td>';
|
||||
aBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
if (iBody) {
|
||||
iBody.innerHTML = '';
|
||||
(inactive || []).forEach((s) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||
'<td>' + esc(s.status) + '</td>' +
|
||||
'<td>' + esc(s.closed_at || '—') + '</td>' +
|
||||
'<td>' + esc(s.close_reason || '—') + '</td>';
|
||||
iBody.appendChild(tr);
|
||||
});
|
||||
if (!inactive || !inactive.length) {
|
||||
iBody.innerHTML = '<tr><td colspan="5" class="muted">No closed sessions yet.</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
const tbody = document.getElementById('ra-events-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!events || !events.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="muted">No audit events.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(events || []).forEach((e) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(e.created_at) + '</td>' +
|
||||
'<td><code>' + esc(e.device_id) + '</code></td>' +
|
||||
'<td>' + esc(e.action) + '</td>' +
|
||||
'<td>' + esc(e.reason || '—') + '</td>';
|
||||
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);
|
||||
})();
|
||||
448
sim/cluster0/lab-seeds/backend/public/assets/js/short_links.js
Normal file
448
sim/cluster0/lab-seeds/backend/public/assets/js/short_links.js
Normal file
@@ -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 = '<tr><td colspan="8" class="muted">No bearer tokens yet — mint one below.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(bearers || []).forEach((b) => {
|
||||
const tr = document.createElement('tr');
|
||||
const active = b.active !== false && !b.revoked_at;
|
||||
const status = active ? '<span class="tag-pill">active</span>' : 'revoked';
|
||||
const revokeBtn = canOperate() && active
|
||||
? ' <button type="button" class="btn btn-sm" data-revoke-bearer="' + esc(String(b.id)) + '">Revoke</button>'
|
||||
: '';
|
||||
const viewBtn =
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-view-links="' + esc(String(b.id)) + '" data-label="' + esc(b.label || '') + '">Links</button>';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(b.id) + '</td>' +
|
||||
'<td>' + esc(b.label) + '</td>' +
|
||||
'<td>' + status + '</td>' +
|
||||
'<td><code>' + esc(bearerCaps(b)) + '</code></td>' +
|
||||
'<td>' + esc(b.link_count) + (b.expired_link_count > 0 ? ' <span class="muted">(' + esc(b.expired_link_count) + ' exp)</span>' : '') + '</td>' +
|
||||
'<td>' + esc(b.rate_limit_per_hour) + '</td>' +
|
||||
'<td>' + esc(b.created_at || '—') + '</td>' +
|
||||
'<td>' + viewBtn + revokeBtn + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSigners(signers) {
|
||||
const tbody = document.getElementById('sl-signers-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!signers || !signers.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No signer keys — mint one for permanent links.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(signers || []).forEach((s) => {
|
||||
const tr = document.createElement('tr');
|
||||
const active = s.active !== false && !s.revoked_at;
|
||||
const status = active ? '<span class="tag-pill">active</span>' : 'revoked';
|
||||
const revokeBtn = canOperate() && active
|
||||
? ' <button type="button" class="btn btn-sm" data-revoke-signer="' + esc(String(s.id)) + '">Revoke</button>'
|
||||
: '';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(s.id) + '</td>' +
|
||||
'<td>' + esc(s.label) + '</td>' +
|
||||
'<td>' + status + '</td>' +
|
||||
'<td>' + esc(s.created_at || '—') + '</td>' +
|
||||
'<td>' + revokeBtn + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLinks(links) {
|
||||
const tbody = document.getElementById('sl-links-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!links || !links.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No links for this bearer.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(links || []).forEach((l) => {
|
||||
const tr = document.createElement('tr');
|
||||
const exp = l.expired
|
||||
? '<span class="muted">expired</span>'
|
||||
: (l.ttl_seconds === 0 || !l.expires_at ? '<span class="tag-pill">permanent</span>' : esc(l.expires_at));
|
||||
tr.innerHTML =
|
||||
'<td><a href="' + esc(l.short_url) + '" target="_blank" rel="noopener">' + esc(l.short_url) + '</a> ' +
|
||||
'<button type="button" class="btn btn-sm" data-copy="' + esc(l.short_url) + '">Copy</button></td>' +
|
||||
'<td title="' + esc(l.origin) + '">' + esc(truncate(l.origin, 64)) + '</td>' +
|
||||
'<td>' + exp + '</td>' +
|
||||
'<td>' + esc(l.access_count) + '</td>' +
|
||||
'<td><a class="btn btn-sm" href="' + esc(l.qr_url) + '" target="_blank" rel="noopener">PNG</a></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAudit(rows) {
|
||||
const tbody = document.getElementById('sl-audit-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!rows || !rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No audit events.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(rows || []).forEach((e) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(e.created_at) + '</td>' +
|
||||
'<td>' + esc(e.event) + '</td>' +
|
||||
'<td>' + esc(e.bearer_id != null ? e.bearer_id : '—') + '</td>' +
|
||||
'<td>' + esc(e.slug || '—') + '</td>' +
|
||||
'<td>' + esc(e.detail || '—') + '</td>';
|
||||
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();
|
||||
})();
|
||||
132
sim/cluster0/lab-seeds/backend/public/assets/js/ticket_create.js
Normal file
132
sim/cluster0/lab-seeds/backend/public/assets/js/ticket_create.js
Normal file
@@ -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);
|
||||
}
|
||||
})();
|
||||
373
sim/cluster0/lab-seeds/backend/public/assets/js/tickets.js
Normal file
373
sim/cluster0/lab-seeds/backend/public/assets/js/tickets.js
Normal file
@@ -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, '>')
|
||||
.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 =
|
||||
'<span class="star-rating" role="img" aria-label="' + score + ' out of 5">';
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
html += '<span class="star' + (i <= score ? ' star--on' : '') + '"></span>';
|
||||
}
|
||||
return html + '</span>';
|
||||
}
|
||||
|
||||
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 '<span class="report-tag" style="--tag-bg:' + bg + '">' + label + '</span>';
|
||||
}
|
||||
return (
|
||||
'<button type="button" class="report-tag report-tag--filter" data-tag-id="' +
|
||||
escapeHtml(id) +
|
||||
'" style="--tag-bg:' +
|
||||
bg +
|
||||
'" title="Filter by tag">' +
|
||||
label +
|
||||
'</button>'
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
'<tr><td colspan="6" class="muted">' + escapeHtml(t('tickets.empty')) + '</td></tr>';
|
||||
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
|
||||
? '<div class="ticket-issue-brief muted">' + escapeHtml(row.brief) + '</div>'
|
||||
: '';
|
||||
html +=
|
||||
'<tr class="report-row report-row--nav" data-href="' +
|
||||
escapeHtml(openUrl) +
|
||||
'" tabindex="0" role="link">';
|
||||
html +=
|
||||
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
|
||||
briefId +
|
||||
'"><span class="report-tree-arrow"></span></button></td>';
|
||||
html +=
|
||||
'<td class="ticket-col-issue"><strong class="ticket-issue-title">' +
|
||||
issueTitle +
|
||||
'</strong>' +
|
||||
issueBrief +
|
||||
'</td>';
|
||||
html += '<td>' + formatTime(row.opened_at_ms) + '</td>';
|
||||
html +=
|
||||
'<td class="ticket-col-env"><span>' +
|
||||
escapeHtml(row.env_brief || '') +
|
||||
'</span><br><span class="muted">' +
|
||||
escapeHtml(row.device_model || '') +
|
||||
'</span></td>';
|
||||
html += '<td>' + formatStars(row.rating) + '</td>';
|
||||
const tagEditBtn = canTagEdit()
|
||||
? '<button type="button" class="tag-edit-btn" data-ticket-id="' +
|
||||
row.id +
|
||||
'" title="Edit tags">✎</button>'
|
||||
: '';
|
||||
const tags = (row.tags || []).map(renderTagPill).join('');
|
||||
html +=
|
||||
'<td class="col-tags"><div class="report-tags">' +
|
||||
tags +
|
||||
tagEditBtn +
|
||||
'</div></td>';
|
||||
html += '</tr>';
|
||||
html +=
|
||||
'<tr class="report-brief-row" id="' +
|
||||
briefId +
|
||||
'" hidden><td colspan="6"><div class="report-brief-panel report-row--nav" data-href="' +
|
||||
escapeHtml(openUrl) +
|
||||
'" tabindex="0" role="link"><p class="muted">' +
|
||||
escapeHtml(t('row.open_report')) +
|
||||
'</p><ul class="report-brief-lines">';
|
||||
[row.brief, row.env_brief, 'Owner: ' + (row.owner_username || '—')]
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
html += '<li>' + escapeHtml(String(line)) + '</li>';
|
||||
});
|
||||
html += '</ul></div></td></tr>';
|
||||
});
|
||||
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 = '<div class="pagination-inner">';
|
||||
html +=
|
||||
'<button type="button" class="btn page-btn" data-page="' +
|
||||
(prev || '') +
|
||||
'" ' +
|
||||
(prev ? '' : 'disabled') +
|
||||
'>← Previous</button>';
|
||||
html +=
|
||||
'<span class="page-info">Page ' +
|
||||
page +
|
||||
' / ' +
|
||||
pages +
|
||||
' · ' +
|
||||
total +
|
||||
' tickets</span>';
|
||||
html +=
|
||||
'<button type="button" class="btn page-btn" data-page="' +
|
||||
(next || '') +
|
||||
'" ' +
|
||||
(next ? '' : 'disabled') +
|
||||
'>Next →</button></div>';
|
||||
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);
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
404
sim/cluster0/lab-seeds/backend/public/index.php
Normal file
404
sim/cluster0/lab-seeds/backend/public/index.php
Normal file
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
/*
|
||||
* package examples/crash_reporter/backend/public/index.php
|
||||
* index.php
|
||||
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (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';
|
||||
21
sim/cluster0/lab-seeds/backend/scripts/apply-be-port80-nginx.sh
Executable file
21
sim/cluster0/lab-seeds/backend/scripts/apply-be-port80-nginx.sh
Executable file
@@ -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
|
||||
28
sim/cluster0/lab-seeds/backend/scripts/apply-build-nginx-fix.sh
Executable file
28
sim/cluster0/lab-seeds/backend/scripts/apply-build-nginx-fix.sh
Executable file
@@ -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'"
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Import graph_session NDJSON directly into graph_sessions (no HTTP).
|
||||
* Usage: php scripts/bulk_import_graph_sessions.php --file=/tmp/fake_graph_100dev.ndjson
|
||||
*/
|
||||
$opts = getopt('', ['file::', 'limit::']);
|
||||
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
|
||||
$limit = isset($opts['limit']) ? max(0, (int) $opts['limit']) : 0;
|
||||
|
||||
if (!is_readable($file)) {
|
||||
fwrite(STDERR, "File not found: $file\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($lines as $i => $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);
|
||||
21
sim/cluster0/lab-seeds/backend/scripts/deploy-to-be-mount.sh
Executable file
21
sim/cluster0/lab-seeds/backend/scripts/deploy-to-be-mount.sh
Executable file
@@ -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)."
|
||||
41
sim/cluster0/lab-seeds/backend/scripts/diagnose-build-proxy.sh
Executable file
41
sim/cluster0/lab-seeds/backend/scripts/diagnose-build-proxy.sh
Executable file
@@ -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
|
||||
104
sim/cluster0/lab-seeds/backend/scripts/ensure_url_shortener_prod_config.php
Executable file
104
sim/cluster0/lab-seeds/backend/scripts/ensure_url_shortener_prod_config.php
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env php81
|
||||
<?php
|
||||
/**
|
||||
* Restore url-shortener prod config after rsync/submodule deploy (config.php is gitignored).
|
||||
*
|
||||
* On BE (as user that can read crashes config.php):
|
||||
* php81 examples/crash_reporter/backend/scripts/ensure_url_shortener_prod_config.php
|
||||
*
|
||||
* - Writes backend/url-shortener/config/config.php (via apps/s symlink when present)
|
||||
* - Ensures crashes config.php has url_shortener.enabled + db block
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$backendRoot = realpath(dirname(__DIR__)) ?: dirname(__DIR__);
|
||||
$crashConfigPath = $backendRoot . '/config/config.php';
|
||||
$repoRoot = realpath(dirname($backendRoot, 3)) ?: dirname($backendRoot, 3);
|
||||
$urlConfigDir = $repoRoot . '/backend/url-shortener/config';
|
||||
$urlConfigPath = $urlConfigDir . '/config.php';
|
||||
|
||||
$sLink = '/var/www/localhost/htdocs/apps/s';
|
||||
if (is_link($sLink)) {
|
||||
$resolved = realpath($sLink);
|
||||
if ($resolved !== false) {
|
||||
$urlConfigDir = $resolved . '/config';
|
||||
$urlConfigPath = $urlConfigDir . '/config.php';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(string $msg): void {
|
||||
fwrite(STDERR, "ensure_url_shortener_prod_config: $msg\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!is_readable($crashConfigPath)) {
|
||||
fail("missing crashes config: $crashConfigPath");
|
||||
}
|
||||
|
||||
/** @var array<string,mixed> $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, "<?php\nreturn " . var_export($urlBlock, true) . ";\n");
|
||||
chmod($urlConfigPath, 0644);
|
||||
|
||||
$crashUrl = [
|
||||
'enabled' => 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, "<?php\nreturn " . var_export($crash, true) . ";\n");
|
||||
|
||||
echo "ok: $urlConfigPath\n";
|
||||
echo "ok: crashes url_shortener.enabled=true\n";
|
||||
128
sim/cluster0/lab-seeds/backend/scripts/generate-fe-nginx-patch.sh
Executable file
128
sim/cluster0/lab-seeds/backend/scripts/generate-fe-nginx-patch.sh
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build fe-nginx-apps-consolidation.patch from repo template vs tmp/FE_gentoo mirror.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
|
||||
FE_MIRROR="${FE_MIRROR:-$REPO_ROOT/tmp/FE_gentoo/etc/nginx}"
|
||||
TEMPLATE="$REPO_ROOT/examples/crash_reporter/backend/nginx/fe/apps.conf"
|
||||
PATCH_OUT="$REPO_ROOT/examples/crash_reporter/backend/patches/fe-nginx-apps-consolidation.patch"
|
||||
|
||||
if [[ ! -f "$FE_MIRROR/nginx.conf" || ! -f "$FE_MIRROR/apps.conf" ]]; then
|
||||
echo "Missing FE mirror at $FE_MIRROR (sshfs tmp/FE_gentoo)" >&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"
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::', 'devices::', 'per-device::']);
|
||||
$devices = max(1, (int) ($opts['devices'] ?? 0));
|
||||
$perDevice = max(1, (int) ($opts['per-device'] ?? 5));
|
||||
$count = isset($opts['count']) ? max(1, (int) $opts['count']) : 0;
|
||||
if ($count <= 0) {
|
||||
$count = $devices > 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";
|
||||
526
sim/cluster0/lab-seeds/backend/scripts/gitea/README.md
Normal file
526
sim/cluster0/lab-seeds/backend/scripts/gitea/README.md
Normal file
@@ -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
|
||||
@@ -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 >/dev/null 2>&1 &
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
316
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_remote.sh
Executable file
316
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_remote.sh
Executable file
@@ -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 <<EOF
|
||||
{
|
||||
"clone_addr": "$GITEA_MIRROR_URL",
|
||||
"repo_name": "$GITEA_REPO",
|
||||
"uid": $_uid,
|
||||
"mirror": true,
|
||||
"mirror_interval": "$GITEA_MIRROR_INTERVAL",
|
||||
"private": $GITEA_PRIVATE,
|
||||
"wiki": true,
|
||||
"issues": false,
|
||||
"milestones": false,
|
||||
"labels": false,
|
||||
"releases": true,
|
||||
"description": "Pull mirror of $GITEA_MIRROR_URL"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
log "API migrate (pull mirror) uid=$_uid via $_api ..."
|
||||
if gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO" 2>/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 <<EOF
|
||||
|
||||
Ongoing sync (no shared mount):
|
||||
• Pull mirror (recommended): Gitea → Repo → Settings → Mirror → Pull mirror
|
||||
Remote: $GITEA_MIRROR_URL
|
||||
Interval: $GITEA_MIRROR_INTERVAL
|
||||
• Or cron on BE (as git user):
|
||||
*/15 * * * * gitea -c $GITEA_APP_INI admin repo-sync-mirrors
|
||||
|
||||
Clone URL (public):
|
||||
$(gitea_public_repo_url)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
print_token_help() {
|
||||
cat <<'EOF'
|
||||
|
||||
About GITEA_ADMIN_TOKEN (optional on BE):
|
||||
• What: a Personal Access Token for your Gitea admin/owner user.
|
||||
• Where: log in to Gitea UI → top-right avatar → Settings → Applications
|
||||
→ Generate New Token → name it e.g. "attach-script" → scopes: all (or repo write).
|
||||
• Why: HTTP git push and REST API require authentication. Without a token the script
|
||||
tries `gitea admin user generate-access-token` on the server (works when run as root).
|
||||
• You do NOT need a token if `gitea admin create-repo` + generate-access-token succeed.
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- main ---
|
||||
|
||||
gitea_load_config || exit 1
|
||||
gitea_ensure_cli_access || exit 1
|
||||
resolve_gitea_owner
|
||||
|
||||
log "config ini=$GITEA_APP_INI RUN_USER=$GITEA_RUN_USER"
|
||||
log "Gitea ROOT_URL=$GITEA_ROOT_URL (public)"
|
||||
log "local API=$(gitea_api_base) local git=$(gitea_local_git_url)"
|
||||
log "target repo: $GITEA_OWNER/$GITEA_REPO"
|
||||
log "mirror from: $GITEA_MIRROR_URL"
|
||||
|
||||
if ! test_remote_reachable "$GITEA_MIRROR_URL"; then
|
||||
warn "remote not reachable from this host — check git:// daemon / firewall"
|
||||
fi
|
||||
|
||||
if command -v rc-service >/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)"
|
||||
128
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_url_shortener.sh
Executable file
128
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_attach_url_shortener.sh
Executable file
@@ -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 <<EOF
|
||||
|
||||
url-shortener on Gitea:
|
||||
• ${GITEA_ROOT_URL%/}/$GITEA_OWNER/$GITEA_REPO
|
||||
• Org repo (private=$GITEA_PRIVATE) — visible to AndroidCast org members; Owners: $GITEA_ORG_OWNERS
|
||||
• Default branch: $GITEA_DEFAULT_BRANCH
|
||||
• Gitea protected branches:${_branches:+ }${_branches:- (none yet)}
|
||||
|
||||
Canonical FE git (optional pre-receive hook — same as android_cast):
|
||||
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
|
||||
|
||||
Re-sync: sudo ./gitea_sync_mirror.sh url-shortener
|
||||
|
||||
EOF
|
||||
22
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_cleanup_tmp.sh
Executable file
22
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_cleanup_tmp.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/ash
|
||||
#
|
||||
# Remove stale gitea script temp dirs under /tmp (mirror clones, work dirs, json).
|
||||
# Safe to run anytime; does not touch running jobs younger than GITEA_TMP_MAX_AGE_MIN.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./gitea_cleanup_tmp.sh
|
||||
# GITEA_TMP_MAX_AGE_MIN=0 sudo ./gitea_cleanup_tmp.sh # aggressive (all ages)
|
||||
#
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=gitea_mirror_lib.sh
|
||||
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
|
||||
|
||||
_before="$(find /tmp -maxdepth 1 \( -name 'gitea-migrate-*' -o -name 'gitea-submodules-*' \
|
||||
-o -name 'gitea-attach-*' -o -name 'gitea-mirror-*' -o -name 'gitea-*.json' \) 2>/dev/null | wc -l | tr -d ' ')"
|
||||
gitea_prune_stale_tmp
|
||||
_after="$(find /tmp -maxdepth 1 \( -name 'gitea-migrate-*' -o -name 'gitea-submodules-*' \
|
||||
-o -name 'gitea-attach-*' -o -name 'gitea-mirror-*' -o -name 'gitea-*.json' \) 2>/dev/null | wc -l | tr -d ' ')"
|
||||
printf '[gitea-cleanup] max_age_min=%s before=%s after=%s\n' \
|
||||
"${GITEA_TMP_MAX_AGE_MIN:-60}" "$_before" "$_after"
|
||||
251
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_collect_backup.sh
Executable file
251
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_collect_backup.sh
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/ash
|
||||
#
|
||||
# Collect full Gitea backup on Alpine BE (parse /etc/gitea/app.ini).
|
||||
# Run as root on the BE where Gitea is installed.
|
||||
#
|
||||
# Usage:
|
||||
# ./gitea_collect_backup.sh
|
||||
# OUT_BASE=/root/gitea-backups ./gitea_collect_backup.sh
|
||||
# GITEA_INI=/etc/gitea/app.ini ./gitea_collect_backup.sh
|
||||
#
|
||||
# Does NOT touch the main working tree:
|
||||
# /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast
|
||||
#
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=gitea_ini.sh
|
||||
. "$SCRIPT_DIR/gitea_ini.sh"
|
||||
|
||||
OUT_BASE="${OUT_BASE:-/var/backups/gitea}"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
OUT_DIR="$OUT_BASE/gitea-collect-$STAMP"
|
||||
MANIFEST="$OUT_DIR/MANIFEST.txt"
|
||||
|
||||
# Paths that must never be deleted or overwritten by the optional reset step.
|
||||
PROTECTED_MAIN_REPO="${PROTECTED_MAIN_REPO:-/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast}"
|
||||
|
||||
log() { printf '[gitea-collect] %s\n' "$*"; }
|
||||
warn() { printf '[gitea-collect] WARN: %s\n' "$*" >&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"
|
||||
120
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirror_sync.sh
Executable file
120
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirror_sync.sh
Executable file
@@ -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 <<EOF
|
||||
|
||||
Quick fixes:
|
||||
1) Manual sync now:
|
||||
sudo ./gitea_sync_mirror.sh $GITEA_REPO
|
||||
2) Sync all mirrors:
|
||||
sudo -u gitea gitea -c $GITEA_APP_INI admin repo-sync-mirrors
|
||||
3) Stuck queue ("already in queue"): Gitea Admin -> 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
|
||||
105
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirrors.sh
Executable file
105
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_diagnose_mirrors.sh
Executable file
@@ -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 <<EOF
|
||||
|
||||
If count=1 (only android_cast under AndroidCast or admin):
|
||||
• gitea_attach_remote.sh only mirrors the main repo — run submodule step:
|
||||
sudo ./gitea_migrate_project.sh . --skip-main
|
||||
or:
|
||||
sudo ./gitea_mirror_submodules.sh .gitmodules
|
||||
• If collect_entries is empty: sync .gitmodules to BE or use submodules.defaults.conf
|
||||
|
||||
Gitea UI: profile "Repositories N" may exclude mirrors on some versions —
|
||||
try Explore → search libvpx, or Site Administration → Repositories.
|
||||
|
||||
EOF
|
||||
264
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_ini.sh
Executable file
264
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_ini.sh
Executable file
@@ -0,0 +1,264 @@
|
||||
#!/bin/ash
|
||||
# Shared /etc/gitea/app.ini helpers for ash/bash (Alpine BE).
|
||||
# shellcheck shell=sh
|
||||
|
||||
gitea_ini_file="${GITEA_INI:-/etc/gitea/app.ini}"
|
||||
|
||||
# ini_get FILE SECTION KEY -> 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
|
||||
}
|
||||
|
||||
41
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_migrate_org_setup.sh
Executable file
41
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_migrate_org_setup.sh
Executable file
@@ -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 <<EOF
|
||||
|
||||
Org setup done.
|
||||
• Owners team (full org/repo control): $GITEA_ORG_OWNERS
|
||||
• Set password or SSH key for foxx: Gitea UI → foxx → Settings
|
||||
• Mirror all project repos under org:
|
||||
GITEA_OWNER=$GITEA_ORG sudo ./gitea_migrate_project.sh /path/to/android_cast
|
||||
|
||||
EOF
|
||||
321
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_migrate_project.sh
Executable file
321
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_migrate_project.sh
Executable file
@@ -0,0 +1,321 @@
|
||||
#!/bin/ash
|
||||
#
|
||||
# One-shot Gitea migration for android_cast:
|
||||
# 1) Pull-mirror main repo from FE git (git://10.7.0.10/android_cast) — fast interval
|
||||
# 2) Discover external git submodules (.gitmodules + pinned SHA at REF)
|
||||
# 3) Pull-mirror each upstream as read-only repo; verify project commit exists
|
||||
#
|
||||
# Canonical .gitmodules stays unchanged (upstream URLs). Use gitea_submodule_local_rewrite.sh
|
||||
# locally if clones should fetch submodules via Gitea.
|
||||
#
|
||||
# Usage (Alpine BE, as root):
|
||||
# sudo ./gitea_migrate_project.sh
|
||||
# sudo ./gitea_migrate_project.sh /var/www/.../androidcast_project/android_cast
|
||||
# GITEA_MIRROR_URL=git://10.7.0.10/android_cast GITEA_MAIN_MIRROR_INTERVAL=1m sudo ./gitea_migrate_project.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_submodule_lib.sh
|
||||
. "$SCRIPT_DIR/gitea_submodule_lib.sh"
|
||||
# shellcheck source=gitea_org_lib.sh
|
||||
. "$SCRIPT_DIR/gitea_org_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}"
|
||||
GITEA_MAIN_MIRROR_INTERVAL="${GITEA_MAIN_MIRROR_INTERVAL:-1m}"
|
||||
GITEA_SUBMODULE_MIRROR_INTERVAL="${GITEA_SUBMODULE_MIRROR_INTERVAL:-24h}"
|
||||
GITEA_PRIVATE="${GITEA_PRIVATE:-false}"
|
||||
GITEA_REF="${GITEA_REF:-HEAD}"
|
||||
GITEA_SKIP_MAIN="${GITEA_SKIP_MAIN:-0}"
|
||||
GITEA_SKIP_SUBMODULES="${GITEA_SKIP_SUBMODULES:-0}"
|
||||
GITEA_SKIP_ORG_SETUP="${GITEA_SKIP_ORG_SETUP:-0}"
|
||||
GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}"
|
||||
WORK_DIR="${WORK_DIR:-}"
|
||||
|
||||
log() { printf '[gitea-migrate] %s\n' "$*"; }
|
||||
warn() { printf '[gitea-migrate] WARN: %s\n' "$*" >&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 <<EOF
|
||||
|
||||
Fast sync for local main mirror ($GITEA_REPO only)
|
||||
-------------------------------------------------
|
||||
Gitea default minimum mirror interval is 10m. For 1m polling set in live app.ini:
|
||||
|
||||
[mirror]
|
||||
MIN_INTERVAL = 1m
|
||||
|
||||
[cron.update_mirrors]
|
||||
SCHEDULE = @every 1m
|
||||
RUN_AT_START = true
|
||||
|
||||
sudo rc-service gitea restart
|
||||
|
||||
Per-repo interval for $GITEA_REPO is already set to $GITEA_MAIN_MIRROR_INTERVAL by this script.
|
||||
|
||||
Immediate sync (push-triggered or cron every minute on BE):
|
||||
sudo $_sync_sh $GITEA_REPO
|
||||
|
||||
Sync all mirrors:
|
||||
sudo $_sync_sh --all
|
||||
|
||||
FE git post-receive hook (git on 10.7.0.10 → trigger BE sync):
|
||||
See $_hook
|
||||
Copy to FE bare repo hooks/post-receive and set GITEA_MIRROR_SYNC_CMD (ssh or curl).
|
||||
|
||||
Submodule mirrors use interval $GITEA_SUBMODULE_MIRROR_INTERVAL; pinned SHAs live in
|
||||
android_cast gitlinks — mirrors hold full upstream history so those commits are fetchable.
|
||||
|
||||
Local clone rewrite (optional, .gitmodules unchanged):
|
||||
$SCRIPT_DIR/gitea_submodule_local_rewrite.sh --apply
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- main ---
|
||||
|
||||
_repo_arg=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
sed -n '2,18p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
--skip-main) GITEA_SKIP_MAIN=1; shift ;;
|
||||
--skip-submodules) GITEA_SKIP_SUBMODULES=1; shift ;;
|
||||
--skip-org) GITEA_SKIP_ORG_SETUP=1; shift ;;
|
||||
--org-setup-only)
|
||||
gitea_load_config || exit 1
|
||||
gitea_ensure_cli_access || exit 1
|
||||
gitea_org_ensure_androidcast "$GITEA_ORG" || exit 1
|
||||
exit 0
|
||||
;;
|
||||
--ref)
|
||||
shift
|
||||
GITEA_REF="${1:-HEAD}"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
die "unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
_repo_arg="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
gitea_load_config || exit 1
|
||||
gitea_ensure_cli_access || exit 1
|
||||
|
||||
if [ "$GITEA_SKIP_ORG_SETUP" -eq 0 ]; then
|
||||
log "org setup: $GITEA_ORG (owners: $GITEA_ORG_OWNERS)"
|
||||
gitea_org_ensure_androidcast "$GITEA_ORG" || warn "org setup incomplete — continue with owner=$GITEA_OWNER"
|
||||
GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}"
|
||||
fi
|
||||
|
||||
if ! gitea_owner_exists "$GITEA_OWNER"; then
|
||||
_fb="$(gitea_pick_owner_username)"
|
||||
[ -n "$_fb" ] || die "no Gitea owner; set GITEA_OWNER=$GITEA_ORG or create org in UI"
|
||||
warn "owner $GITEA_OWNER not found; using $_fb"
|
||||
GITEA_OWNER="$_fb"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
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 ]; }
|
||||
377
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_lib.sh
Executable file
377
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_lib.sh
Executable file
@@ -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 <<EOF
|
||||
{
|
||||
"clone_addr": "$_clone_url",
|
||||
"repo_name": "$_repo",
|
||||
"uid": $_uid,
|
||||
"mirror": true,
|
||||
"mirror_interval": "$_interval",
|
||||
"private": $_private,
|
||||
"wiki": false,
|
||||
"issues": false,
|
||||
"milestones": false,
|
||||
"labels": false,
|
||||
"releases": true,
|
||||
"description": "$_desc"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
_resp="$(gitea_mktemp_file)"
|
||||
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
|
||||
-X POST "$_api/repos/migrate" \
|
||||
-H "Authorization: token $_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$_payload")"
|
||||
|
||||
if [ "$_http" = "201" ] || [ "$_http" = "200" ]; then
|
||||
gitea_rm_path "$_resp"
|
||||
return 0
|
||||
fi
|
||||
if [ "$_http" = "409" ] || [ "$_http" = "422" ]; then
|
||||
gitea_rm_path "$_resp"
|
||||
return 2
|
||||
fi
|
||||
if [ -r "$_resp" ]; then
|
||||
printf '[gitea-mirror] migrate HTTP %s: ' "$_http" >&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"
|
||||
}
|
||||
246
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_submodules.sh
Executable file
246
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_mirror_submodules.sh
Executable file
@@ -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 <<EOF
|
||||
$(cat "$WORK_DIR/entries.list")
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- main ---
|
||||
|
||||
gitea_load_config || exit 1
|
||||
gitea_ensure_cli_access || exit 1
|
||||
|
||||
_gitmodules_arg=""
|
||||
if [ $# -ge 1 ] && [ -n "$1" ]; then
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
sed -n '2,18p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
_gitmodules_arg="$1"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
gitea_org_ensure_androidcast "$GITEA_ORG" 2>/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 <<EOF
|
||||
|
||||
Keep upstream .gitmodules on canonical git (recommended):
|
||||
Gitea mirrors are independent; no remote .gitmodules edit required.
|
||||
After clone, point submodules at Gitea locally (stored in .git/config only):
|
||||
$_rewrite_sh --apply
|
||||
Or machine-wide url.insteadOf (any repo using the same upstream URLs):
|
||||
$_rewrite_sh --apply --global
|
||||
Then: git submodule update --init --recursive
|
||||
|
||||
Optional — rewrite committed .gitmodules on git://10.7.0.10/android_cast:
|
||||
Use the snippet above if every clone should default to Gitea URLs.
|
||||
|
||||
Clone main repo from Gitea:
|
||||
git clone $_main_repo_url
|
||||
cd android_cast && $_rewrite_sh --apply && git submodule update --init --recursive
|
||||
|
||||
Periodic sync all mirrors:
|
||||
sudo -u $GITEA_RUN_USER gitea -c $GITEA_APP_INI admin repo-sync-mirrors
|
||||
|
||||
EOF
|
||||
|
||||
[ "$_fail" -eq 0 ] || exit 1
|
||||
229
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_org_lib.sh
Normal file
229
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_org_lib.sh
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/bin/ash
|
||||
# Gitea organization, team, and user helpers (AndroidCast team layout).
|
||||
# shellcheck shell=sh
|
||||
# Requires: . gitea_ini.sh && gitea_load_config && . gitea_mirror_lib.sh
|
||||
|
||||
GITEA_ORG="${GITEA_ORG:-ac}"
|
||||
GITEA_ORG_FULL_NAME="${GITEA_ORG_FULL_NAME:-Android Cast (ac)}"
|
||||
GITEA_ORG_VISIBILITY="${GITEA_ORG_VISIBILITY:-public}"
|
||||
# Space-separated Gitea logins with org Owner (admin) team membership.
|
||||
GITEA_ORG_OWNERS="${GITEA_ORG_OWNERS:-admin foxx}"
|
||||
# Transfer mirrors from a personal account when re-homing under the org.
|
||||
GITEA_TRANSFER_FROM="${GITEA_TRANSFER_FROM:-admin}"
|
||||
|
||||
gitea_org_log() { printf '[gitea-org] %s\n' "$*"; }
|
||||
gitea_org_warn() { printf '[gitea-org] WARN: %s\n' "$*" >&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 <<EOF
|
||||
{
|
||||
"username": "$_org",
|
||||
"full_name": "$_full",
|
||||
"description": "Android Cast project mirrors and sources",
|
||||
"visibility": "$GITEA_ORG_VISIBILITY",
|
||||
"repo_admin_change_team_access": true
|
||||
}
|
||||
EOF
|
||||
)
|
||||
_resp="$(gitea_mktemp_file)"
|
||||
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
|
||||
-X POST "$_api/orgs" \
|
||||
-H "Authorization: token $_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$_payload")"
|
||||
_rc=1
|
||||
case "$_http" in
|
||||
201|200) _rc=0 ;;
|
||||
409|422)
|
||||
gitea_org_exists "$_org" && _rc=0
|
||||
;;
|
||||
esac
|
||||
gitea_rm_path "$_resp"
|
||||
return "$_rc"
|
||||
}
|
||||
|
||||
gitea_user_create_if_missing() {
|
||||
_user="$1"
|
||||
_email="${2:-${_user}@local}"
|
||||
if gitea_user_exists "$_user"; then
|
||||
gitea_org_log "user exists: $_user"
|
||||
return 0
|
||||
fi
|
||||
gitea_org_log "creating user $_user (password/SSH key: set later in Gitea UI)"
|
||||
_pw="setme-$(date +%s)-$$"
|
||||
if gitea_cli "admin" "user" "create" \
|
||||
"--username" "$_user" \
|
||||
"--email" "$_email" \
|
||||
"--password" "$_pw" \
|
||||
"--must-change-password" 2>/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
|
||||
}
|
||||
235
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_polish_project.sh
Executable file
235
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_polish_project.sh
Executable file
@@ -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 <<EOF
|
||||
|
||||
UI checklist:
|
||||
• foxx/admin: open ${GITEA_ROOT_URL%/}/$GITEA_ORG (org page — not only "Your repositories")
|
||||
• Expect: android_cast, url-shortener, libvpx, opus, speex under $GITEA_ORG
|
||||
• If android_cast still stale: sudo ./gitea_diagnose_mirror_sync.sh
|
||||
• Branch protection: master/next on Gitea; install FE hook:
|
||||
examples/git/hooks/pre-receive.android_cast.example
|
||||
• Email/2FA later: app.ini REGISTER_EMAIL_CONFIRM=false; add SMTP when ready
|
||||
|
||||
EOF
|
||||
|
||||
[ "$_main_ok" -eq 1 ] || exit 2
|
||||
172
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_protect_branches.sh
Executable file
172
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_protect_branches.sh
Executable file
@@ -0,0 +1,172 @@
|
||||
#!/bin/ash
|
||||
#
|
||||
# Server-side protection for master and next on Gitea (android_cast).
|
||||
# Blocks force-push for everyone; push/merge limited to org Owners (admin, foxx).
|
||||
# Side branches (feature/*, fix/*) are unaffected — no wildcard rule.
|
||||
#
|
||||
# Usage (BE as root):
|
||||
# sudo ./gitea_protect_branches.sh
|
||||
# GITEA_OWNER=AndroidCast GITEA_REPO=android_cast sudo ./gitea_protect_branches.sh
|
||||
# sudo ./gitea_protect_branches.sh --list
|
||||
#
|
||||
# Note: canonical git on FE (git://10.7.0.10/android_cast) needs its own hook —
|
||||
# see examples/git/hooks/pre-receive.android_cast.example
|
||||
#
|
||||
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}"
|
||||
# Space-separated branches to protect (exact names only).
|
||||
GITEA_PROTECT_BRANCHES="${GITEA_PROTECT_BRANCHES:-master next}"
|
||||
# Users allowed to push (fast-forward / merge) to protected branches.
|
||||
GITEA_PROTECT_PUSH_USERS="${GITEA_PROTECT_PUSH_USERS:-admin foxx}"
|
||||
# Org team with push access (AndroidCast Owners).
|
||||
GITEA_PROTECT_PUSH_TEAM="${GITEA_PROTECT_PUSH_TEAM:-Owners}"
|
||||
|
||||
log() { printf '[gitea-protect] %s\n' "$*"; }
|
||||
warn() { printf '[gitea-protect] WARN: %s\n' "$*" >&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 <<EOF
|
||||
{
|
||||
"rule_name": "$_branch",
|
||||
"branch_name": "$_branch",
|
||||
"priority": $_prio,
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": [$_users_json],
|
||||
"push_whitelist_teams": ["$GITEA_PROTECT_PUSH_TEAM"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"block_admin_merge_override": true
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
gitea_apply_protection() {
|
||||
_branch="$1"
|
||||
_prio="$2"
|
||||
command -v curl >/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 <<EOF
|
||||
|
||||
Gitea rules applied. Verify in UI:
|
||||
$GITEA_OWNER/$GITEA_REPO → Settings → Branches
|
||||
|
||||
Protected: master, next only
|
||||
• No force-push (anyone)
|
||||
• Push/merge: $GITEA_PROTECT_PUSH_USERS + team $GITEA_PROTECT_PUSH_TEAM
|
||||
• Admins cannot bypass (block_admin_merge_override)
|
||||
|
||||
IMPORTANT — canonical origin on FE is separate:
|
||||
Install examples/git/hooks/pre-receive.android_cast.example on the bare repo
|
||||
at git://10.7.0.10/android_cast (hooks/pre-receive).
|
||||
|
||||
EOF
|
||||
|
||||
[ "$_fail" -eq 0 ]
|
||||
58
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_set_default_branch.sh
Executable file
58
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_set_default_branch.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/ash
|
||||
#
|
||||
# Set default branch (next) on Gitea repos for UI clone/browse defaults.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./gitea_set_default_branch.sh
|
||||
# GITEA_DEFAULT_BRANCH=next GITEA_OWNER=AndroidCast sudo ./gitea_set_default_branch.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:-ac}}"
|
||||
GITEA_DEFAULT_BRANCH="${GITEA_DEFAULT_BRANCH:-next}"
|
||||
# Space-separated repo names; empty = all listed under owner.
|
||||
GITEA_REPO_NAMES="${GITEA_REPO_NAMES:-}"
|
||||
|
||||
log() { printf '[gitea-default-branch] %s\n' "$*"; }
|
||||
warn() { printf '[gitea-default-branch] WARN: %s\n' "$*" >&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 ]
|
||||
@@ -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
|
||||
}
|
||||
156
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_local_rewrite.sh
Executable file
156
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_submodule_local_rewrite.sh
Executable file
@@ -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
|
||||
63
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_sync_mirror.sh
Executable file
63
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_sync_mirror.sh
Executable file
@@ -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"
|
||||
49
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_user_admin.sh
Executable file
49
sim/cluster0/lab-seeds/backend/scripts/gitea/gitea_user_admin.sh
Executable file
@@ -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
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user